From 4bc1fd314b7550d4ac49360e2b83041663fea153 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Jul 2026 01:31:18 -0400 Subject: [PATCH] refactor(config): consolidate write preparation regression tests (#114332) --- src/config/io.write-prepare.test.ts | 3506 ++++++++------------------- 1 file changed, 1064 insertions(+), 2442 deletions(-) diff --git a/src/config/io.write-prepare.test.ts b/src/config/io.write-prepare.test.ts index 2291fa6e3085..6785401914fd 100644 --- a/src/config/io.write-prepare.test.ts +++ b/src/config/io.write-prepare.test.ts @@ -1,4 +1,4 @@ -// Covers config write preparation diffs and metadata preservation. +// Covers canonical config writes, roster migration, include ownership, and authored env refs. import { describe, expect, it, vi } from "vitest"; import { collectChangedPaths } from "./config-change-paths.js"; import { applyUnsetPathsForWrite } from "./config-path-mutation.js"; @@ -10,890 +10,681 @@ import type { OpenClawConfig } from "./types.js"; vi.unmock("../agents/agent-scope-config.js"); -describe("config io write prepare", () => { - it("ignores prototype-chain keys when building merge patches", () => { - // Discriminating fixture: `collision` is own on base and only inherited on - // target. With `key in target` the old code treated the inherited value as - // present and emitted it in the patch; Object.hasOwn deletes the own key. - const base = { - safe: { mode: "local" }, - collision: { mode: "owned-base" }, - }; - const target = Object.create({ - collision: { mode: "inherited-target" }, - }) as Record; - target.safe = { mode: "cloud" }; +type PersistInput = Parameters[0]; +type WriteCase = { + name: string; + current: unknown; + next: unknown; + source?: unknown; + authored?: unknown; + before?: unknown; + options?: Partial; + expected?: unknown; + error?: string; + verify?: (persisted: OpenClawConfig) => void; +}; +const main = { default: true }; +const worker = { workspace: "/srv/worker" }; +const roster = (entries: Record) => ({ agents: { entries } }); +const listRoster = (list: unknown[]) => ({ agents: { list } }); +const identityRef = { source: "env", provider: "default", id: "SSH_IDENTITY" }; +const runtimeSecretEntry = { + default: true, + sandbox: { ssh: { identityData: "resolved-private-key" } }, +}; +const authoredSecretEntry = { + default: true, + sandbox: { ssh: { identityData: identityRef } }, +}; + +const writeCases: WriteCase[] = [ + { + name: "persists caller changes onto resolved config without leaking runtime defaults", + current: { + gateway: { port: 18789 }, + agents: { defaults: { cliBackend: "codex" } }, + messages: { ackReaction: "eyes" }, + sessions: { persistence: true }, + }, + source: { gateway: { port: 18789 } }, + next: { gateway: { port: 18789, auth: { mode: "token" } } }, + expected: { gateway: { port: 18789, auth: { mode: "token" } } }, + }, + { + name: "persists the complete injected roster when a pre-roster config adds an agent", + current: { gateway: { mode: "local" }, ...roster({ main }) }, + authored: { gateway: { mode: "local" } }, + next: { gateway: { mode: "local" }, ...roster({ main, worker }) }, + expected: { gateway: { mode: "local" }, ...roster({ main, worker }) }, + }, + { + name: "preserves roster siblings for an explicit agent leaf write", + current: roster({ main, worker }), + next: roster({ main, worker: { ...worker, default: false } }), + options: { + explicitSetPaths: [["agents", "entries", "worker", "default"]], + explicitSetValueSource: roster({ worker: { default: false } }), + }, + expected: roster({ main, worker: { ...worker, default: false } }), + }, + { + name: "rejects a canonical roster rewrite that silently drops an entry", + current: roster({ main, worker }), + next: roster({ worker }), + error: "Config write would drop agent roster entries without an explicit deletion: main.", + }, + { + name: "allows an explicitly authorized agent deletion from the canonical roster", + current: roster({ main, worker }), + next: roster({ worker }), + options: { allowedAgentRosterRemovals: ["main"] }, + expected: roster({ worker }), + }, + { + name: "replaces a complete legacy list atomically when the roster changes", + current: roster({ main, ops: { workspace: "/srv/ops" } }), + authored: listRoster([ + { id: "main", ...main }, + { id: "ops", workspace: "/srv/ops" }, + ]), + next: roster({ main, ops: { workspace: "/srv/ops" }, worker }), + expected: roster({ main, ops: { workspace: "/srv/ops" }, worker }), + }, + { + name: "uses the complete next roster when an unrelated explicit value source is present", + current: roster({ main }), + next: { ...roster({ main, worker }), gateway: { port: 19001 } }, + options: { + explicitSetPaths: [["gateway", "port"]], + explicitSetValueSource: { gateway: { port: 19001 } }, + }, + expected: { ...roster({ main, worker }), gateway: { port: 19001 } }, + }, + { + name: "preserves non-roster siblings from an explicit agents parent write", + current: roster({ main }), + next: roster({ main, worker }), + options: { + explicitSetPaths: [["agents"]], + explicitSetValueSource: { + agents: { + defaults: { model: { primary: "openai/gpt-5.5" } }, + entries: { main, worker }, + }, + }, + }, + expected: { + agents: { + defaults: { model: { primary: "openai/gpt-5.5" } }, + entries: { main, worker }, + }, + }, + }, + { + name: "preserves authored env references while atomically replacing a legacy roster", + current: roster({ main: { ...main, agentDir: "/srv/main" } }), + authored: listRoster([{ id: "main", ...main, agentDir: "${MAIN_AGENT_DIR}" }]), + next: roster({ main: { ...main, agentDir: "/srv/main" }, worker }), + expected: roster({ main: { ...main, agentDir: "${MAIN_AGENT_DIR}" }, worker }), + }, + { + name: "preserves an unchanged env-backed default during an unrelated roster addition", + current: roster({ main: { ...main, agentDir: "/srv/main" } }), + authored: roster({ main: { default: "${MAIN_DEFAULT}", agentDir: "/srv/main" } }), + next: roster({ main: { ...main, agentDir: "/srv/main" }, worker }), + expected: roster({ main: { default: "${MAIN_DEFAULT}", agentDir: "/srv/main" }, worker }), + }, + { + name: "honors an explicit roster leaf write that equals the resolved runtime value", + current: roster({ main: { ...main, agentDir: "/srv/main" } }), + authored: roster({ main: { ...main, agentDir: "${MAIN_AGENT_DIR}" } }), + next: roster({ main: { ...main, agentDir: "/srv/main" } }), + options: { + explicitSetPaths: [["agents", "entries", "main", "agentDir"]], + explicitSetValueSource: roster({ main: { ...main, agentDir: "/srv/main" } }), + }, + expected: roster({ main: { ...main, agentDir: "/srv/main" } }), + }, + { + name: "honors explicit legacy-list leaves under an env-resolved agent id", + current: roster({ main: { ...main, agentDir: "/srv/main" } }), + before: listRoster([{ id: "main", ...main, agentDir: "/srv/main" }]), + authored: listRoster([{ id: "${AGENT_ID}", ...main, agentDir: "${MAIN_AGENT_DIR}" }]), + next: roster({ main: { ...main, agentDir: "/srv/main" } }), + options: { + explicitSetPaths: [["agents", "list", "0", "agentDir"]], + explicitSetValueSource: listRoster([{ id: "${AGENT_ID}", ...main, agentDir: "/srv/main" }]), + }, + expected: roster({ main: { ...main, agentDir: "/srv/main" } }), + }, + { + name: "preserves authored refs in a full legacy-list write with an env-backed id", + current: roster({ main: { ...main, agentDir: "/resolved/old" } }), + before: listRoster([{ id: "main", ...main, agentDir: "/resolved/old" }]), + authored: listRoster([{ id: "${AGENT_ID}", ...main, agentDir: "${OLD_DIR}" }]), + next: roster({ main: { ...main, agentDir: "/resolved/new" } }), + options: { + explicitSetPaths: [["agents", "list"]], + explicitSetValueSource: listRoster([{ id: "${AGENT_ID}", ...main, agentDir: "${NEW_DIR}" }]), + }, + expected: roster({ main: { ...main, agentDir: "${NEW_DIR}" } }), + }, + { + name: "rejects a whole-list write with an unmappable new env-backed id", + current: roster({ main }), + before: listRoster([{ id: "main", ...main }]), + authored: listRoster([{ id: "main", ...main }]), + next: roster({ main, worker: { workspace: "/resolved/worker" } }), + options: { + explicitSetPaths: [["agents", "list"]], + explicitSetValueSource: listRoster([ + { id: "main", ...main }, + { id: "${WORKER_ID}", workspace: "${WORKER_DIR}" }, + ]), + }, + error: "cannot safely resolve an explicitly replaced agent list slot", + }, + { + name: "keeps explicit legacy-list reorders keyed by each new item id", + current: roster({ main: { ...main, workspace: "/srv/main" }, ops: { workspace: "/srv/ops" } }), + before: listRoster([ + { id: "main", ...main, workspace: "/srv/main" }, + { id: "ops", workspace: "/srv/ops" }, + ]), + authored: listRoster([ + { id: "main", ...main, workspace: "/srv/main" }, + { id: "ops", workspace: "/srv/ops" }, + ]), + next: listRoster([ + { id: "ops", workspace: "/srv/ops" }, + { id: "main", ...main, workspace: "/srv/main" }, + ]), + options: { + explicitSetPaths: [["agents", "list"]], + explicitSetValueSource: listRoster([ + { id: "ops", workspace: "/srv/ops" }, + { id: "main", ...main, workspace: "/srv/main" }, + ]), + }, + expected: roster({ ops: { workspace: "/srv/ops" }, main: { ...main, workspace: "/srv/main" } }), + }, + { + name: "preserves unchanged authored array elements during partial roster changes", + current: roster({ main: { ...main, tools: { allow: ["read", "old"] } } }), + authored: roster({ main: { ...main, tools: { allow: ["${PRIMARY_TOOL}", "old"] } } }), + next: roster({ main: { ...main, tools: { allow: ["read", "new"] } } }), + expected: roster({ main: { ...main, tools: { allow: ["${PRIMARY_TOOL}", "new"] } } }), + }, + { + name: "preserves authored secret references in unchanged roster fields", + current: roster({ main: runtimeSecretEntry }), + source: roster({ main: authoredSecretEntry }), + authored: roster({ main: authoredSecretEntry }), + next: roster({ main: runtimeSecretEntry, worker }), + expected: roster({ main: authoredSecretEntry, worker }), + }, + { + name: "preserves an entry-internal include while atomically adding an agent", + current: roster({ main: { ...main, identity: { name: "Main", emoji: "🦞" } } }), + authored: roster({ main: { ...main, identity: { $include: "./identity.json" } } }), + next: roster({ main: { ...main, identity: { name: "Main", emoji: "🦞" } }, worker }), + expected: roster({ main: { ...main, identity: { $include: "./identity.json" } }, worker }), + }, + { + name: "preserves an entry-internal include authored in a legacy list while adding an agent", + current: roster({ main: { ...main, identity: { name: "Main", emoji: "🦞" } } }), + before: listRoster([{ id: "main", ...main, identity: { name: "Main", emoji: "🦞" } }]), + authored: listRoster([{ id: "main", ...main, identity: { $include: "./identity.json" } }]), + next: roster({ main: { ...main, identity: { name: "Main", emoji: "🦞" } }, worker }), + expected: roster({ main: { ...main, identity: { $include: "./identity.json" } }, worker }), + }, + { + name: "rejects a roster write when a legacy whole-entry include owns the agent id", + current: roster({ main }), + before: listRoster([{ id: "main", ...main }]), + authored: listRoster([{ $include: "./main-agent.json" }]), + next: roster({ main, worker }), + error: "flatten $include-owned config at agents", + }, + { + name: "rejects a roster write that changes an entry-internal included subtree", + current: roster({ main: { ...main, identity: { name: "Main", emoji: "🦞" } } }), + authored: roster({ main: { ...main, identity: { $include: "./identity.json" } } }), + next: roster({ main: { ...main, identity: { name: "Changed", emoji: "🦞" } }, worker }), + error: "flatten $include-owned config at agents.entries.main.identity", + }, + { + name: "preserves authored references when an agent id is renamed", + current: roster({ main: runtimeSecretEntry }), + source: roster({ main: authoredSecretEntry }), + before: listRoster([{ id: "main", ...authoredSecretEntry }]), + authored: listRoster([{ id: "main", ...authoredSecretEntry }]), + next: roster({ primary: runtimeSecretEntry }), + options: { + explicitSetPaths: [["agents", "list", "0", "id"]], + explicitSetValueSource: listRoster([{ id: "primary", ...runtimeSecretEntry }]), + allowedAgentRosterRemovals: ["main"], + }, + expected: roster({ primary: authoredSecretEntry }), + }, + { + name: "rejects an env-backed agent rename whose resolved identity is unavailable", + current: roster({ main }), + before: listRoster([{ id: "main", ...main }]), + authored: listRoster([{ id: "${OLD_AGENT_ID}", ...main }]), + next: roster({ ops: main }), + options: { + explicitSetPaths: [["agents", "list", "0", "id"]], + explicitSetValueSource: listRoster([{ id: "${NEW_AGENT_ID}", ...main }]), + }, + error: "cannot safely resolve an env-backed renamed agent id", + }, + { + name: "applies a legacy-list unset to the renamed canonical entry", + current: roster({ main: { ...main, workspace: "/srv/main" } }), + before: listRoster([{ id: "main", ...main, workspace: "/srv/main" }]), + authored: listRoster([{ id: "main", ...main, workspace: "/srv/main" }]), + next: roster({ primary: main }), + options: { + explicitSetPaths: [["agents", "list", "0", "id"]], + explicitSetValueSource: listRoster([{ id: "primary", ...main }]), + unsetPaths: [["agents", "list", "0", "workspace"]], + allowedAgentRosterRemovals: ["main"], + }, + expected: roster({ primary: main }), + }, + { + name: "applies an indexed unset after an explicit legacy-list reorder with a resolved id", + current: roster({ main: { ...main, workspace: "/srv/main" }, worker }), + source: listRoster([ + { id: "main", ...main, workspace: "/srv/main" }, + { id: "worker", ...worker }, + ]), + before: listRoster([ + { id: "main", ...main, workspace: "/srv/main" }, + { id: "worker", ...worker }, + ]), + authored: listRoster([ + { id: "main", ...main, workspace: "/srv/main" }, + { id: "${WORKER_ID}", ...worker }, + ]), + next: roster({ worker: {}, main: { ...main, workspace: "/srv/main" } }), + options: { + explicitSetPaths: [["agents", "list"]], + explicitSetValueSource: listRoster([ + { id: "${WORKER_ID}" }, + { id: "main", ...main, workspace: "/srv/main" }, + ]), + unsetPaths: [["agents", "list", "0", "workspace"]], + }, + expected: roster({ worker: {}, main: { ...main, workspace: "/srv/main" } }), + }, + { + name: "removes the explicitly reordered list slot instead of the surviving agent", + current: roster({ main, "0": worker }), + source: listRoster([ + { id: "main", ...main }, + { id: "0", ...worker }, + ]), + authored: listRoster([ + { id: "main", ...main }, + { id: "0", ...worker }, + ]), + next: roster({ main }), + options: { + explicitSetPaths: [["agents", "list"]], + explicitSetValueSource: listRoster([ + { id: "0", ...worker }, + { id: "main", ...main }, + ]), + unsetPaths: [["agents", "list", "0"]], + allowedAgentRosterRemovals: ["0"], + }, + expected: roster({ main }), + }, + { + name: "rejects an unprovable newly introduced environment-backed id", + current: roster({ main, worker_id: { workspace: "/srv/existing" } }), + source: listRoster([ + { id: "main", ...main }, + { id: "worker_id", workspace: "/srv/existing" }, + ]), + authored: listRoster([ + { id: "main", ...main }, + { id: "worker_id", workspace: "/srv/existing" }, + ]), + next: roster({ "new-worker": {}, worker_id: { workspace: "/srv/existing" }, main }), + options: { + explicitSetPaths: [["agents", "list"]], + explicitSetValueSource: listRoster([ + { id: "${WORKER_ID}" }, + { id: "worker_id", workspace: "/srv/existing" }, + { id: "main", ...main }, + ]), + unsetPaths: [["agents", "list", "0", "workspace"]], + }, + error: "cannot safely resolve an explicitly replaced agent list slot", + }, + { + name: "rejects an indexed unset across duplicate explicit list ids", + current: roster({ worker: { workspace: "/old" } }), + source: listRoster([{ id: "worker", workspace: "/old" }]), + before: listRoster([{ id: "worker", workspace: "/old" }]), + authored: listRoster([{ id: "${WORKER_ID}", workspace: "/old" }]), + next: roster({ worker: { workspace: "/second" } }), + options: { + explicitSetPaths: [["agents", "list"]], + explicitSetValueSource: listRoster([ + { id: "${WORKER_ID}", workspace: "/first" }, + { id: "worker", workspace: "/second" }, + ]), + unsetPaths: [["agents", "list", "0"]], + }, + error: 'cannot canonicalize duplicate normalized agent id "worker"', + }, + { + name: "rejects duplicate normalized ids in an explicit legacy-list value source", + current: roster({ main }), + before: listRoster([{ id: "main", ...main }]), + authored: listRoster([{ id: "main", ...main }]), + next: roster({ main }), + options: { + explicitSetPaths: [["agents", "list"]], + explicitSetValueSource: listRoster([{ id: "Ops", ...main }, { id: " ops " }]), + }, + error: 'Config write cannot canonicalize duplicate normalized agent id "ops".', + }, + { + name: "keys legacy authored references by the pre-migration resolved agent id", + current: roster({ main: runtimeSecretEntry }), + source: roster({ main: authoredSecretEntry }), + before: listRoster([{ id: "main", ...authoredSecretEntry }]), + authored: listRoster([{ id: "${AGENT_ID}", ...authoredSecretEntry }]), + next: roster({ main: runtimeSecretEntry, worker }), + expected: roster({ main: authoredSecretEntry, worker }), + }, + { + name: "rejects ambiguous one-for-one replacements with authored references", + current: roster({ main: runtimeSecretEntry }), + source: roster({ main: authoredSecretEntry }), + authored: roster({ main: authoredSecretEntry }), + next: roster({ worker: { ...main, ...worker } }), + error: "cannot safely match renamed agent entries", + }, + { + name: "keeps the normalized default when authored legacy input marked it false", + current: roster({ main, ops: { workspace: "/srv/ops" } }), + before: listRoster([ + { id: "main", default: false }, + { id: "ops", workspace: "/srv/ops" }, + ]), + authored: listRoster([ + { id: "main", default: false }, + { id: "ops", workspace: "/srv/ops" }, + ]), + next: roster({ main, ops: { workspace: "/srv/ops" }, worker }), + expected: roster({ main, ops: { workspace: "/srv/ops" }, worker }), + }, + { + name: "preserves authored references when roster arrays shift", + current: roster({ main: { ...main, tools: { allow: ["read", "old"] } } }), + authored: roster({ main: { ...main, tools: { allow: ["${PRIMARY_TOOL}", "old"] } } }), + next: roster({ main: { ...main, tools: { allow: ["new", "read", "old"] } } }), + expected: roster({ main: { ...main, tools: { allow: ["new", "${PRIMARY_TOOL}", "old"] } } }), + }, + { + name: "does not reuse an authored array reference after its source element was consumed", + current: roster({ main: { ...main, tools: { allow: ["a", "b"] } } }), + authored: roster({ main: { ...main, tools: { allow: ["${TOOL_A}", "${TOOL_B}"] } } }), + next: roster({ main: { ...main, tools: { allow: ["b", "b"] } } }), + expected: roster({ main: { ...main, tools: { allow: ["${TOOL_B}", "b"] } } }), + }, + { + name: "rejects unsetting an id inside a legacy list entry", + current: roster({ main }), + authored: listRoster([{ id: "main", ...main }]), + next: roster({ main }), + options: { unsetPaths: [["agents", "list", "0", "id"]] }, + error: "cannot unset an agent id", + }, + { + name: "does not resurrect an authored roster removed from the complete next config", + current: { agents: { defaults: { workspace: "/srv/default" }, entries: { main } } }, + next: { agents: { defaults: { workspace: "/srv/default" } } }, + expected: { agents: { defaults: { workspace: "/srv/default" } } }, + }, + { + name: "allows roster writes beside unrelated root includes using pre-migration provenance", + current: roster({ main }), + before: { channels: { telegram: { enabled: true } } }, + authored: { $include: "./channels.json" }, + next: roster({ main, worker }), + expected: { $include: "./channels.json", ...roster({ main, worker }) }, + }, + { + name: "preserves an authored legacy list when a non-roster field changes", + current: { ...roster({ main, ops: { workspace: "/srv/ops" } }), gateway: { port: 18789 } }, + authored: { + ...listRoster([ + { id: "main", ...main }, + { id: "ops", workspace: "/srv/ops" }, + ]), + gateway: { port: 18789 }, + }, + next: { ...roster({ main, ops: { workspace: "/srv/ops" } }), gateway: { port: 19001 } }, + expected: { + ...listRoster([ + { id: "main", ...main }, + { id: "ops", workspace: "/srv/ops" }, + ]), + gateway: { port: 19001 }, + }, + }, + { + name: "preserves untouched include-owned subtrees during unrelated writes", + current: { agents: { defaults: { model: "openai/gpt-5.4" } }, gateway: { mode: "local" } }, + authored: { agents: { $include: "./config/agents.json" }, gateway: { mode: "local" } }, + next: { + agents: { defaults: { model: "openai/gpt-5.4" } }, + gateway: { mode: "local", port: 18789 }, + }, + expected: { + agents: { $include: "./config/agents.json" }, + gateway: { mode: "local", port: 18789 }, + }, + }, + { + name: "allows removing root-authored sibling keys beside an include", + current: { gateway: { mode: "local", legacyKey: true } }, + authored: { gateway: { $include: "./config/gateway.json", legacyKey: true } }, + next: { gateway: { mode: "local" } }, + expected: { gateway: { $include: "./config/gateway.json" } }, + }, + { + name: "allows nested root-authored sibling edits without flattening included values", + current: { gateway: { mode: "local", auth: { mode: "token", token: "old" } } }, + authored: { gateway: { $include: "./config/gateway.json", auth: { token: "old" } } }, + next: { gateway: { mode: "local", auth: { mode: "none", token: "new", strategy: "strict" } } }, + expected: { + gateway: { + $include: "./config/gateway.json", + auth: { token: "new", mode: "none", strategy: "strict" }, + }, + }, + }, + { + name: "does not copy runtime-normalized include values into root-authored siblings", + current: { gateway: { tls: { certPath: "/home/test/cert.pem", enabled: false } } }, + source: { gateway: { tls: { certPath: "~/cert.pem", enabled: false } } }, + authored: { gateway: { $include: "./config/gateway.json", tls: { enabled: false } } }, + next: { gateway: { tls: { certPath: "~/cert.pem", enabled: true } } }, + expected: { gateway: { $include: "./config/gateway.json", tls: { enabled: true } } }, + }, + { + name: "rejects included-value edits beside root-authored sibling edits", + current: { gateway: { mode: "local", legacyKey: "old" } }, + authored: { gateway: { $include: "./config/gateway.json", legacyKey: "old" } }, + next: { gateway: { mode: "remote", legacyKey: "new" } }, + error: "Config write would flatten $include-owned config at gateway", + }, + { + name: "preserves include-owned array entries across runtime-only normalization", + current: { + ...listRoster([{ id: "main", workspace: "/home/test/agent" }]), + gateway: { mode: "local" }, + }, + source: { ...listRoster([{ id: "main", workspace: "~/agent" }]), gateway: { mode: "local" } }, + authored: { + ...listRoster([{ $include: "./config/main-agent.json" }]), + gateway: { mode: "local" }, + }, + next: { + ...listRoster([{ id: "main", workspace: "~/agent" }]), + gateway: { mode: "local", port: 18789 }, + }, + expected: { + ...listRoster([{ $include: "./config/main-agent.json" }]), + gateway: { mode: "local", port: 18789 }, + }, + }, + { + name: "rejects roster edits beside an include-owned array entry", + current: listRoster([ + { id: "main", workspace: "~/agent" }, + { id: "ops", workspace: "~/ops" }, + ]), + authored: listRoster([ + { $include: "./config/main-agent.json" }, + { id: "ops", workspace: "~/ops" }, + ]), + next: listRoster([ + { id: "main", workspace: "~/agent" }, + { id: "ops", workspace: "~/ops-next" }, + { id: "new", workspace: "~/new" }, + ]), + error: "Config write would flatten $include-owned config at agents", + }, + { + name: "rejects writes that change include-owned array entries", + current: listRoster([{ id: "main", workspace: "~/agent" }]), + authored: listRoster([{ $include: "./config/main-agent.json" }]), + next: listRoster([{ id: "main", workspace: "~/other-agent" }]), + error: "Config write would flatten $include-owned config at agents", + }, + { + name: "rejects array shifts when an included value has a duplicate sibling", + current: { plugins: { load: { paths: ["/same", "/same"] } } }, + authored: { plugins: { load: { paths: [{ $include: "./path.json5" }, "/same"] } } }, + next: { plugins: { load: { paths: ["/same"] } } }, + error: "Config write would flatten $include-owned config at plugins.load.paths.0", + }, + { + name: "allows unrelated removals after duplicate include-resolved values", + current: { plugins: { load: { paths: ["/same", "/same", "/other"] } } }, + authored: { + plugins: { load: { paths: [{ $include: "./path.json5" }, "/same", "/other"] } }, + }, + next: { plugins: { load: { paths: ["/same", "/same"] } } }, + expected: { plugins: { load: { paths: [{ $include: "./path.json5" }, "/same"] } } }, + }, + { + name: "rejects included-entry removals hidden by duplicate sibling edits", + current: { plugins: { load: { paths: ["/same", "/same", "/old"] } } }, + authored: { + plugins: { load: { paths: [{ $include: "./path.json5" }, "/same", "/old"] } }, + }, + next: { plugins: { load: { paths: ["/same", "/new"] } } }, + error: "Config write would flatten $include-owned config at plugins.load.paths.0", + }, + { + name: "rejects newly introduced duplicates of include-owned array entries", + current: { plugins: { load: { paths: ["/root", "/included"] } } }, + authored: { plugins: { load: { paths: ["/root", { $include: "./path.json5" }] } } }, + next: { plugins: { load: { paths: ["/included", "/included"] } } }, + error: "Config write would flatten $include-owned config at plugins.load.paths.1", + }, + { + name: "rejects writes that would flatten include-owned subtrees", + current: { agents: { defaults: { model: "openai/gpt-5.4" } } }, + authored: { agents: { $include: "./config/agents.json" } }, + next: { agents: { defaults: { model: "anthropic/sonnet-4.5" } } }, + error: "Config write would flatten $include-owned config at agents", + }, + { + name: "preserves root $schema during unrelated partial writes", + current: { $schema: "https://openclaw.ai/config.json", gateway: { mode: "local" } }, + next: { gateway: { mode: "local", port: 18789 } }, + expected: { + $schema: "https://openclaw.ai/config.json", + gateway: { mode: "local", port: 18789 }, + }, + }, + { + name: "rejects writes that would flatten a root include", + current: { + $schema: "https://openclaw.ai/config-from-include.json", + gateway: { mode: "local" }, + }, + authored: { $include: "./extra.json5", gateway: { mode: "local" } }, + next: { gateway: { mode: "local", port: 18789 } }, + error: "Config write would flatten $include-owned config at ", + }, + { + name: "does not restore root $schema when the next config explicitly clears it", + current: { $schema: "https://openclaw.ai/config.json", gateway: { mode: "local" } }, + next: { $schema: null, gateway: { mode: "local", port: 18789 } }, + expected: { gateway: { mode: "local", port: 18789 } }, + }, + { + name: "does not restore root $schema when the next config sets an invalid value", + current: { $schema: "https://openclaw.ai/config.json", gateway: { mode: "local" } }, + next: { $schema: 123, gateway: { mode: "local", port: 18789 } }, + expected: { $schema: 123, gateway: { mode: "local", port: 18789 } }, + }, +]; + +function resolveWriteCase(testCase: WriteCase): OpenClawConfig { + return resolvePersistCandidateForWrite({ + runtimeConfig: testCase.current, + sourceConfig: testCase.source ?? testCase.current, + nextConfig: testCase.next, + ...(testCase.authored === undefined ? {} : { rootAuthoredConfig: testCase.authored }), + ...(testCase.before === undefined ? {} : { sourceConfigBeforeMigrations: testCase.before }), + ...testCase.options, + }) as OpenClawConfig; +} + +describe("config io write prepare", () => { + it.each(writeCases)("$name", (testCase) => { + if (testCase.error) { + expect(() => resolveWriteCase(testCase)).toThrow(testCase.error); + return; + } + const persisted = resolveWriteCase(testCase); + expect(persisted).toEqual(testCase.expected); + testCase.verify?.(persisted); + }); + + it("ignores prototype-chain keys when building merge patches", () => { + const base = { safe: { mode: "local" }, collision: { mode: "owned-base" } }; + const target = Object.create({ collision: { mode: "inherited-target" } }) as Record< + string, + unknown + >; + target.safe = { mode: "cloud" }; expect(createMergePatch(base, target)).toEqual({ safe: { mode: "cloud" }, collision: null, }); }); - it("persists caller changes onto resolved config without leaking runtime defaults", () => { - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig: { - gateway: { port: 18789 }, - agents: { defaults: { cliBackend: "codex" } }, - messages: { ackReaction: "eyes" }, - sessions: { persistence: true }, - }, - sourceConfig: { - gateway: { port: 18789 }, - }, - nextConfig: { - gateway: { - port: 18789, - auth: { mode: "token" }, - }, - }, - }) as Record; - - expect(persisted.gateway).toEqual({ - port: 18789, - auth: { mode: "token" }, - }); - expect(persisted).not.toHaveProperty("agents.defaults"); - expect(persisted).not.toHaveProperty("messages.ackReaction"); - expect(persisted).not.toHaveProperty("sessions.persistence"); - }); - - it("persists the complete injected roster when a pre-roster config adds an agent", () => { - const current = { - gateway: { mode: "local" }, - agents: { entries: { main: { default: true } } }, - }; - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig: current, - sourceConfig: current, - rootAuthoredConfig: { gateway: { mode: "local" } }, - nextConfig: { - ...current, - agents: { - entries: { - main: { default: true }, - worker: { workspace: "/srv/worker" }, - }, - }, - }, - }) as OpenClawConfig; - - expect(persisted.agents).toEqual({ - entries: { - main: { default: true }, - worker: { workspace: "/srv/worker" }, - }, - }); - }); - - it("preserves roster siblings for an explicit agent leaf write", () => { - const current = { - agents: { - entries: { - main: { default: true }, - worker: { workspace: "/srv/worker" }, - }, - }, - } satisfies OpenClawConfig; - const nextConfig = { - agents: { - entries: { - main: { default: true }, - worker: { workspace: "/srv/worker", default: false }, - }, - }, - } satisfies OpenClawConfig; - - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig: current, - sourceConfig: current, - rootAuthoredConfig: current, - nextConfig, - explicitSetPaths: [["agents", "entries", "worker", "default"]], - explicitSetValueSource: { - agents: { entries: { worker: { default: false } } }, - }, - }) as OpenClawConfig; - - expect(persisted.agents?.entries).toEqual({ - main: { default: true }, - worker: { workspace: "/srv/worker", default: false }, - }); - }); - - it("rejects a canonical roster rewrite that silently drops an entry", () => { - const current = { - agents: { - entries: { - main: { default: true }, - worker: { workspace: "/srv/worker" }, - }, - }, - } satisfies OpenClawConfig; - - expect(() => - resolvePersistCandidateForWrite({ - runtimeConfig: current, - sourceConfig: current, - rootAuthoredConfig: current, - nextConfig: { - agents: { entries: { worker: { workspace: "/srv/worker" } } }, - }, - }), - ).toThrow("Config write would drop agent roster entries without an explicit deletion: main."); - }); - - it("allows an explicitly authorized agent deletion from the canonical roster", () => { - const current = { - agents: { - entries: { - main: { default: true }, - worker: { workspace: "/srv/worker" }, - }, - }, - } satisfies OpenClawConfig; - - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig: current, - sourceConfig: current, - rootAuthoredConfig: current, - nextConfig: { - agents: { entries: { worker: { workspace: "/srv/worker" } } }, - }, - allowedAgentRosterRemovals: ["main"], - }) as OpenClawConfig; - - expect(persisted.agents?.entries).toEqual({ - worker: { workspace: "/srv/worker" }, - }); - }); - - it("replaces a complete legacy list atomically when the roster changes", () => { - const entries = { - main: { default: true }, - ops: { workspace: "/srv/ops" }, - }; - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig: { agents: { entries } }, - sourceConfig: { agents: { entries } }, - rootAuthoredConfig: { - agents: { - list: [ - { id: "main", default: true }, - { id: "ops", workspace: "/srv/ops" }, - ], - }, - }, - nextConfig: { - agents: { - entries: { - ...entries, - worker: { workspace: "/srv/worker" }, - }, - }, - }, - }) as OpenClawConfig; - - expect(persisted.agents).toEqual({ - entries: { - main: { default: true }, - ops: { workspace: "/srv/ops" }, - worker: { workspace: "/srv/worker" }, - }, - }); - expect(persisted.agents).not.toHaveProperty("list"); - }); - - it("uses the complete next roster when an unrelated explicit value source is present", () => { - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig: { agents: { entries: { main: { default: true } } } }, - sourceConfig: { agents: { entries: { main: { default: true } } } }, - rootAuthoredConfig: { agents: { entries: { main: { default: true } } } }, - nextConfig: { - agents: { - entries: { - main: { default: true }, - worker: { workspace: "/srv/worker" }, - }, - }, - gateway: { port: 19001 }, - }, - explicitSetPaths: [["gateway", "port"]], - explicitSetValueSource: { gateway: { port: 19001 } }, - }) as OpenClawConfig; - - expect(persisted.agents?.entries).toEqual({ - main: { default: true }, - worker: { workspace: "/srv/worker" }, - }); - expect(persisted.gateway?.port).toBe(19001); - }); - - it("preserves non-roster siblings from an explicit agents parent write", () => { - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig: { agents: { entries: { main: { default: true } } } }, - sourceConfig: { agents: { entries: { main: { default: true } } } }, - rootAuthoredConfig: { agents: { entries: { main: { default: true } } } }, - nextConfig: { - agents: { - entries: { - main: { default: true }, - worker: { workspace: "/srv/worker" }, - }, - }, - }, - explicitSetPaths: [["agents"]], - explicitSetValueSource: { - agents: { - defaults: { model: { primary: "openai/gpt-5.5" } }, - entries: { - main: { default: true }, - worker: { workspace: "/srv/worker" }, - }, - }, - }, - }) as OpenClawConfig; - - expect(persisted.agents?.defaults?.model).toEqual({ primary: "openai/gpt-5.5" }); - expect(persisted.agents?.entries).toHaveProperty("worker"); - }); - - it("preserves authored env references while atomically replacing a legacy roster", () => { - const resolvedMain = { default: true, agentDir: "/srv/main" }; - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig: { agents: { entries: { main: resolvedMain } } }, - sourceConfig: { agents: { entries: { main: resolvedMain } } }, - rootAuthoredConfig: { - agents: { - list: [{ id: "main", default: true, agentDir: "${MAIN_AGENT_DIR}" }], - }, - }, - nextConfig: { - agents: { - entries: { - main: resolvedMain, - worker: { workspace: "/srv/worker" }, - }, - }, - }, - }) as OpenClawConfig; - - expect(persisted.agents).toEqual({ - entries: { - main: { default: true, agentDir: "${MAIN_AGENT_DIR}" }, - worker: { workspace: "/srv/worker" }, - }, - }); - }); - - it("preserves an unchanged env-backed default during an unrelated roster addition", () => { - const resolvedMain = { default: true, agentDir: "/srv/main" }; - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig: { agents: { entries: { main: resolvedMain } } }, - sourceConfig: { agents: { entries: { main: resolvedMain } } }, - rootAuthoredConfig: { - agents: { - entries: { - main: { default: "${MAIN_DEFAULT}", agentDir: "/srv/main" }, - }, - }, - }, - nextConfig: { - agents: { - entries: { - main: resolvedMain, - worker: { workspace: "/srv/worker" }, - }, - }, - }, - }) as OpenClawConfig; - - expect(persisted.agents?.entries?.main?.default).toBe("${MAIN_DEFAULT}"); - }); - - it("honors an explicit roster leaf write that equals the resolved runtime value", () => { - const resolvedMain = { default: true, agentDir: "/srv/main" }; - const nextConfig = { agents: { entries: { main: resolvedMain } } }; - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig: nextConfig, - sourceConfig: nextConfig, - rootAuthoredConfig: { - agents: { entries: { main: { default: true, agentDir: "${MAIN_AGENT_DIR}" } } }, - }, - nextConfig, - explicitSetPaths: [["agents", "entries", "main", "agentDir"]], - explicitSetValueSource: nextConfig, - }) as OpenClawConfig; - - expect(persisted.agents?.entries?.main?.agentDir).toBe("/srv/main"); - }); - - it("honors explicit legacy-list leaves under an env-resolved agent id", () => { - const resolvedMain = { default: true, agentDir: "/srv/main" }; - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig: { agents: { entries: { main: resolvedMain } } }, - sourceConfig: { agents: { entries: { main: resolvedMain } } }, - sourceConfigBeforeMigrations: { - agents: { list: [{ id: "main", ...resolvedMain }] }, - }, - rootAuthoredConfig: { - agents: { - list: [{ id: "${AGENT_ID}", default: true, agentDir: "${MAIN_AGENT_DIR}" }], - }, - }, - nextConfig: { agents: { entries: { main: resolvedMain } } }, - explicitSetPaths: [["agents", "list", "0", "agentDir"]], - explicitSetValueSource: { - agents: { list: [{ id: "${AGENT_ID}", ...resolvedMain }] }, - }, - }) as OpenClawConfig; - - expect(persisted.agents?.entries?.main?.agentDir).toBe("/srv/main"); - }); - - it("preserves authored refs in a full legacy-list write with an env-backed id", () => { - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig: { - agents: { entries: { main: { default: true, agentDir: "/resolved/old" } } }, - }, - sourceConfig: { - agents: { entries: { main: { default: true, agentDir: "/resolved/old" } } }, - }, - sourceConfigBeforeMigrations: { - agents: { list: [{ id: "main", default: true, agentDir: "/resolved/old" }] }, - }, - rootAuthoredConfig: { - agents: { - list: [{ id: "${AGENT_ID}", default: true, agentDir: "${OLD_DIR}" }], - }, - }, - nextConfig: { - agents: { entries: { main: { default: true, agentDir: "/resolved/new" } } }, - }, - explicitSetPaths: [["agents", "list"]], - explicitSetValueSource: { - agents: { - list: [{ id: "${AGENT_ID}", default: true, agentDir: "${NEW_DIR}" }], - }, - }, - }) as OpenClawConfig; - - expect(persisted.agents?.entries).toEqual({ - main: { default: true, agentDir: "${NEW_DIR}" }, - }); - }); - - it("rejects a whole-list write with an unmappable new env-backed id", () => { - expect(() => - resolvePersistCandidateForWrite({ - runtimeConfig: { - agents: { entries: { main: { default: true } } }, - }, - sourceConfig: { - agents: { entries: { main: { default: true } } }, - }, - sourceConfigBeforeMigrations: { - agents: { list: [{ id: "main", default: true }] }, - }, - rootAuthoredConfig: { - agents: { list: [{ id: "main", default: true }] }, - }, - nextConfig: { - agents: { - entries: { - main: { default: true }, - worker: { workspace: "/resolved/worker" }, - }, - }, - }, - explicitSetPaths: [["agents", "list"]], - explicitSetValueSource: { - agents: { - list: [ - { id: "main", default: true }, - { id: "${WORKER_ID}", workspace: "${WORKER_DIR}" }, - ], - }, - }, - }), - ).toThrow("cannot safely resolve an explicitly replaced agent list slot"); - }); - - it("keeps explicit legacy-list reorders keyed by each new item id", () => { - const main = { id: "main", default: true, workspace: "/srv/main" }; - const ops = { id: "ops", workspace: "/srv/ops" }; - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig: { - agents: { - entries: { - main: { default: true, workspace: "/srv/main" }, - ops: { workspace: "/srv/ops" }, - }, - }, - }, - sourceConfig: { - agents: { - entries: { - main: { default: true, workspace: "/srv/main" }, - ops: { workspace: "/srv/ops" }, - }, - }, - }, - sourceConfigBeforeMigrations: { agents: { list: [main, ops] } }, - rootAuthoredConfig: { agents: { list: [main, ops] } }, - nextConfig: { agents: { list: [ops, main] } }, - explicitSetPaths: [["agents", "list"]], - explicitSetValueSource: { agents: { list: [ops, main] } }, - }) as OpenClawConfig; - - expect(persisted.agents?.entries).toEqual({ - ops: { workspace: "/srv/ops" }, - main: { default: true, workspace: "/srv/main" }, - }); - }); - - it("preserves unchanged authored array elements during partial roster changes", () => { - const runtimeMain = { - default: true, - tools: { allow: ["read", "old"] }, - }; - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig: { agents: { entries: { main: runtimeMain } } }, - sourceConfig: { agents: { entries: { main: runtimeMain } } }, - rootAuthoredConfig: { - agents: { - entries: { - main: { - default: true, - tools: { allow: ["${PRIMARY_TOOL}", "old"] }, - }, - }, - }, - }, - nextConfig: { - agents: { - entries: { - main: { default: true, tools: { allow: ["read", "new"] } }, - }, - }, - }, - }) as OpenClawConfig; - - expect(persisted.agents?.entries?.main?.tools?.allow).toEqual(["${PRIMARY_TOOL}", "new"]); - }); - - it("preserves authored secret references in unchanged roster fields", () => { - const identityRef = { source: "env", provider: "default", id: "SSH_IDENTITY" }; - const runtimeMain = { - default: true, - sandbox: { ssh: { identityData: "resolved-private-key" } }, - }; - const sourceMain = { - default: true, - sandbox: { ssh: { identityData: identityRef } }, - }; - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig: { agents: { entries: { main: runtimeMain } } }, - sourceConfig: { agents: { entries: { main: sourceMain } } }, - rootAuthoredConfig: { agents: { entries: { main: sourceMain } } }, - nextConfig: { - agents: { - entries: { - main: runtimeMain, - worker: { workspace: "/srv/worker" }, - }, - }, - }, - }) as OpenClawConfig; - - expect(persisted.agents?.entries?.main?.sandbox?.ssh?.identityData).toEqual(identityRef); - }); - - it("preserves an entry-internal include while atomically adding an agent", () => { - const resolvedMain = { - default: true, - identity: { name: "Main", emoji: "🦞" }, - }; - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig: { agents: { entries: { main: resolvedMain } } }, - sourceConfig: { agents: { entries: { main: resolvedMain } } }, - sourceConfigBeforeMigrations: { agents: { entries: { main: resolvedMain } } }, - rootAuthoredConfig: { - agents: { - entries: { - main: { default: true, identity: { $include: "./identity.json" } }, - }, - }, - }, - nextConfig: { - agents: { - entries: { - main: resolvedMain, - worker: { workspace: "/srv/worker" }, - }, - }, - }, - }) as OpenClawConfig; - - expect(persisted.agents?.entries).toEqual({ - main: { default: true, identity: { $include: "./identity.json" } }, - worker: { workspace: "/srv/worker" }, - }); - }); - - it("preserves an entry-internal include authored in a legacy list while adding an agent", () => { - const resolvedMain = { - id: "main", - default: true, - identity: { name: "Main", emoji: "🦞" }, - }; - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig: { - agents: { entries: { main: { default: true, identity: resolvedMain.identity } } }, - }, - sourceConfig: { - agents: { entries: { main: { default: true, identity: resolvedMain.identity } } }, - }, - sourceConfigBeforeMigrations: { agents: { list: [resolvedMain] } }, - rootAuthoredConfig: { - agents: { - list: [{ id: "main", default: true, identity: { $include: "./identity.json" } }], - }, - }, - nextConfig: { - agents: { - entries: { - main: { default: true, identity: resolvedMain.identity }, - worker: { workspace: "/srv/worker" }, - }, - }, - }, - }) as OpenClawConfig; - - expect(persisted.agents?.list).toBeUndefined(); - expect(persisted.agents?.entries).toEqual({ - main: { default: true, identity: { $include: "./identity.json" } }, - worker: { workspace: "/srv/worker" }, - }); - }); - - it("rejects a roster write when a legacy whole-entry include owns the agent id", () => { - expect(() => - resolvePersistCandidateForWrite({ - runtimeConfig: { agents: { entries: { main: { default: true } } } }, - sourceConfig: { agents: { entries: { main: { default: true } } } }, - sourceConfigBeforeMigrations: { - agents: { list: [{ id: "main", default: true }] }, - }, - rootAuthoredConfig: { - agents: { list: [{ $include: "./main-agent.json" }] }, - }, - nextConfig: { - agents: { - entries: { - main: { default: true }, - worker: { workspace: "/srv/worker" }, - }, - }, - }, - }), - ).toThrow("flatten $include-owned config at agents"); - }); - - it("rejects a roster write that changes an entry-internal included subtree", () => { - const resolvedMain = { - default: true, - identity: { name: "Main", emoji: "🦞" }, - }; - expect(() => - resolvePersistCandidateForWrite({ - runtimeConfig: { agents: { entries: { main: resolvedMain } } }, - sourceConfig: { agents: { entries: { main: resolvedMain } } }, - sourceConfigBeforeMigrations: { agents: { entries: { main: resolvedMain } } }, - rootAuthoredConfig: { - agents: { - entries: { - main: { default: true, identity: { $include: "./identity.json" } }, - }, - }, - }, - nextConfig: { - agents: { - entries: { - main: { default: true, identity: { name: "Changed", emoji: "🦞" } }, - worker: { workspace: "/srv/worker" }, - }, - }, - }, - }), - ).toThrow("flatten $include-owned config at agents.entries.main.identity"); - }); - - it("preserves authored references when an agent id is renamed", () => { - const identityRef = { source: "env", provider: "default", id: "SSH_IDENTITY" }; - const runtimeEntry = { - default: true, - sandbox: { ssh: { identityData: "resolved-private-key" } }, - }; - const sourceEntry = { - default: true, - sandbox: { ssh: { identityData: identityRef } }, - }; - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig: { agents: { entries: { main: runtimeEntry } } }, - sourceConfig: { agents: { entries: { main: sourceEntry } } }, - sourceConfigBeforeMigrations: { - agents: { list: [{ id: "main", ...sourceEntry }] }, - }, - rootAuthoredConfig: { - agents: { list: [{ id: "main", ...sourceEntry }] }, - }, - nextConfig: { agents: { entries: { primary: runtimeEntry } } }, - explicitSetPaths: [["agents", "list", "0", "id"]], - explicitSetValueSource: { - agents: { list: [{ id: "primary", ...runtimeEntry }] }, - }, - allowedAgentRosterRemovals: ["main"], - }) as OpenClawConfig; - - expect(persisted.agents?.entries?.primary?.sandbox?.ssh?.identityData).toEqual(identityRef); - expect(persisted.agents?.entries?.main).toBeUndefined(); - }); - - it("rejects an env-backed agent rename whose resolved identity is unavailable", () => { - expect(() => - resolvePersistCandidateForWrite({ - runtimeConfig: { agents: { entries: { main: { default: true } } } }, - sourceConfig: { agents: { entries: { main: { default: true } } } }, - sourceConfigBeforeMigrations: { - agents: { list: [{ id: "main", default: true }] }, - }, - rootAuthoredConfig: { - agents: { list: [{ id: "${OLD_AGENT_ID}", default: true }] }, - }, - nextConfig: { agents: { entries: { ops: { default: true } } } }, - explicitSetPaths: [["agents", "list", "0", "id"]], - explicitSetValueSource: { - agents: { list: [{ id: "${NEW_AGENT_ID}", default: true }] }, - }, - }), - ).toThrow("cannot safely resolve an env-backed renamed agent id"); - }); - - it("applies a legacy-list unset to the renamed canonical entry", () => { - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig: { - agents: { entries: { main: { default: true, workspace: "/srv/main" } } }, - }, - sourceConfig: { - agents: { entries: { main: { default: true, workspace: "/srv/main" } } }, - }, - sourceConfigBeforeMigrations: { - agents: { list: [{ id: "main", default: true, workspace: "/srv/main" }] }, - }, - rootAuthoredConfig: { - agents: { list: [{ id: "main", default: true, workspace: "/srv/main" }] }, - }, - nextConfig: { agents: { entries: { primary: { default: true } } } }, - explicitSetPaths: [["agents", "list", "0", "id"]], - explicitSetValueSource: { - agents: { list: [{ id: "primary", default: true }] }, - }, - unsetPaths: [["agents", "list", "0", "workspace"]], - allowedAgentRosterRemovals: ["main"], - }) as OpenClawConfig; - - expect(persisted.agents?.entries).toEqual({ primary: { default: true } }); - }); - - it("applies an indexed unset after an explicit legacy-list reorder with a resolved id", () => { - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig: { - agents: { - entries: { - main: { default: true, workspace: "/srv/main" }, - worker: { workspace: "/srv/worker" }, - }, - }, - }, - sourceConfig: { - agents: { - list: [ - { id: "main", default: true, workspace: "/srv/main" }, - { id: "worker", workspace: "/srv/worker" }, - ], - }, - }, - sourceConfigBeforeMigrations: { - agents: { - list: [ - { id: "main", default: true, workspace: "/srv/main" }, - { id: "worker", workspace: "/srv/worker" }, - ], - }, - }, - rootAuthoredConfig: { - agents: { - list: [ - { id: "main", default: true, workspace: "/srv/main" }, - { id: "${WORKER_ID}", workspace: "/srv/worker" }, - ], - }, - }, - nextConfig: { - agents: { - entries: { - worker: {}, - main: { default: true, workspace: "/srv/main" }, - }, - }, - }, - explicitSetPaths: [["agents", "list"]], - explicitSetValueSource: { - agents: { - list: [{ id: "${WORKER_ID}" }, { id: "main", default: true, workspace: "/srv/main" }], - }, - }, - unsetPaths: [["agents", "list", "0", "workspace"]], - }) as OpenClawConfig; - - expect(persisted.agents?.entries).toEqual({ - worker: {}, - main: { default: true, workspace: "/srv/main" }, - }); - }); - - it("removes the explicitly reordered list slot instead of the surviving agent", () => { - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig: { - agents: { - entries: { - main: { default: true }, - "0": { workspace: "/srv/worker" }, - }, - }, - }, - sourceConfig: { - agents: { - list: [ - { id: "main", default: true }, - { id: "0", workspace: "/srv/worker" }, - ], - }, - }, - rootAuthoredConfig: { - agents: { - list: [ - { id: "main", default: true }, - { id: "0", workspace: "/srv/worker" }, - ], - }, - }, - nextConfig: { agents: { entries: { main: { default: true } } } }, - explicitSetPaths: [["agents", "list"]], - explicitSetValueSource: { - agents: { - list: [ - { id: "0", workspace: "/srv/worker" }, - { id: "main", default: true }, - ], - }, - }, - unsetPaths: [["agents", "list", "0"]], - allowedAgentRosterRemovals: ["0"], - }) as OpenClawConfig; - - expect(persisted.agents?.entries).toEqual({ main: { default: true } }); - }); - - it("rejects an unprovable newly introduced environment-backed id", () => { - expect(() => - resolvePersistCandidateForWrite({ - runtimeConfig: { - agents: { - entries: { - main: { default: true }, - worker_id: { workspace: "/srv/existing" }, - }, - }, - }, - sourceConfig: { - agents: { - list: [ - { id: "main", default: true }, - { id: "worker_id", workspace: "/srv/existing" }, - ], - }, - }, - rootAuthoredConfig: { - agents: { - list: [ - { id: "main", default: true }, - { id: "worker_id", workspace: "/srv/existing" }, - ], - }, - }, - nextConfig: { - agents: { - entries: { - "new-worker": {}, - worker_id: { workspace: "/srv/existing" }, - main: { default: true }, - }, - }, - }, - explicitSetPaths: [["agents", "list"]], - explicitSetValueSource: { - agents: { - list: [ - { id: "${WORKER_ID}" }, - { id: "worker_id", workspace: "/srv/existing" }, - { id: "main", default: true }, - ], - }, - }, - unsetPaths: [["agents", "list", "0", "workspace"]], - }), - ).toThrow("cannot safely resolve an explicitly replaced agent list slot"); - }); - - it("rejects an indexed unset across duplicate explicit list ids", () => { - expect(() => - resolvePersistCandidateForWrite({ - runtimeConfig: { agents: { entries: { worker: { workspace: "/old" } } } }, - sourceConfig: { agents: { list: [{ id: "worker", workspace: "/old" }] } }, - sourceConfigBeforeMigrations: { - agents: { list: [{ id: "worker", workspace: "/old" }] }, - }, - rootAuthoredConfig: { - agents: { list: [{ id: "${WORKER_ID}", workspace: "/old" }] }, - }, - nextConfig: { agents: { entries: { worker: { workspace: "/second" } } } }, - explicitSetPaths: [["agents", "list"]], - explicitSetValueSource: { - agents: { - list: [ - { id: "${WORKER_ID}", workspace: "/first" }, - { id: "worker", workspace: "/second" }, - ], - }, - }, - unsetPaths: [["agents", "list", "0"]], - }), - ).toThrow('cannot canonicalize duplicate normalized agent id "worker"'); - }); - it("rejects duplicate normalized ids before canonicalizing a legacy roster", () => { - const nextConfig = { - agents: { - list: [ - { id: "Ops", workspace: "/first" }, - { id: " ops ", workspace: "/second" }, - ], - }, - }; + const nextConfig = listRoster([ + { id: "Ops", workspace: "/first" }, + { id: " ops ", workspace: "/second" }, + ]); const before = structuredClone(nextConfig); - expect(() => resolvePersistCandidateForWrite({ runtimeConfig: {}, @@ -911,663 +702,199 @@ describe("config io write prepare", () => { expect(nextConfig).toEqual(before); }); - it("rejects duplicate normalized ids in an explicit legacy-list value source", () => { - expect(() => - resolvePersistCandidateForWrite({ - runtimeConfig: { agents: { entries: { main: { default: true } } } }, - sourceConfig: { agents: { entries: { main: { default: true } } } }, - sourceConfigBeforeMigrations: { - agents: { list: [{ id: "main", default: true }] }, - }, - rootAuthoredConfig: { - agents: { list: [{ id: "main", default: true }] }, - }, - nextConfig: { agents: { entries: { main: { default: true } } } }, - explicitSetPaths: [["agents", "list"]], - explicitSetValueSource: { - agents: { - list: [{ id: "Ops", default: true }, { id: " ops " }], - }, - }, - }), - ).toThrowError( - expect.objectContaining({ - name: "DuplicateAgentRosterIdError", - message: 'Config write cannot canonicalize duplicate normalized agent id "ops".', - }), - ); - }); - - it("keys legacy authored references by the pre-migration resolved agent id", () => { - const identityRef = { source: "env", provider: "default", id: "SSH_IDENTITY" }; - const runtimeEntry = { - default: true, - sandbox: { ssh: { identityData: "resolved-private-key" } }, - }; - const sourceEntry = { - default: true, - sandbox: { ssh: { identityData: identityRef } }, - }; - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig: { agents: { entries: { main: runtimeEntry } } }, - sourceConfig: { agents: { entries: { main: sourceEntry } } }, - sourceConfigBeforeMigrations: { - agents: { list: [{ id: "main", ...sourceEntry }] }, - }, - rootAuthoredConfig: { - agents: { list: [{ id: "${AGENT_ID}", ...sourceEntry }] }, - }, - nextConfig: { - agents: { - entries: { - main: runtimeEntry, - worker: { workspace: "/srv/worker" }, - }, - }, - }, - }) as OpenClawConfig; - - expect(persisted.agents?.entries?.main?.sandbox?.ssh?.identityData).toEqual(identityRef); - }); - - it("rejects ambiguous one-for-one replacements with authored references", () => { - const identityRef = { source: "env", provider: "default", id: "SSH_IDENTITY" }; - expect(() => - resolvePersistCandidateForWrite({ - runtimeConfig: { - agents: { - entries: { - main: { - default: true, - sandbox: { ssh: { identityData: "resolved-private-key" } }, - }, - }, - }, - }, - sourceConfig: { - agents: { - entries: { - main: { default: true, sandbox: { ssh: { identityData: identityRef } } }, - }, - }, - }, - rootAuthoredConfig: { - agents: { - entries: { - main: { default: true, sandbox: { ssh: { identityData: identityRef } } }, - }, - }, - }, - nextConfig: { - agents: { - entries: { - worker: { default: true, workspace: "/srv/worker" }, - }, - }, - }, - }), - ).toThrow("cannot safely match renamed agent entries"); - }); - - it("keeps the normalized default when authored legacy input marked it false", () => { - const normalizedEntries = { - main: { default: true }, - ops: { workspace: "/srv/ops" }, - }; - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig: { agents: { entries: normalizedEntries } }, - sourceConfig: { agents: { entries: normalizedEntries } }, - sourceConfigBeforeMigrations: { - agents: { - list: [ - { id: "main", default: false }, - { id: "ops", workspace: "/srv/ops" }, - ], - }, - }, - rootAuthoredConfig: { - agents: { - list: [ - { id: "main", default: false }, - { id: "ops", workspace: "/srv/ops" }, - ], - }, - }, - nextConfig: { - agents: { - entries: { - ...normalizedEntries, - worker: { workspace: "/srv/worker" }, - }, - }, - }, - }) as OpenClawConfig; - - expect(persisted.agents?.entries?.main?.default).toBe(true); - expect( - Object.values(persisted.agents?.entries ?? {}).filter((entry) => entry.default === true), - ).toHaveLength(1); - }); - - it("preserves authored references when roster arrays shift", () => { - const runtimeMain = { - default: true, - tools: { allow: ["read", "old"] }, - }; - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig: { agents: { entries: { main: runtimeMain } } }, - sourceConfig: { agents: { entries: { main: runtimeMain } } }, - rootAuthoredConfig: { - agents: { - entries: { - main: { - default: true, - tools: { allow: ["${PRIMARY_TOOL}", "old"] }, - }, - }, - }, - }, - nextConfig: { - agents: { - entries: { - main: { default: true, tools: { allow: ["new", "read", "old"] } }, - }, - }, - }, - }) as OpenClawConfig; - - expect(persisted.agents?.entries?.main?.tools?.allow).toEqual([ - "new", - "${PRIMARY_TOOL}", - "old", + it.each([ + { + name: "translates a legacy list unset before canonicalizing the roster", + canonicalSource: false, + }, + { + name: "translates a legacy list unset after the source roster has been canonicalized", + canonicalSource: true, + }, + ])("$name", ({ canonicalSource }) => { + const entries = { main, worker }; + const legacy = listRoster([ + { id: "main", ...main }, + { id: "worker", ...worker }, ]); - }); - - it("does not reuse an authored array reference after its source element was consumed", () => { - const runtimeMain = { - default: true, - tools: { allow: ["a", "b"] }, - }; - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig: { agents: { entries: { main: runtimeMain } } }, - sourceConfig: { agents: { entries: { main: runtimeMain } } }, - rootAuthoredConfig: { - agents: { - entries: { - main: { - default: true, - tools: { allow: ["${TOOL_A}", "${TOOL_B}"] }, - }, - }, - }, - }, - nextConfig: { - agents: { - entries: { - main: { default: true, tools: { allow: ["b", "b"] } }, - }, - }, - }, - }) as OpenClawConfig; - - expect(persisted.agents?.entries?.main?.tools?.allow).toEqual(["${TOOL_B}", "b"]); - }); - - it("translates a legacy list unset before canonicalizing the roster", () => { - const persisted = applyUnsetPathsForWrite( - resolvePersistCandidateForWrite({ - runtimeConfig: { - agents: { - entries: { - main: { default: true }, - worker: { workspace: "/srv/worker" }, - }, - }, - }, - sourceConfig: { - agents: { - list: [ - { id: "main", default: true }, - { id: "worker", workspace: "/srv/worker" }, - ], - }, - }, - rootAuthoredConfig: { - agents: { - list: [ - { id: "main", default: true }, - { id: "worker", workspace: "/srv/worker" }, - ], - }, - }, - nextConfig: { - agents: { - entries: { - main: { default: true }, - worker: { workspace: "/srv/worker" }, - }, - }, - }, - unsetPaths: [["agents", "list", "1"]], - allowedAgentRosterRemovals: ["worker"], - }) as OpenClawConfig, - [["agents", "list", "1"]], - ); - - expect(persisted.agents).toEqual({ entries: { main: { default: true } } }); - }); - - it("rejects unsetting an id inside a legacy list entry", () => { - expect(() => - resolvePersistCandidateForWrite({ - runtimeConfig: { agents: { entries: { main: { default: true } } } }, - sourceConfig: { agents: { entries: { main: { default: true } } } }, - rootAuthoredConfig: { - agents: { list: [{ id: "main", default: true }] }, - }, - nextConfig: { agents: { entries: { main: { default: true } } } }, - unsetPaths: [["agents", "list", "0", "id"]], - }), - ).toThrow("cannot unset an agent id"); + const unsetPaths = [["agents", "list", "1"]]; + expect( + applyUnsetPathsForWrite( + resolvePersistCandidateForWrite({ + runtimeConfig: roster(entries), + sourceConfig: canonicalSource ? roster(entries) : legacy, + ...(canonicalSource ? { sourceConfigBeforeMigrations: legacy } : {}), + rootAuthoredConfig: legacy, + nextConfig: roster(entries), + unsetPaths, + allowedAgentRosterRemovals: ["worker"], + }) as OpenClawConfig, + unsetPaths, + ), + ).toEqual(roster({ main })); }); it("omits canonical entries when the complete legacy list is unset", () => { + const unsetPaths = [["agents", "list"]]; const persisted = applyUnsetPathsForWrite( resolvePersistCandidateForWrite({ - runtimeConfig: { agents: { entries: { main: { default: true } } } }, - sourceConfig: { agents: { entries: { main: { default: true } } } }, - rootAuthoredConfig: { - agents: { list: [{ id: "main", default: true }] }, - }, - nextConfig: { agents: { entries: { main: { default: true } } } }, - unsetPaths: [["agents", "list"]], + runtimeConfig: roster({ main }), + sourceConfig: roster({ main }), + rootAuthoredConfig: listRoster([{ id: "main", ...main }]), + nextConfig: roster({ main }), + unsetPaths, allowedAgentRosterRemovals: ["main"], }) as OpenClawConfig, - [["agents", "list"]], + unsetPaths, ); - - expect(persisted.agents?.list).toBeUndefined(); - expect(persisted.agents?.entries).toBeUndefined(); - }); - - it("does not resurrect an authored roster removed from the complete next config", () => { - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig: { - agents: { - defaults: { workspace: "/srv/default" }, - entries: { main: { default: true } }, - }, - }, - sourceConfig: { - agents: { - defaults: { workspace: "/srv/default" }, - entries: { main: { default: true } }, - }, - }, - rootAuthoredConfig: { - agents: { - defaults: { workspace: "/srv/default" }, - entries: { main: { default: true } }, - }, - }, - nextConfig: { agents: { defaults: { workspace: "/srv/default" } } }, - }) as OpenClawConfig; - - expect(persisted.agents?.entries).toBeUndefined(); - expect(persisted.agents?.defaults?.workspace).toBe("/srv/default"); - }); - - it("allows roster writes beside unrelated root includes using pre-migration provenance", () => { - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig: { agents: { entries: { main: { default: true } } } }, - sourceConfig: { agents: { entries: { main: { default: true } } } }, - sourceConfigBeforeMigrations: { channels: { telegram: { enabled: true } } }, - rootAuthoredConfig: { $include: "./channels.json" }, - nextConfig: { - agents: { - entries: { - main: { default: true }, - worker: { workspace: "/srv/worker" }, - }, - }, - }, - }) as OpenClawConfig; - - expect(persisted).toMatchObject({ - $include: "./channels.json", - agents: { - entries: { - main: { default: true }, - worker: { workspace: "/srv/worker" }, - }, - }, - }); - }); - - it("preserves an authored legacy list when a non-roster field changes", () => { - const list = [ - { id: "main", default: true }, - { id: "ops", workspace: "/srv/ops" }, - ]; - const entries = { - main: { default: true }, - ops: { workspace: "/srv/ops" }, - }; - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig: { agents: { entries }, gateway: { port: 18789 } }, - sourceConfig: { agents: { entries }, gateway: { port: 18789 } }, - rootAuthoredConfig: { agents: { list }, gateway: { port: 18789 } }, - nextConfig: { agents: { entries }, gateway: { port: 19001 } }, - }) as OpenClawConfig; - - expect(persisted.agents?.list).toEqual(list); + expect(persisted.agents).not.toHaveProperty("list"); expect(persisted.agents).not.toHaveProperty("entries"); - expect(persisted.gateway?.port).toBe(19001); }); it("strips transient plugin install records from partial writes", () => { + const install = { + source: "npm", + spec: "@ollama/openclaw-web-search", + installPath: "/tmp/openclaw-web-search", + resolvedName: "@ollama/openclaw-web-search", + resolvedVersion: "0.2.2", + }; const persisted = applyUnsetPathsForWrite( resolvePersistCandidateForWrite({ - runtimeConfig: { - plugins: { - entries: {}, - }, - }, - sourceConfig: { - plugins: { - entries: {}, - installs: { - "openclaw-web-search": { - source: "npm", - spec: "@ollama/openclaw-web-search", - installPath: "/tmp/openclaw-web-search", - resolvedName: "@ollama/openclaw-web-search", - resolvedVersion: "0.2.2", - }, - }, - }, - }, + runtimeConfig: { plugins: { entries: {} } }, + sourceConfig: { plugins: { entries: {}, installs: { "openclaw-web-search": install } } }, nextConfig: { plugins: { entries: {}, installs: { - "openclaw-web-search": { - source: "npm", - spec: "@ollama/openclaw-web-search@0.2.2", - installPath: "/tmp/openclaw-web-search", - resolvedName: "@ollama/openclaw-web-search", - resolvedVersion: "0.2.2", - }, + "openclaw-web-search": { ...install, spec: "@ollama/openclaw-web-search@0.2.2" }, }, }, }, }) as OpenClawConfig, [["plugins", "installs"]], - ) as { - plugins?: { - installs?: Record>; - }; - }; - - expect(persisted.plugins?.installs).toBeUndefined(); + ); + expect(persisted.plugins).not.toHaveProperty("installs"); }); it("preserves authored agent provider params during narrowed agent-list writes", () => { - const sourceConfig = { - agents: { - defaults: { + const defaults = { + params: { transport: "sse", openaiWsWarmup: false }, + models: { + "openai/gpt-5.4": { + alias: "GPT", params: { transport: "sse", openaiWsWarmup: false }, - models: { - "openai/gpt-5.4": { - alias: "GPT", - params: { transport: "sse", openaiWsWarmup: false }, - }, - }, }, - list: [{ id: "main" }], }, + }; + const sourceConfig = { + agents: { defaults, list: [{ id: "main" }] }, gateway: { mode: "local" }, }; - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig: { - ...sourceConfig, - agents: { - ...sourceConfig.agents, - defaults: { - ...sourceConfig.agents.defaults, - maxConcurrent: 4, - }, + expect( + resolvePersistCandidateForWrite({ + runtimeConfig: { + ...sourceConfig, + agents: { ...sourceConfig.agents, defaults: { ...defaults, maxConcurrent: 4 } }, }, - }, - sourceConfig, - nextConfig: { - agents: { list: [{ id: "main" }, { id: "ops" }] }, - gateway: { mode: "local" }, - }, - }) as OpenClawConfig; - - expect(persisted.agents?.defaults?.params).toEqual({ - transport: "sse", - openaiWsWarmup: false, - }); - expect(persisted.agents?.defaults?.models?.["openai/gpt-5.4"]).toEqual({ - alias: "GPT", - params: { transport: "sse", openaiWsWarmup: false }, - }); - expect(persisted.agents?.entries).toEqual({ - main: {}, - ops: {}, - }); - expect(persisted.agents).not.toHaveProperty("list"); + sourceConfig, + nextConfig: { + agents: { list: [{ id: "main" }, { id: "ops" }] }, + gateway: { mode: "local" }, + }, + }), + ).toEqual({ agents: { defaults, entries: { main: {}, ops: {} } }, gateway: { mode: "local" } }); }); it("preserves authored Google model params under normalized config keys", () => { - const sourceConfig: OpenClawConfig = { + const params = { thinking: { level: "high" } }; + const sourceConfig = { agents: { defaults: { model: { primary: "google/gemini-3-pro-preview" }, - models: { - "google/gemini-3-pro-preview": { - alias: "Gemini", - params: { thinking: { level: "high" } }, - }, - }, + models: { "google/gemini-3-pro-preview": { alias: "Gemini", params } }, }, }, }; - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig: sourceConfig, - sourceConfig, - nextConfig: { - agents: { - defaults: { - model: { primary: "google/gemini-3.1-pro-preview" }, - models: { - "google/gemini-3.1-pro-preview": {}, + expect( + resolvePersistCandidateForWrite({ + runtimeConfig: sourceConfig, + sourceConfig, + nextConfig: { + agents: { + defaults: { + model: { primary: "google/gemini-3.1-pro-preview" }, + models: { "google/gemini-3.1-pro-preview": {} }, }, }, }, + }), + ).toEqual({ + agents: { + defaults: { + model: { primary: "google/gemini-3.1-pro-preview" }, + models: { "google/gemini-3.1-pro-preview": { params } }, + }, }, - }) as OpenClawConfig; - - expect(persisted.agents?.defaults?.model).toEqual({ - primary: "google/gemini-3.1-pro-preview", - }); - expect(persisted.agents?.defaults?.models).not.toHaveProperty("google/gemini-3-pro-preview"); - expect(persisted.agents?.defaults?.models?.["google/gemini-3.1-pro-preview"]).toEqual({ - params: { thinking: { level: "high" } }, }); }); it("does not reintroduce legacy openai-codex model params after doctor route repair", () => { - const sourceConfig: OpenClawConfig = { + const params = { reasoning_effort: "high" }; + const sourceConfig = { agents: { defaults: { model: "openai-codex/gpt-5.5", - models: { - "openai-codex/gpt-5.5": { - params: { reasoning_effort: "high" }, - }, - }, + models: { "openai-codex/gpt-5.5": { params } }, }, }, }; - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig: sourceConfig, - sourceConfig, - nextConfig: { - agents: { - defaults: { - model: "openai/gpt-5.5", - models: { - "openai/gpt-5.5": { - params: { reasoning_effort: "high" }, - agentRuntime: { id: "codex" }, - }, - }, - }, + const nextConfig = { + agents: { + defaults: { + model: "openai/gpt-5.5", + models: { "openai/gpt-5.5": { params, agentRuntime: { id: "codex" } } }, }, }, - }) as OpenClawConfig; - - expect(persisted.agents?.defaults?.model).toBe("openai/gpt-5.5"); - expect(persisted.agents?.defaults?.models).not.toHaveProperty("openai-codex/gpt-5.5"); - expect(persisted.agents?.defaults?.models?.["openai/gpt-5.5"]).toEqual({ - params: { reasoning_effort: "high" }, - agentRuntime: { id: "codex" }, - }); + }; + expect( + resolvePersistCandidateForWrite({ runtimeConfig: sourceConfig, sourceConfig, nextConfig }), + ).toEqual(nextConfig); }); it("normalizes retired Google model refs during unrelated config writes", () => { - const sourceConfig: OpenClawConfig = { - agents: { - defaults: { - model: { - primary: "google/gemini-3-pro-preview", - fallbacks: ["google/gemini-3-pro-preview", "openai/gpt-5.5"], + function makeGoogleConfig(modelRef: string): OpenClawConfig { + return { + agents: { + defaults: { + model: { primary: modelRef, fallbacks: [modelRef, "openai/gpt-5.5"] }, + utilityModel: modelRef, + heartbeat: { model: modelRef }, + subagents: { model: { primary: modelRef, fallbacks: [modelRef] } }, + compaction: { model: modelRef, memoryFlush: { model: modelRef } }, + models: { [modelRef]: { alias: "Gemini" } }, }, - utilityModel: "google/gemini-3-pro-preview", - heartbeat: { model: "google/gemini-3-pro-preview" }, - subagents: { - model: { - primary: "google/gemini-3-pro-preview", - fallbacks: ["google/gemini-3-pro-preview"], - }, - }, - compaction: { - model: "google/gemini-3-pro-preview", - memoryFlush: { model: "google/gemini-3-pro-preview" }, - }, - models: { - "google/gemini-3-pro-preview": { - alias: "Gemini", + entries: { + ops: { + model: { primary: modelRef, fallbacks: [modelRef] }, + utilityModel: modelRef, + heartbeat: { model: modelRef }, + subagents: { model: modelRef }, + models: { [modelRef]: { alias: "Ops Gemini" } }, }, }, }, - entries: { - ops: { - model: { - primary: "google/gemini-3-pro-preview", - fallbacks: ["google/gemini-3-pro-preview"], - }, - utilityModel: "google/gemini-3-pro-preview", - heartbeat: { model: "google/gemini-3-pro-preview" }, - subagents: { model: "google/gemini-3-pro-preview" }, - models: { - "google/gemini-3-pro-preview": { - alias: "Ops Gemini", - }, - }, - }, - }, - }, - gateway: { port: 18789 }, - }; - const runtimeConfig: OpenClawConfig = { - agents: { - defaults: { - model: { - primary: "google/gemini-3.1-pro-preview", - fallbacks: ["google/gemini-3.1-pro-preview", "openai/gpt-5.5"], - }, - utilityModel: "google/gemini-3.1-pro-preview", - heartbeat: { model: "google/gemini-3.1-pro-preview" }, - subagents: { - model: { - primary: "google/gemini-3.1-pro-preview", - fallbacks: ["google/gemini-3.1-pro-preview"], - }, - }, - compaction: { - model: "google/gemini-3.1-pro-preview", - memoryFlush: { model: "google/gemini-3.1-pro-preview" }, - }, - models: { - "google/gemini-3.1-pro-preview": { - alias: "Gemini", - }, - }, - }, - entries: { - ops: { - model: { - primary: "google/gemini-3.1-pro-preview", - fallbacks: ["google/gemini-3.1-pro-preview"], - }, - utilityModel: "google/gemini-3.1-pro-preview", - heartbeat: { model: "google/gemini-3.1-pro-preview" }, - subagents: { model: "google/gemini-3.1-pro-preview" }, - models: { - "google/gemini-3.1-pro-preview": { - alias: "Ops Gemini", - }, - }, - }, - }, - }, - gateway: { port: 18789 }, - }; - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig, - sourceConfig, - nextConfig: { - ...runtimeConfig, - gateway: { port: 18888 }, - }, - }) as OpenClawConfig; - - expect(persisted.agents?.defaults?.model).toEqual({ - primary: "google/gemini-3.1-pro-preview", - fallbacks: ["google/gemini-3.1-pro-preview", "openai/gpt-5.5"], - }); - expect(persisted.agents?.defaults?.utilityModel).toBe("google/gemini-3.1-pro-preview"); - expect(persisted.agents?.defaults?.heartbeat?.model).toBe("google/gemini-3.1-pro-preview"); - expect(persisted.agents?.defaults?.subagents?.model).toEqual({ - primary: "google/gemini-3.1-pro-preview", - fallbacks: ["google/gemini-3.1-pro-preview"], - }); - expect(persisted.agents?.defaults?.compaction?.model).toBe("google/gemini-3.1-pro-preview"); - expect(persisted.agents?.defaults?.compaction?.memoryFlush?.model).toBe( - "google/gemini-3.1-pro-preview", - ); - expect(persisted.agents?.defaults?.models).toEqual({ - "google/gemini-3.1-pro-preview": { - alias: "Gemini", - }, - }); - expect(persisted.agents?.entries?.ops?.model).toEqual({ - primary: "google/gemini-3.1-pro-preview", - fallbacks: ["google/gemini-3.1-pro-preview"], - }); - expect(persisted.agents?.entries?.ops?.utilityModel).toBe("google/gemini-3.1-pro-preview"); - expect(persisted.agents?.entries?.ops?.heartbeat?.model).toBe("google/gemini-3.1-pro-preview"); - expect(persisted.agents?.entries?.ops?.subagents?.model).toBe("google/gemini-3.1-pro-preview"); - expect(persisted.agents?.entries?.ops?.models).toEqual({ - "google/gemini-3.1-pro-preview": { - alias: "Ops Gemini", - }, - }); - expect(persisted.gateway?.port).toBe(18888); + gateway: { port: 18789 }, + }; + } + const runtimeConfig = makeGoogleConfig("google/gemini-3.1-pro-preview"); + expect( + resolvePersistCandidateForWrite({ + runtimeConfig, + sourceConfig: makeGoogleConfig("google/gemini-3-pro-preview"), + nextConfig: { ...runtimeConfig, gateway: { port: 18888 } }, + }), + ).toEqual({ ...runtimeConfig, gateway: { port: 18888 } }); }); it("normalizes retired Google provider catalog refs during unrelated config writes", () => { @@ -1580,52 +907,29 @@ describe("config io write prepare", () => { contextWindow: 1_048_576, maxTokens: 65_536, }); - const sourceConfig: OpenClawConfig = { + const makeConfig = (id: string): OpenClawConfig => ({ models: { providers: { google: { baseUrl: "https://generativelanguage.googleapis.com/v1beta", - models: [makeModel("google/gemini-3-pro-preview", "Gemini 3 Pro")], + models: [makeModel(id, "Gemini 3 Pro")], }, kilocode: { baseUrl: "https://kilocode.test/v1", - models: [makeModel("google/gemini-3-pro-preview", "Gemini via Kilo")], + models: [makeModel(id, "Gemini via Kilo")], }, }, }, gateway: { port: 18789 }, - }; - const runtimeConfig: OpenClawConfig = { - models: { - providers: { - google: { - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - models: [makeModel("google/gemini-3.1-pro-preview", "Gemini 3 Pro")], - }, - kilocode: { - baseUrl: "https://kilocode.test/v1", - models: [makeModel("google/gemini-3.1-pro-preview", "Gemini via Kilo")], - }, - }, - }, - gateway: { port: 18789 }, - }; - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig, - sourceConfig, - nextConfig: { - ...runtimeConfig, - gateway: { port: 18888 }, - }, - }) as OpenClawConfig; - - expect(persisted.models?.providers?.google?.models).toEqual([ - makeModel("google/gemini-3.1-pro-preview", "Gemini 3 Pro"), - ]); - expect(persisted.models?.providers?.kilocode?.models).toEqual([ - makeModel("google/gemini-3.1-pro-preview", "Gemini via Kilo"), - ]); - expect(persisted.gateway?.port).toBe(18888); + }); + const runtimeConfig = makeConfig("google/gemini-3.1-pro-preview"); + expect( + resolvePersistCandidateForWrite({ + runtimeConfig, + sourceConfig: makeConfig("google/gemini-3-pro-preview"), + nextConfig: { ...runtimeConfig, gateway: { port: 18888 } }, + }), + ).toEqual({ ...runtimeConfig, gateway: { port: 18888 } }); }); it("normalizes manifest-backed provider catalog refs during unrelated config writes", () => { @@ -1638,77 +942,41 @@ describe("config io write prepare", () => { contextWindow: 200_000, maxTokens: 8192, }); - const sourceConfig: OpenClawConfig = { + const makeConfig = (id: string): OpenClawConfig => ({ models: { - providers: { - myproxy: { - baseUrl: "https://proxy.example/v1", - models: [makeModel("latest")], - }, - }, + providers: { myproxy: { baseUrl: "https://proxy.example/v1", models: [makeModel(id)] } }, }, gateway: { port: 18789 }, - }; - const runtimeConfig: OpenClawConfig = { - models: { - providers: { - myproxy: { - baseUrl: "https://proxy.example/v1", - models: [makeModel("vendor/modern-model")], - }, - }, - }, - gateway: { port: 18789 }, - }; - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig, - sourceConfig, - nextConfig: { - ...runtimeConfig, - gateway: { port: 18888 }, - }, - modelIdNormalizationPolicies: new Map([ - [ - "myproxy", - { - aliases: { latest: "modern-model" }, - prefixWhenBare: "vendor", - }, - ], - ]), - }) as OpenClawConfig; - - expect(persisted.models?.providers?.myproxy?.models).toEqual([ - makeModel("vendor/modern-model"), - ]); - expect(persisted.gateway?.port).toBe(18888); + }); + const runtimeConfig = makeConfig("vendor/modern-model"); + expect( + resolvePersistCandidateForWrite({ + runtimeConfig, + sourceConfig: makeConfig("latest"), + nextConfig: { ...runtimeConfig, gateway: { port: 18888 } }, + modelIdNormalizationPolicies: new Map([ + ["myproxy", { aliases: { latest: "modern-model" }, prefixWhenBare: "vendor" }], + ]), + }), + ).toEqual({ ...runtimeConfig, gateway: { port: 18888 } }); }); it("allows explicit unsets to remove authored agent provider params", () => { - const sourceConfig: OpenClawConfig = { - agents: { - defaults: { - params: { transport: "sse", openaiWsWarmup: false }, - models: { - "openai/gpt-5.4": { - params: { transport: "sse", openaiWsWarmup: false }, - }, - }, - }, - }, + const params = { transport: "sse", openaiWsWarmup: false }; + const current = { + agents: { defaults: { params, models: { "openai/gpt-5.4": { params } } } }, }; - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig: sourceConfig, - sourceConfig, - nextConfig: { agents: { defaults: { models: { "openai/gpt-5.4": {} } } } }, - unsetPaths: [ - ["agents", "defaults", "params"], - ["agents", "defaults", "models", "openai/gpt-5.4", "params"], - ], - }) as OpenClawConfig; - - expect(persisted.agents?.defaults).not.toHaveProperty("params"); - expect(persisted.agents?.defaults?.models?.["openai/gpt-5.4"]).not.toHaveProperty("params"); + expect( + resolvePersistCandidateForWrite({ + runtimeConfig: current, + sourceConfig: current, + nextConfig: { agents: { defaults: { models: { "openai/gpt-5.4": {} } } } }, + unsetPaths: [ + ["agents", "defaults", "params"], + ["agents", "defaults", "models", "openai/gpt-5.4", "params"], + ], + }), + ).toEqual({ agents: { defaults: { models: { "openai/gpt-5.4": {} } } } }); }); it("applies explicit unsets without mutating caller config", () => { @@ -1717,19 +985,14 @@ describe("config io write prepare", () => { commands: { ownerDisplay: "hash" }, tools: { alsoAllow: ["exec", "fetch", "read"] }, }; - - const next = applyUnsetPathsForWrite(input, [ - ["commands", "ownerDisplay"], - ["tools", "alsoAllow", "1"], - ]); - - expect(input).toEqual({ - gateway: { mode: "local" }, - commands: { ownerDisplay: "hash" }, - tools: { alsoAllow: ["exec", "fetch", "read"] }, - }); - expect(next.commands ?? {}).not.toHaveProperty("ownerDisplay"); - expect(next.tools?.alsoAllow).toEqual(["exec", "read"]); + const before = structuredClone(input); + expect( + applyUnsetPathsForWrite(input, [ + ["commands", "ownerDisplay"], + ["tools", "alsoAllow", "1"], + ]), + ).toEqual({ gateway: { mode: "local" }, tools: { alsoAllow: ["exec", "read"] } }); + expect(input).toEqual(before); }); it.each([ @@ -1747,480 +1010,124 @@ describe("config io write prepare", () => { commands: { ownerDisplay: "hash" }, tools: { alsoAllow: ["exec", "fetch"] }, }; - expect(applyUnsetPathsForWrite(input, [[...unsetPath]])).toBe(input); }); - it("preserves untouched include-owned subtrees during unrelated writes", () => { - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig: { - agents: { - defaults: { model: "openai/gpt-5.4" }, - }, - gateway: { mode: "local" }, - }, - sourceConfig: { - agents: { - defaults: { model: "openai/gpt-5.4" }, - }, - gateway: { mode: "local" }, - }, - rootAuthoredConfig: { - agents: { $include: "./config/agents.json" }, - gateway: { mode: "local" }, - }, - nextConfig: { - agents: { - defaults: { model: "openai/gpt-5.4" }, - }, - gateway: { mode: "local", port: 18789 }, - }, - }) as Record; - - expect(persisted.agents).toEqual({ $include: "./config/agents.json" }); - expect(persisted.gateway).toEqual({ mode: "local", port: 18789 }); - }); - - it("allows removing root-authored sibling keys beside an include", () => { - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig: { - gateway: { mode: "local", legacyKey: true }, - }, - sourceConfig: { - gateway: { mode: "local", legacyKey: true }, - }, - rootAuthoredConfig: { - gateway: { $include: "./config/gateway.json", legacyKey: true }, - }, - nextConfig: { - gateway: { mode: "local" }, - }, - }) as Record; - - expect(persisted.gateway).toEqual({ $include: "./config/gateway.json" }); - }); - - it("allows nested root-authored sibling edits without flattening included values", () => { - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig: { - gateway: { - mode: "local", - auth: { mode: "token", token: "old" }, - }, - }, - sourceConfig: { - gateway: { - mode: "local", - auth: { mode: "token", token: "old" }, - }, - }, - rootAuthoredConfig: { - gateway: { - $include: "./config/gateway.json", - auth: { token: "old" }, - }, - }, - nextConfig: { - gateway: { - mode: "local", - auth: { mode: "none", token: "new", strategy: "strict" }, - }, - }, - }) as Record; - - expect(persisted.gateway).toEqual({ - $include: "./config/gateway.json", - auth: { token: "new", mode: "none", strategy: "strict" }, - }); - }); - - it("does not copy runtime-normalized include values into root-authored siblings", () => { - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig: { - gateway: { - tls: { certPath: "/home/test/cert.pem", enabled: false }, - }, - }, - sourceConfig: { - gateway: { - tls: { certPath: "~/cert.pem", enabled: false }, - }, - }, - rootAuthoredConfig: { - gateway: { - $include: "./config/gateway.json", - tls: { enabled: false }, - }, - }, - nextConfig: { - gateway: { - tls: { certPath: "~/cert.pem", enabled: true }, - }, - }, - }) as Record; - - expect(persisted.gateway).toEqual({ - $include: "./config/gateway.json", - tls: { enabled: true }, - }); - }); - - it("rejects included-value edits beside root-authored sibling edits", () => { - expect(() => - resolvePersistCandidateForWrite({ - runtimeConfig: { - gateway: { mode: "local", legacyKey: "old" }, - }, - sourceConfig: { - gateway: { mode: "local", legacyKey: "old" }, - }, - rootAuthoredConfig: { - gateway: { $include: "./config/gateway.json", legacyKey: "old" }, - }, - nextConfig: { - gateway: { mode: "remote", legacyKey: "new" }, - }, - }), - ).toThrow("Config write would flatten $include-owned config at gateway"); - }); - - it("preserves include-owned array entries across runtime-only normalization", () => { - const sourceAgents = { list: [{ id: "main", workspace: "~/agent" }] }; - const runtimeAgents = { list: [{ id: "main", workspace: "/home/test/agent" }] }; - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig: { - agents: runtimeAgents, - gateway: { mode: "local" }, - }, - sourceConfig: { - agents: sourceAgents, - gateway: { mode: "local" }, - }, - rootAuthoredConfig: { - agents: { list: [{ $include: "./config/main-agent.json" }] }, - gateway: { mode: "local" }, - }, - nextConfig: { - agents: sourceAgents, - gateway: { mode: "local", port: 18789 }, - }, - }) as Record; - - expect(persisted.agents).toEqual({ - list: [{ $include: "./config/main-agent.json" }], - }); - expect(persisted.gateway).toEqual({ mode: "local", port: 18789 }); - }); - - it("rejects roster edits beside an include-owned array entry", () => { - const mainAgent = { id: "main", workspace: "~/agent" }; - expect(() => - resolvePersistCandidateForWrite({ - runtimeConfig: { - agents: { list: [mainAgent, { id: "ops", workspace: "~/ops" }] }, - }, - sourceConfig: { - agents: { list: [mainAgent, { id: "ops", workspace: "~/ops" }] }, - }, - rootAuthoredConfig: { - agents: { - list: [{ $include: "./config/main-agent.json" }, { id: "ops", workspace: "~/ops" }], - }, - }, - nextConfig: { - agents: { - list: [ - mainAgent, - { id: "ops", workspace: "~/ops-next" }, - { id: "new", workspace: "~/new" }, - ], - }, - }, - }), - ).toThrow("Config write would flatten $include-owned config at agents"); - }); - - it("rejects writes that change include-owned array entries", () => { - const agents = { list: [{ id: "main", workspace: "~/agent" }] }; - - expect(() => - resolvePersistCandidateForWrite({ - runtimeConfig: { agents }, - sourceConfig: { agents }, - rootAuthoredConfig: { - agents: { list: [{ $include: "./config/main-agent.json" }] }, - }, - nextConfig: { - agents: { list: [{ id: "main", workspace: "~/other-agent" }] }, - }, - }), - ).toThrow("Config write would flatten $include-owned config at agents"); - }); - - it("rejects array shifts when an included value has a duplicate sibling", () => { - const paths = ["/same", "/same"]; - - expect(() => - resolvePersistCandidateForWrite({ - runtimeConfig: { plugins: { load: { paths } } }, - sourceConfig: { plugins: { load: { paths } } }, - rootAuthoredConfig: { - plugins: { - load: { paths: [{ $include: "./path.json5" }, "/same"] }, - }, - }, - nextConfig: { plugins: { load: { paths: ["/same"] } } }, - }), - ).toThrow("Config write would flatten $include-owned config at plugins.load.paths.0"); - }); - - it("allows unrelated removals after duplicate include-resolved values", () => { - const paths = ["/same", "/same", "/other"]; - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig: { plugins: { load: { paths } } }, - sourceConfig: { plugins: { load: { paths } } }, - rootAuthoredConfig: { - plugins: { - load: { paths: [{ $include: "./path.json5" }, "/same", "/other"] }, - }, - }, - nextConfig: { plugins: { load: { paths: ["/same", "/same"] } } }, - }) as Record; - - expect(persisted).toEqual({ - plugins: { - load: { paths: [{ $include: "./path.json5" }, "/same"] }, - }, - }); - }); - - it("rejects included-entry removals hidden by duplicate sibling edits", () => { - const paths = ["/same", "/same", "/old"]; - - expect(() => - resolvePersistCandidateForWrite({ - runtimeConfig: { plugins: { load: { paths } } }, - sourceConfig: { plugins: { load: { paths } } }, - rootAuthoredConfig: { - plugins: { - load: { paths: [{ $include: "./path.json5" }, "/same", "/old"] }, - }, - }, - nextConfig: { plugins: { load: { paths: ["/same", "/new"] } } }, - }), - ).toThrow("Config write would flatten $include-owned config at plugins.load.paths.0"); - }); - - it("rejects newly introduced duplicates of include-owned array entries", () => { - const paths = ["/root", "/included"]; - - expect(() => - resolvePersistCandidateForWrite({ - runtimeConfig: { plugins: { load: { paths } } }, - sourceConfig: { plugins: { load: { paths } } }, - rootAuthoredConfig: { - plugins: { - load: { paths: ["/root", { $include: "./path.json5" }] }, - }, - }, - nextConfig: { plugins: { load: { paths: ["/included", "/included"] } } }, - }), - ).toThrow("Config write would flatten $include-owned config at plugins.load.paths.1"); - }); - - it("rejects writes that would flatten include-owned subtrees", () => { - expect(() => - resolvePersistCandidateForWrite({ - runtimeConfig: { - agents: { - defaults: { model: "openai/gpt-5.4" }, - }, - }, - sourceConfig: { - agents: { - defaults: { model: "openai/gpt-5.4" }, - }, - }, - rootAuthoredConfig: { - agents: { $include: "./config/agents.json" }, - }, - nextConfig: { - agents: { - defaults: { model: "anthropic/sonnet-4.5" }, - }, - }, - }), - ).toThrow("Config write would flatten $include-owned config at agents"); - }); - it('formats actionable guidance for dmPolicy="open" without wildcard allowFrom', () => { const message = formatConfigValidationFailure( "channels.telegram.allowFrom", 'channels.telegram.dmPolicy = "open" requires channels.telegram.allowFrom to include "*"', ); - expect(message).toContain("openclaw config set channels.telegram.allowFrom '[\"*\"]'"); expect(message).toContain('openclaw config set channels.telegram.dmPolicy "pairing"'); }); it("preserves env refs on unchanged paths while keeping changed paths resolved", () => { - const changedPaths = new Set(); - collectChangedPaths( - { - plugins: { entries: { acme: { config: { env: { API_KEY: "secret" } } } } }, - gateway: { port: 18789 }, - }, - { - plugins: { entries: { acme: { config: { env: { API_KEY: "secret" } } } } }, - gateway: { - port: 18789, - auth: { mode: "token" }, - }, - }, - "", - changedPaths, - ); - - const restored = restoreEnvRefsFromMap( - { - plugins: { entries: { acme: { config: { env: { API_KEY: "secret" } } } } }, - gateway: { - port: 18789, - auth: { mode: "token" }, - }, - }, - "", - new Map([["plugins.entries.acme.config.env.API_KEY", "${ACME_API_KEY}"]]), - changedPaths, - ) as { - plugins: { entries: { acme: { config: { env: { API_KEY: string } } } } }; - gateway: { port: number; auth: { mode: string } }; + const unchanged = { + plugins: { entries: { acme: { config: { env: { API_KEY: "secret" } } } } }, }; - - expect(restored.plugins.entries.acme.config.env.API_KEY).toBe("${ACME_API_KEY}"); - expect(restored.gateway).toEqual({ - port: 18789, - auth: { mode: "token" }, + const before = { ...unchanged, gateway: { port: 18789 } }; + const after = { ...unchanged, gateway: { port: 18789, auth: { mode: "token" } } }; + const changedPaths = new Set(); + collectChangedPaths(before, after, "", changedPaths); + expect( + restoreEnvRefsFromMap( + after, + "", + new Map([["plugins.entries.acme.config.env.API_KEY", "${ACME_API_KEY}"]]), + changedPaths, + ), + ).toEqual({ + plugins: { entries: { acme: { config: { env: { API_KEY: "${ACME_API_KEY}" } } } } }, + gateway: { port: 18789, auth: { mode: "token" } }, }); }); it("preserves env refs in arrays while keeping appended entries resolved", () => { + const config = (args: string[]) => ({ plugins: { entries: { acme: { config: { args } } } } }); const changedPaths = new Set(); collectChangedPaths( - { - plugins: { entries: { acme: { config: { args: ["${USER_ID}", "123"] } } } }, - }, - { - plugins: { - entries: { acme: { config: { args: ["${USER_ID}", "123", "456"] } } }, - }, - }, + config(["${USER_ID}", "123"]), + config(["${USER_ID}", "123", "456"]), "", changedPaths, ); - - const restored = restoreEnvRefsFromMap( - { - plugins: { entries: { acme: { config: { args: ["999", "123", "456"] } } } }, - }, - "", - new Map([["plugins.entries.acme.config.args[0]", "${USER_ID}"]]), - changedPaths, - ) as { - plugins: { entries: { acme: { config: { args: string[] } } } }; - }; - - expect(restored.plugins.entries.acme.config.args).toEqual(["${USER_ID}", "123", "456"]); + expect( + restoreEnvRefsFromMap( + config(["999", "123", "456"]), + "", + new Map([["plugins.entries.acme.config.args[0]", "${USER_ID}"]]), + changedPaths, + ), + ).toEqual(config(["${USER_ID}", "123", "456"])); }); - it("does not overwrite identity-restored env refs with positional map entries", () => { - const restored = restoreEnvRefsFromMap( - { - agents: [ - { id: "b", token: "${TOKEN_B}" }, - { id: "a", token: "${TOKEN_A}" }, - ], - }, - "", - new Map([ - ["agents[0].token", "${TOKEN_A}"], - ["agents[1].token", "${TOKEN_B}"], - ]), - new Set(["agents[0].id", "agents[1].id"]), - new Set(["agents[0].token", "agents[1].token"]), - ); - - expect(restored).toEqual({ + it.each([ + { + name: "does not overwrite identity-restored env refs with positional map entries", agents: [ { id: "b", token: "${TOKEN_B}" }, { id: "a", token: "${TOKEN_A}" }, ], - }); - }); - - it("ignores prototype-chain keys when collecting changed paths", () => { - // Same one-sided collision as the merge-patch fixture: own on base, only - // inherited on target. Old `in` checks walked into collision.mode; hasOwn - // reports the top-level own-key removal instead. - const base = { - safe: { mode: "local" }, - collision: { mode: "owned-base" }, - }; - const target = Object.create({ - collision: { mode: "inherited-target" }, - }) as Record; - target.safe = { mode: "cloud" }; - - const changedPaths = new Set(); - collectChangedPaths(base, target, "", changedPaths); - - expect([...changedPaths].toSorted()).toEqual(["collision", "safe.mode"]); - }); - - it("does not overwrite identity-restored escaped refs with positional map entries", () => { - const restored = restoreEnvRefsFromMap( - { - agents: [ - { id: "real", token: "${TOKEN}" }, - { id: "literal", token: "$${TOKEN}" }, - ], - }, - "", - new Map([["agents[1].token", "${TOKEN}"]]), - new Set(["agents[0].id", "agents[1].id"]), - new Set(["agents[0].token", "agents[1].token"]), - ); - - expect(restored).toEqual({ + refs: [ + ["agents[0].token", "${TOKEN_A}"], + ["agents[1].token", "${TOKEN_B}"], + ] as const, + }, + { + name: "does not overwrite identity-restored escaped refs with positional map entries", agents: [ { id: "real", token: "${TOKEN}" }, { id: "literal", token: "$${TOKEN}" }, ], - }); + refs: [["agents[1].token", "${TOKEN}"]] as const, + }, + ])("$name", ({ agents, refs }) => { + expect( + restoreEnvRefsFromMap( + { agents }, + "", + new Map(refs), + new Set(["agents[0].id", "agents[1].id"]), + new Set(["agents[0].token", "agents[1].token"]), + ), + ).toEqual({ agents }); + }); + + it("ignores prototype-chain keys when collecting changed paths", () => { + const base = { safe: { mode: "local" }, collision: { mode: "owned-base" } }; + const target = Object.create({ collision: { mode: "inherited-target" } }) as Record< + string, + unknown + >; + target.safe = { mode: "cloud" }; + const changedPaths = new Set(); + collectChangedPaths(base, target, "", changedPaths); + expect([...changedPaths].toSorted()).toEqual(["collision", "safe.mode"]); }); it("restores unchanged paths even when their values equal another authored template", () => { - const restored = restoreEnvRefsFromMap( - { - included: { - first: "${SECOND}", - second: "second-secret", - third: "$${SECOND}", - escaped: "$${SECOND}", + expect( + restoreEnvRefsFromMap( + { + included: { + first: "${SECOND}", + second: "second-secret", + third: "$${SECOND}", + escaped: "$${SECOND}", + }, + gateway: { port: 18790 }, }, - gateway: { port: 18790 }, - }, - "", - new Map([ - ["included.first", "${FIRST}"], - ["included.second", "${SECOND}"], - ["included.third", "${THIRD}"], - ["included.escaped", "$${SECOND}"], - ]), - new Set(["gateway.port"]), - ); - - expect(restored).toEqual({ + "", + new Map([ + ["included.first", "${FIRST}"], + ["included.second", "${SECOND}"], + ["included.third", "${THIRD}"], + ["included.escaped", "$${SECOND}"], + ]), + new Set(["gateway.port"]), + ), + ).toEqual({ included: { first: "${FIRST}", second: "${SECOND}", @@ -2231,471 +1138,186 @@ describe("config io write prepare", () => { }); }); - it("keeps the read-time env snapshot when writing the same config path", () => { + it.each([ + { + name: "keeps the read-time env snapshot when writing the same config path", + expectedPath: "/tmp/openclaw.json", + retained: true, + }, + { + name: "drops the read-time env snapshot when writing a different config path", + expectedPath: "/tmp/other.json", + retained: false, + }, + ])("$name", ({ expectedPath, retained }) => { const snapshot = { OPENAI_API_KEY: "sk-secret" }; - expect( - resolveWriteEnvSnapshotForPath({ - actualConfigPath: "/tmp/openclaw.json", - expectedConfigPath: "/tmp/openclaw.json", - envSnapshotForRestore: snapshot, - }), - ).toBe(snapshot); - }); - - it("drops the read-time env snapshot when writing a different config path", () => { - expect( - resolveWriteEnvSnapshotForPath({ - actualConfigPath: "/tmp/openclaw.json", - expectedConfigPath: "/tmp/other.json", - envSnapshotForRestore: { OPENAI_API_KEY: "sk-secret" }, - }), - ).toBeUndefined(); + const actual = resolveWriteEnvSnapshotForPath({ + actualConfigPath: "/tmp/openclaw.json", + expectedConfigPath: expectedPath, + envSnapshotForRestore: snapshot, + }); + if (retained) { + expect(actual).toBe(snapshot); + } else { + expect(actual).toBeUndefined(); + } }); it("keeps runtime-only channel defaults out of the persisted candidate", () => { const sourceConfig = { gateway: { port: 18789 }, - channels: { - imessage: { - cliPath: "/usr/local/bin/imsg", - }, - }, - } satisfies OpenClawConfig; - - const runtimeConfig: OpenClawConfig = { - gateway: { port: 18789 }, - channels: { - imessage: { - cliPath: "/usr/local/bin/imsg", - }, - }, - } satisfies OpenClawConfig; - (runtimeConfig.channels!.imessage as Record).runtimeOnlyDefault = true; - - const nextConfig: OpenClawConfig = structuredClone(runtimeConfig); - nextConfig.gateway = { - ...nextConfig.gateway, - auth: { mode: "token" }, + channels: { imessage: { cliPath: "/usr/local/bin/imsg" } }, }; - - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig, - sourceConfig, - nextConfig, - }) as Record; - - expect(persisted.gateway).toEqual({ - port: 18789, - auth: { mode: "token" }, + const runtimeConfig = { + ...sourceConfig, + channels: { imessage: { cliPath: "/usr/local/bin/imsg", runtimeOnlyDefault: true } }, + }; + expect( + resolvePersistCandidateForWrite({ + runtimeConfig, + sourceConfig, + nextConfig: { + ...structuredClone(runtimeConfig), + gateway: { port: 18789, auth: { mode: "token" } }, + }, + }), + ).toEqual({ + gateway: { port: 18789, auth: { mode: "token" } }, + channels: { imessage: { cliPath: "/usr/local/bin/imsg" } }, }); - const channels = persisted.channels as Record> | undefined; - expect(channels?.imessage?.cliPath).toBe("/usr/local/bin/imsg"); - expect(channels?.imessage).not.toHaveProperty("runtimeOnlyDefault"); }); it("does not reintroduce legacy nested dm.policy defaults in the persisted candidate", () => { + const oldChannel = { dmPolicy: "pairing", dm: { enabled: true, policy: "pairing" } }; + const newChannel = { dmPolicy: "pairing", dm: { enabled: true } }; const sourceConfig = { - channels: { - discord: { - dmPolicy: "pairing", - dm: { enabled: true, policy: "pairing" }, - }, - slack: { - dmPolicy: "pairing", - dm: { enabled: true, policy: "pairing" }, - }, - }, + channels: { discord: structuredClone(oldChannel), slack: structuredClone(oldChannel) }, gateway: { port: 18789 }, - } as unknown as OpenClawConfig; - - const nextConfig = structuredClone(sourceConfig); - delete (nextConfig.channels?.discord?.dm as { enabled?: boolean; policy?: string } | undefined) - ?.policy; - delete (nextConfig.channels?.slack?.dm as { enabled?: boolean; policy?: string } | undefined) - ?.policy; - - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig: sourceConfig, - sourceConfig, - nextConfig, - }) as { - channels?: { - discord?: { dm?: Record; dmPolicy?: unknown }; - slack?: { dm?: Record; dmPolicy?: unknown }; - }; }; - - expect(persisted.channels?.discord?.dmPolicy).toBe("pairing"); - expect(persisted.channels?.discord?.dm).toEqual({ enabled: true }); - expect(persisted.channels?.slack?.dmPolicy).toBe("pairing"); - expect(persisted.channels?.slack?.dm).toEqual({ enabled: true }); + const nextConfig = { + channels: { discord: structuredClone(newChannel), slack: structuredClone(newChannel) }, + gateway: { port: 18789 }, + }; + expect( + resolvePersistCandidateForWrite({ runtimeConfig: sourceConfig, sourceConfig, nextConfig }), + ).toEqual(nextConfig); }); it("preserves normalized nested channel enabled keys during unrelated writes", () => { - const sourceConfig = { - channels: { - slack: { - channels: { - ops: { - enabled: false, - }, - }, - }, - googlechat: { - groups: { - "spaces/aaa": { - enabled: true, - }, - }, - }, - discord: { - guilds: { - "100": { - channels: { - general: { - enabled: false, - }, - }, - }, - }, - }, - }, - } satisfies OpenClawConfig; - - const nextConfig: OpenClawConfig = { - ...structuredClone(sourceConfig), - gateway: { - auth: { mode: "token" }, - }, + const channels = { + slack: { channels: { ops: { enabled: false } } }, + googlechat: { groups: { "spaces/aaa": { enabled: true } } }, + discord: { guilds: { "100": { channels: { general: { enabled: false } } } } }, }; - - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig: sourceConfig, - sourceConfig, - nextConfig, - }) as { - channels?: { - slack?: { channels?: Record> }; - googlechat?: { groups?: Record> }; - discord?: { - guilds?: Record> }>; - }; - }; - gateway?: Record; - }; - - expect(persisted.gateway).toEqual({ - auth: { mode: "token" }, - }); - expect(persisted.channels?.slack?.channels?.ops).toEqual({ enabled: false }); - expect(persisted.channels?.googlechat?.groups?.["spaces/aaa"]).toEqual({ enabled: true }); - expect(persisted.channels?.discord?.guilds?.["100"]?.channels?.general).toEqual({ - enabled: false, - }); - }); - - it("preserves root $schema during unrelated partial writes", () => { - const sourceConfig: OpenClawConfig = { - $schema: "https://openclaw.ai/config.json", - gateway: { mode: "local" }, - } satisfies OpenClawConfig; - - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig: sourceConfig, - sourceConfig, - nextConfig: { - gateway: { mode: "local", port: 18789 }, - } satisfies OpenClawConfig, - }) as OpenClawConfig; - - expect(persisted.$schema).toBe("https://openclaw.ai/config.json"); - expect(persisted.gateway).toEqual({ mode: "local", port: 18789 }); - }); - - it("rejects writes that would flatten a root include", () => { - const sourceConfig = { - $schema: "https://openclaw.ai/config-from-include.json", - gateway: { mode: "local" }, - }; - - expect(() => - resolvePersistCandidateForWrite({ - runtimeConfig: sourceConfig, - sourceConfig, - rootAuthoredConfig: { - $include: "./extra.json5", - gateway: { mode: "local" }, - }, - nextConfig: { - gateway: { mode: "local", port: 18789 }, - }, - }), - ).toThrow("Config write would flatten $include-owned config at "); - }); - - it("allows an explicit local leaf override beside a root include", () => { - const sourceConfig = { - agents: { - defaults: { workspace: "/srv/old" }, - entries: { ops: { default: true } }, - }, - }; - + const sourceConfig = { channels }; + const nextConfig = { ...structuredClone(sourceConfig), gateway: { auth: { mode: "token" } } }; expect( - resolvePersistCandidateForWrite({ - runtimeConfig: sourceConfig, - sourceConfig, - rootAuthoredConfig: { $include: "./agents.json5" }, - nextConfig: sourceConfig, - explicitSetPaths: [["agents", "defaults", "workspace"]], - explicitSetValueSource: { - agents: { defaults: { workspace: "/srv/next" } }, - }, - allowIncludeAncestorExplicitSetPaths: true, - }), - ).toEqual({ - $include: "./agents.json5", - agents: { defaults: { workspace: "/srv/next" } }, - }); + resolvePersistCandidateForWrite({ runtimeConfig: sourceConfig, sourceConfig, nextConfig }), + ).toEqual(nextConfig); }); - it("allows an explicit local leaf override below a nested include", () => { - const sourceConfig = { - agents: { - defaults: { workspace: "/srv/old" }, - entries: { ops: { default: true } }, - }, - }; - - expect( - resolvePersistCandidateForWrite({ - runtimeConfig: sourceConfig, - sourceConfig, - rootAuthoredConfig: { agents: { $include: "./agents.json5" } }, - nextConfig: sourceConfig, - explicitSetPaths: [["agents", "defaults", "workspace"]], - explicitSetValueSource: { - agents: { defaults: { workspace: "/srv/next" } }, - }, - allowIncludeAncestorExplicitSetPaths: true, - }), - ).toEqual({ - agents: { + it.each([ + { + name: "allows an explicit local leaf override beside a root include", + authored: { $include: "./agents.json5" }, + expected: { $include: "./agents.json5", - defaults: { workspace: "/srv/next" }, + agents: { defaults: { workspace: "/srv/next" } }, }, - }); + }, + { + name: "allows an explicit local leaf override below a nested include", + authored: { agents: { $include: "./agents.json5" } }, + expected: { + agents: { $include: "./agents.json5", defaults: { workspace: "/srv/next" } }, + }, + }, + ])("$name", ({ authored, expected }) => { + const sourceConfig = { + agents: { defaults: { workspace: "/srv/old" }, entries: { ops: main } }, + }; + expect( + resolvePersistCandidateForWrite({ + runtimeConfig: sourceConfig, + sourceConfig, + rootAuthoredConfig: authored, + nextConfig: sourceConfig, + explicitSetPaths: [["agents", "defaults", "workspace"]], + explicitSetValueSource: { agents: { defaults: { workspace: "/srv/next" } } }, + allowIncludeAncestorExplicitSetPaths: true, + }), + ).toEqual(expected); }); - it("does not restore root $schema when the next config explicitly clears it", () => { - const sourceConfig = { - $schema: "https://openclaw.ai/config.json", - gateway: { mode: "local" }, - }; - - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig: sourceConfig, - sourceConfig, - nextConfig: { - $schema: null, - gateway: { mode: "local", port: 18789 }, - }, - }) as Record; - - expect(persisted).not.toHaveProperty("$schema"); - expect(persisted.gateway).toEqual({ mode: "local", port: 18789 }); - }); - - it("does not restore root $schema when the next config sets an invalid value", () => { - const sourceConfig = { - $schema: "https://openclaw.ai/config.json", - gateway: { mode: "local" }, - }; - - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig: sourceConfig, - sourceConfig, - nextConfig: { - $schema: 123, - gateway: { mode: "local", port: 18789 }, - }, - }) as Record; - - expect(persisted.$schema).toBe(123); - expect(persisted.gateway).toEqual({ mode: "local", port: 18789 }); - }); - - it("persists explicitly set keys whose values match runtime defaults", () => { - const runtimeConfig = { - channels: { - telegram: { - botToken: "tok-abc", - dmPolicy: "pairing", - groupPolicy: "allowlist", - }, - }, - gateway: { port: 18789 }, - }; - const sourceConfig = { - channels: { - telegram: { - botToken: "tok-abc", - }, - }, - gateway: { port: 18789 }, - }; - - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig, - sourceConfig, - nextConfig: sourceConfig, - explicitSetValueSource: runtimeConfig, - explicitSetPaths: [ + it.each([ + { + name: "persists explicitly set keys whose values match runtime defaults", + paths: [ ["channels", "telegram", "dmPolicy"], ["channels", "telegram", "groupPolicy"], ], - }) as { channels?: { telegram?: Record } }; - - expect(persisted.channels?.telegram?.dmPolicy).toBe("pairing"); - expect(persisted.channels?.telegram?.groupPolicy).toBe("allowlist"); - expect(persisted.channels?.telegram?.botToken).toBe("tok-abc"); + }, + { + name: "persists default-valued children inside explicitly set objects", + paths: [["channels", "telegram"]], + }, + ])("$name", ({ paths }) => { + const telegram = { botToken: "tok-abc", dmPolicy: "pairing", groupPolicy: "allowlist" }; + const runtimeConfig = { channels: { telegram } }; + const sourceConfig = { channels: { telegram: { botToken: "tok-abc" } } }; + expect( + resolvePersistCandidateForWrite({ + runtimeConfig, + sourceConfig, + nextConfig: sourceConfig, + explicitSetValueSource: runtimeConfig, + explicitSetPaths: paths, + }), + ).toEqual(runtimeConfig); }); - it("persists default-valued children inside explicitly set objects", () => { - const runtimeConfig = { - channels: { - telegram: { - botToken: "tok-abc", - dmPolicy: "pairing", - groupPolicy: "allowlist", - }, - }, - }; - const sourceConfig = { - channels: { - telegram: { - botToken: "tok-abc", - }, - }, - }; - - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig, - sourceConfig, - nextConfig: sourceConfig, - explicitSetValueSource: runtimeConfig, - explicitSetPaths: [["channels", "telegram"]], - }) as { channels?: { telegram?: Record } }; - - expect(persisted.channels?.telegram).toEqual({ - botToken: "tok-abc", - dmPolicy: "pairing", - groupPolicy: "allowlist", - }); - }); - - it("persists explicitly set array-index children whose values match runtime defaults", () => { - const runtimeConfig = { - models: { - providers: { - openai: { - models: [{ id: "gpt-5.5", contextWindow: 128000 }], - }, - }, - }, - }; - const sourceConfig = { - models: { - providers: { - openai: { - models: [{ id: "gpt-5.5" }], - }, - }, - }, - }; - - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig, - sourceConfig, - nextConfig: sourceConfig, - explicitSetValueSource: runtimeConfig, - explicitSetPaths: [["models", "providers", "openai", "models", "0", "contextWindow"]], - }) as { models?: { providers?: { openai?: { models?: Array> } } } }; - - expect(persisted.models?.providers?.openai?.models?.[0]).toEqual({ - id: "gpt-5.5", - contextWindow: 128000, - }); - }); - - it("ignores unsafe array-index explicit set paths", () => { - const runtimeConfig = { - models: { - providers: { - openai: { - models: [{ id: "gpt-5.5", contextWindow: 128000 }], - }, - }, - }, - }; - const sourceConfig = { - models: { - providers: { - openai: { - models: [{ id: "gpt-5.5" }], - }, - }, - }, - }; - - const persisted = resolvePersistCandidateForWrite({ - runtimeConfig, - sourceConfig, - nextConfig: sourceConfig, - explicitSetValueSource: runtimeConfig, - explicitSetPaths: [ + it.each([ + { + name: "persists explicitly set array-index children whose values match runtime defaults", + paths: [["models", "providers", "openai", "models", "0", "contextWindow"]], + includesDefault: true, + }, + { + name: "ignores unsafe array-index explicit set paths", + paths: [ ["models", "providers", "openai", "models", "0abc", "contextWindow"], ["models", "providers", "openai", "models", "+0", "contextWindow"], ["models", "providers", "openai", "models", "9007199254740993", "contextWindow"], ["models", "providers", "openai", "models", "4294967294", "contextWindow"], ], - }) as { models?: { providers?: { openai?: { models?: Array> } } } }; - - expect(persisted.models?.providers?.openai?.models).toEqual([{ id: "gpt-5.5" }]); + includesDefault: false, + }, + ])("$name", ({ paths, includesDefault }) => { + const withModels = (models: Record[]) => ({ + models: { providers: { openai: { models } } }, + }); + const sourceConfig = withModels([{ id: "gpt-5.5" }]); + const runtimeConfig = withModels([{ id: "gpt-5.5", contextWindow: 128000 }]); + expect( + resolvePersistCandidateForWrite({ + runtimeConfig, + sourceConfig, + nextConfig: sourceConfig, + explicitSetValueSource: runtimeConfig, + explicitSetPaths: paths, + }), + ).toEqual(includesDefault ? runtimeConfig : sourceConfig); }); it("rejects default-valued explicit writes under include-owned paths", () => { + const sourceConfig = { agents: { defaults: {} } }; expect(() => resolvePersistCandidateForWrite({ - runtimeConfig: { - agents: { - defaults: { - params: { temperature: 0 }, - }, - }, - }, - sourceConfig: { - agents: { - defaults: {}, - }, - }, - rootAuthoredConfig: { - agents: { - defaults: { $include: "./agents-defaults.json" }, - }, - }, - nextConfig: { - agents: { - defaults: {}, - }, - }, - explicitSetValueSource: { - agents: { - defaults: { - params: { temperature: 0 }, - }, - }, - }, + runtimeConfig: { agents: { defaults: { params: { temperature: 0 } } } }, + sourceConfig, + rootAuthoredConfig: { agents: { defaults: { $include: "./agents-defaults.json" } } }, + nextConfig: sourceConfig, + explicitSetValueSource: { agents: { defaults: { params: { temperature: 0 } } } }, explicitSetPaths: [["agents", "defaults", "params"]], }), ).toThrow("Config write would flatten $include-owned config at agents.defaults");