fix(execenv): support OpenClaw 2026.6.x agents schema (#3028) (#4319)

Adapts OpenClaw execenv prep to the 2026.6.x agents schema (agents.list config path removed; agents live in a sqlite registry). Case-insensitive key-missing guard + registry fallback on read, version-aware emission on write so per-task workspace pinning keeps working.

Closes #3028

MUL-3643
This commit is contained in:
jockibeard
2026-06-24 04:05:38 -06:00
committed by GitHub
parent a66f7ce8b1
commit 3adfaf4285
2 changed files with 234 additions and 18 deletions

View File

@@ -190,8 +190,9 @@ func prepareOpenclawConfig(envRoot, workDir string, opts OpenclawConfigPrep) (Op
}
var resolvedList []any
var agentsFromRegistry bool
if exists {
resolvedList, err = openclawResolvedAgentsList(bin, timeout)
resolvedList, agentsFromRegistry, err = openclawResolvedAgentsList(bin, timeout)
if err != nil {
return OpenclawConfigResult{}, fmt.Errorf("read openclaw agents.list: %w", err)
}
@@ -248,7 +249,7 @@ func prepareOpenclawConfig(envRoot, workDir string, opts OpenclawConfigPrep) (Op
}
}
cfg := buildPerTaskOpenclawConfig(activePath, exists, snapshotPath, resolvedList, workDir, managedMcp, hasManagedMcp, opts.Gateway)
cfg := buildPerTaskOpenclawConfig(activePath, exists, snapshotPath, resolvedList, agentsFromRegistry, workDir, managedMcp, hasManagedMcp, opts.Gateway)
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
@@ -304,12 +305,22 @@ func prepareOpenclawConfig(envRoot, workDir string, opts OpenclawConfigPrep) (Op
// snapshot $include has already dropped the user's `mcp` block, the
// resulting view of `mcp.servers` is exactly the managed set — including
// `{}` for "admin saved no servers" (mirrors `hasManagedCodexMcpConfig`).
func buildPerTaskOpenclawConfig(activePath string, exists bool, snapshotPath string, resolvedList []any, workDir string, managedMcp map[string]any, hasManagedMcp bool, gateway OpenclawGatewayPin) map[string]any {
func buildPerTaskOpenclawConfig(activePath string, exists bool, snapshotPath string, resolvedList []any, agentsFromRegistry bool, workDir string, managedMcp map[string]any, hasManagedMcp bool, gateway OpenclawGatewayPin) map[string]any {
agents := map[string]any{
"defaults": map[string]any{"workspace": workDir},
}
if rewritten := rewriteAgentsListWorkspaces(resolvedList, workDir); rewritten != nil {
agents["list"] = rewritten
// Only write per-agent overrides back to the wrapper when they came from
// the config-schema `agents.list` path (pre-2026.6). A registry-sourced
// list (OpenClaw 2026.6.x+) is NOT valid `agents.list[]` config — the
// schema validator rejects it ("agents.list.0: Invalid input") and fails
// closed before the agent runs. 2026.6.x has no in-config path for per-
// agent workspace pinning, so `agents.defaults.workspace` (set above) is
// the only knob, and it is sufficient: OpenClaw applies it to the agent it
// selects from the registry (see upstream #3028, write-side half).
if !agentsFromRegistry {
if rewritten := rewriteAgentsListWorkspaces(resolvedList, workDir); rewritten != nil {
agents["list"] = rewritten
}
}
cfg := map[string]any{
"agents": agents,
@@ -516,18 +527,76 @@ func openclawResolvedFullConfig(bin string, timeout time.Duration) (map[string]a
return cfg, nil
}
// openclawResolvedAgentsList fetches the user's resolved agents.list via
// `openclaw config get agents.list --json`. The CLI returns the post-
// include, post-env-substitution view of the array, which is exactly the
// shape we need to rewrite each entry's workspace.
// openclawResolvedAgentsList fetches the user's resolved per-agent list and
// reports which schema produced it. The schema matters downstream: a config-
// sourced list is itself valid `agents.list[]` config and may be written back
// into the wrapper to pin per-agent workspaces, whereas a registry-sourced
// list MUST NOT be written back — see openclawRegistryAgentsList.
//
// Returns nil (not an error) when agents.list is unset.
func openclawResolvedAgentsList(bin string, timeout time.Duration) ([]any, error) {
// Two schemas are supported:
//
// - Pre-2026.6: agents live in the config under `agents.list`. We read them
// via `openclaw config get agents.list --json`, which returns the post-
// include, post-env-substitution array. fromRegistry=false.
// - 2026.6.x and later: `agents.list` is no longer a config path — agents
// live in a sqlite registry. `config get agents.list` exits non-zero with
// "Config path not found: agents.list". We fall back to the
// `openclaw agents list --json` *subcommand*. fromRegistry=true.
//
// Returns (nil, false, nil) when neither source yields any agents.
func openclawResolvedAgentsList(bin string, timeout time.Duration) ([]any, bool, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
out, err := openclawExec(ctx, bin, "config", "get", "agents.list", "--json")
if err != nil {
if isOpenclawKeyMissing(err) {
// New schema: the config path is gone; the agents live in the
// sqlite registry. Resolve them via the subcommand instead.
list, rerr := openclawRegistryAgentsList(bin, timeout)
return list, true, rerr
}
return nil, false, err
}
trimmed := strings.TrimSpace(out)
if trimmed == "" || trimmed == "null" {
return nil, false, nil
}
var list []any
if err := json.Unmarshal([]byte(trimmed), &list); err != nil {
return nil, false, fmt.Errorf("parse `openclaw config get agents.list --json` output: %w", err)
}
return list, false, nil
}
// openclawRegistryAgentsList resolves agents from the sqlite-backed registry
// via `openclaw agents list --json` (OpenClaw 2026.6.x+).
//
// **The result is for read-side use only — it must never be written back into
// the wrapper as `agents.list`.** The registry entries carry CLI-only fields
// (identityName, identitySource, agentDir, bindings, isDefault) that are NOT
// part of the 2026.6.x config schema's `agents.list[]` shape; OpenClaw's
// validator rejects them ("agents.list.0: Invalid input") and fails closed
// before the agent runs. Worse, `agents.list` is no longer a valid config
// path at all in 2026.6.x — there is no in-config way to pin a per-agent
// workspace. The per-task workspace is instead pinned via
// `agents.defaults.workspace` alone, which the wrapper always sets and which
// OpenClaw applies to the agent it selects from the registry (verified on
// 2026.6.8). Callers gate the write-back on fromRegistry from
// openclawResolvedAgentsList.
//
// Returns nil (not an error) when the registry is empty or the subcommand
// reports no agents.
func openclawRegistryAgentsList(bin string, timeout time.Duration) ([]any, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
out, err := openclawExec(ctx, bin, "agents", "list", "--json")
if err != nil {
// Older OpenClaw builds may lack the subcommand entirely; treat an
// unrecognized/missing subcommand the same as "no agents to pin"
// rather than failing closed, since the defaults.workspace override
// alone still gives correct per-task skill discovery for the common
// single-agent case.
if isOpenclawKeyMissing(err) || isOpenclawUnknownSubcommand(err) {
return nil, nil
}
return nil, err
@@ -538,7 +607,7 @@ func openclawResolvedAgentsList(bin string, timeout time.Duration) ([]any, error
}
var list []any
if err := json.Unmarshal([]byte(trimmed), &list); err != nil {
return nil, fmt.Errorf("parse `openclaw config get agents.list --json` output: %w", err)
return nil, fmt.Errorf("parse `openclaw agents list --json` output: %w", err)
}
return list, nil
}
@@ -635,9 +704,32 @@ func isOpenclawKeyMissing(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "No value at ") ||
// Match case-insensitively: the CLI's "key not found" wording has drifted
// across versions and capitalization is not stable. Pre-2026.6 emitted
// "Path not found"; OpenClaw 2026.6.x emits "Config path not found:
// agents.list" (lowercase "path", "Config" prefix). A case-sensitive
// strings.Contains on "Path not found" silently stopped matching the
// 2026.6.x string, turning the intended graceful-skip into a fail-closed
// error that broke every OpenClaw 2026.6.x runtime (see upstream #3028).
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "no value at ") ||
strings.Contains(msg, "not set") ||
strings.Contains(msg, "missing key") ||
strings.Contains(msg, "Path not found")
strings.Contains(msg, "path not found")
}
// isOpenclawUnknownSubcommand returns true when the CLI error indicates the
// invoked subcommand/option does not exist on this OpenClaw build (e.g. an
// older release predating `openclaw agents list --json`). Used so the
// registry fallback degrades to "no agents to pin" rather than failing
// closed on builds that never had the subcommand.
func isOpenclawUnknownSubcommand(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "unknown command") ||
strings.Contains(msg, "unknown option") ||
strings.Contains(msg, "does not recognize") ||
strings.Contains(msg, "unknown argument")
}

View File

@@ -249,6 +249,11 @@ func TestPrepareOpenclawConfigKeyMissingTreatedAsEmpty(t *testing.T) {
stub := installOpenclawStub(t, map[string]openclawResponse{
"config file": {stdout: userConfigPath},
"config get agents.list --json": {err: errors.New("openclaw: No value at agents.list")},
// Pre-2026.6 single-agent installs with no per-agent overrides resolve
// to an empty registry once the config-path probe reports missing.
// (2026.6.x registry-population is covered by
// TestPrepareOpenclawConfigNewSchemaFallsBackToRegistry.)
"agents list --json": {stdout: "null"},
})
result, err := prepareOpenclawConfig(envRoot, workDir, OpenclawConfigPrep{OpenclawBin: stub.bin})
@@ -1110,7 +1115,7 @@ func TestBuildPerTaskOpenclawConfigOmitsGatewayWhenZero(t *testing.T) {
t.Parallel()
cfg := buildPerTaskOpenclawConfig(
"", false, "", nil, "/workdir", nil, false,
"", false, "", nil, false, "/workdir", nil, false,
OpenclawGatewayPin{},
)
if _, present := cfg["gateway"]; present {
@@ -1128,7 +1133,7 @@ func TestBuildPerTaskOpenclawConfigWritesGatewayBlock(t *testing.T) {
TLS: true,
}
cfg := buildPerTaskOpenclawConfig(
"", false, "", nil, "/workdir", nil, false,
"", false, "", nil, false, "/workdir", nil, false,
pin,
)
@@ -1167,7 +1172,7 @@ func TestBuildPerTaskOpenclawConfigPartialGatewayOmitsZeroFields(t *testing.T) {
// fields must not land in the wrapper as empty strings/zeros — that
// would override the user's value with junk.
cfg := buildPerTaskOpenclawConfig(
"", false, "", nil, "/workdir", nil, false,
"", false, "", nil, false, "/workdir", nil, false,
OpenclawGatewayPin{Host: "gw.internal", Port: 18789},
)
gw := cfg["gateway"].(map[string]any)
@@ -1178,3 +1183,122 @@ func TestBuildPerTaskOpenclawConfigPartialGatewayOmitsZeroFields(t *testing.T) {
t.Errorf("tls field must be omitted when false, got %v", gw["tls"])
}
}
// TestIsOpenclawKeyMissing covers the "key not found" wordings the CLI has
// emitted across versions. The 2026.6.x string ("Config path not found:
// agents.list", lowercase "path") is the regression from upstream #3028:
// the matcher used to compare case-sensitively against "Path not found" and
// silently stopped recognizing this, turning the intended graceful-skip
// into a fail-closed error that broke every OpenClaw 2026.6.x runtime.
func TestIsOpenclawKeyMissing(t *testing.T) {
t.Parallel()
cases := []struct {
name string
err error
want bool
}{
{"nil", nil, false},
{"pre-2026.6 No value at", errors.New("openclaw: No value at agents.list"), true},
{"pre-2026.6 Path not found", errors.New("openclaw config get agents.list --json: Path not found"), true},
{"not set", errors.New("agents.list is not set"), true},
{"missing key", errors.New("missing key: agents.list"), true},
{
"2026.6.x Config path not found (verbatim #3028)",
errors.New("openclaw config get agents.list --json: exit status 1 (stderr: Config path not found: agents.list. Run openclaw config validate to inspect config shape.)"),
true,
},
{"real failure stays an error", errors.New("openclaw: failed to read config: permission denied"), false},
{"malformed json is not a missing key", errors.New("parse output: invalid character 'x'"), false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := isOpenclawKeyMissing(tc.err); got != tc.want {
t.Errorf("isOpenclawKeyMissing(%v) = %v, want %v", tc.err, got, tc.want)
}
})
}
}
// TestPrepareOpenclawConfigNewSchemaOmitsAgentsList — OpenClaw 2026.6.x
// removed the `agents.list` config path; `config get agents.list` exits
// non-zero with "Config path not found" and the agents live in a sqlite
// registry reachable via the `openclaw agents list --json` subcommand.
//
// The preparer must (a) treat the config-path error as "missing, fall back"
// (read-side, #3028 first half) and (b) NOT write the registry-sourced agents
// back into the wrapper as `agents.list` (write-side, #3028 second half).
// `agents.list` is not a valid 2026.6.x config path — its schema validator
// rejects the registry shape ("agents.list.0: Invalid input") and fails
// closed before the agent runs. Per-task workspace pinning for the new schema
// rides on `agents.defaults.workspace` alone, which OpenClaw applies to the
// agent it selects from the registry.
func TestPrepareOpenclawConfigNewSchemaOmitsAgentsList(t *testing.T) {
envRoot := t.TempDir()
workDir := filepath.Join(envRoot, "workdir")
if err := os.MkdirAll(workDir, 0o755); err != nil {
t.Fatalf("mkdir workdir: %v", err)
}
userConfigPath := filepath.Join(t.TempDir(), "openclaw.json")
if err := os.WriteFile(userConfigPath, []byte(`{}`), 0o600); err != nil {
t.Fatalf("write user cfg: %v", err)
}
// Real registry shape from `openclaw agents list --json` on 2026.6.8 —
// carries CLI-only fields (identityName, agentDir, bindings, isDefault)
// that the config schema rejects if written back as agents.list[].
registry := `[{"id":"main","identityName":"Beau","identitySource":"identity","workspace":"/Users/cob/.openclaw/workspace","agentDir":"/Users/cob/.openclaw/agents/main/agent","model":"anthropic/claude-sonnet-4-6","bindings":0,"isDefault":true}]`
stub := installOpenclawStub(t, map[string]openclawResponse{
"config file": {stdout: userConfigPath},
// New-schema error, verbatim #3028 string.
"config get agents.list --json": {err: errors.New("openclaw config get agents.list --json: exit status 1 (stderr: Config path not found: agents.list. Run openclaw config validate to inspect config shape.)")},
// Registry subcommand returns the real agents.
"agents list --json": {stdout: registry},
})
result, err := prepareOpenclawConfig(envRoot, workDir, OpenclawConfigPrep{OpenclawBin: stub.bin})
if err != nil {
t.Fatalf("prepareOpenclawConfig: %v", err)
}
got := mustReadJSON(t, result.ConfigPath)
agents := got["agents"].(map[string]any)
if agents["defaults"].(map[string]any)["workspace"] != workDir {
t.Errorf("defaults.workspace not pinned to workDir")
}
if _, present := agents["list"]; present {
t.Fatalf("agents.list must be omitted for a registry-sourced (2026.6.x) host — OpenClaw rejects it; got %v", agents["list"])
}
}
// TestPrepareOpenclawConfigNewSchemaEmptyRegistry — new-schema config-path
// error plus an empty registry (`[]`) is the 2026.6.x equivalent of "no
// agents.list": emit defaults.workspace only, omit agents.list, no error.
func TestPrepareOpenclawConfigNewSchemaEmptyRegistry(t *testing.T) {
envRoot := t.TempDir()
workDir := filepath.Join(envRoot, "workdir")
if err := os.MkdirAll(workDir, 0o755); err != nil {
t.Fatalf("mkdir workdir: %v", err)
}
userConfigPath := filepath.Join(t.TempDir(), "openclaw.json")
if err := os.WriteFile(userConfigPath, []byte(`{}`), 0o600); err != nil {
t.Fatalf("write user cfg: %v", err)
}
stub := installOpenclawStub(t, map[string]openclawResponse{
"config file": {stdout: userConfigPath},
"config get agents.list --json": {err: errors.New("Config path not found: agents.list")},
"agents list --json": {stdout: "[]"},
})
result, err := prepareOpenclawConfig(envRoot, workDir, OpenclawConfigPrep{OpenclawBin: stub.bin})
if err != nil {
t.Fatalf("prepareOpenclawConfig: %v", err)
}
got := mustReadJSON(t, result.ConfigPath)
agents := got["agents"].(map[string]any)
if _, present := agents["list"]; present {
t.Errorf("agents.list should be omitted for empty registry, got %v", agents["list"])
}
if agents["defaults"].(map[string]any)["workspace"] != workDir {
t.Errorf("defaults.workspace not set")
}
}