diff --git a/server/internal/daemon/execenv/openclaw_config.go b/server/internal/daemon/execenv/openclaw_config.go index d87defc0e..40e1ae99b 100644 --- a/server/internal/daemon/execenv/openclaw_config.go +++ b/server/internal/daemon/execenv/openclaw_config.go @@ -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") } diff --git a/server/internal/daemon/execenv/openclaw_config_test.go b/server/internal/daemon/execenv/openclaw_config_test.go index dded2763b..a1970cb3a 100644 --- a/server/internal/daemon/execenv/openclaw_config_test.go +++ b/server/internal/daemon/execenv/openclaw_config_test.go @@ -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") + } +}