diff --git a/src/cli/config-cli.test.ts b/src/cli/config-cli.test.ts index 0feff508742..f1b77534a9e 100644 --- a/src/cli/config-cli.test.ts +++ b/src/cli/config-cli.test.ts @@ -51,8 +51,10 @@ function buildSnapshot(params: { exists: true, raw: JSON.stringify(params.resolved), parsed: params.resolved, + sourceConfig: params.resolved, resolved: params.resolved, valid: true, + runtimeConfig: params.config, config: params.config, issues: [], warnings: [], @@ -89,8 +91,10 @@ function makeInvalidSnapshot(params: { exists: true, raw: "{}", parsed: {}, + sourceConfig: {}, resolved: {}, valid: false, + runtimeConfig: {}, config: {}, issues: params.issues, warnings: [], @@ -416,8 +420,10 @@ describe("config cli", () => { raw: null, parsed: {}, resolved: {}, + sourceConfig: {}, valid: true, config: {}, + runtimeConfig: {}, issues: [], warnings: [], legacyIssues: [], diff --git a/src/cli/plugins-install-config.test.ts b/src/cli/plugins-install-config.test.ts index d98134526d6..185cb311779 100644 --- a/src/cli/plugins-install-config.test.ts +++ b/src/cli/plugins-install-config.test.ts @@ -25,8 +25,10 @@ function makeSnapshot(overrides: Partial = {}): ConfigFileSn exists: true, raw: '{ "plugins": {} }', parsed: { plugins: {} }, + sourceConfig: { plugins: {} } as ConfigFileSnapshot["sourceConfig"], resolved: { plugins: {} } as OpenClawConfig, valid: false, + runtimeConfig: { plugins: {} } as ConfigFileSnapshot["runtimeConfig"], config: { plugins: {} } as OpenClawConfig, hash: "abc", issues: [{ path: "plugins.installs.matrix", message: "stale path" }], diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index fa2801988b3..5f41d27299f 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -182,8 +182,10 @@ describe("update-cli", () => { raw: "{}", parsed: {}, resolved: baseConfig, + sourceConfig: baseConfig, valid: true, config: baseConfig, + runtimeConfig: baseConfig, issues: [], warnings: [], legacyIssues: [], diff --git a/src/commands/doctor/shared/config-flow-steps.test.ts b/src/commands/doctor/shared/config-flow-steps.test.ts index 3383076e589..b6192d638fb 100644 --- a/src/commands/doctor/shared/config-flow-steps.test.ts +++ b/src/commands/doctor/shared/config-flow-steps.test.ts @@ -15,7 +15,9 @@ describe("doctor config flow steps", () => { issues: [], raw: "{}", resolved: {}, + sourceConfig: {}, config: {}, + runtimeConfig: {}, warnings: [], } satisfies DoctorConfigPreflightResult["snapshot"], state: { diff --git a/src/commands/models/load-config.test.ts b/src/commands/models/load-config.test.ts index 2d35c012a49..b5b03edcd08 100644 --- a/src/commands/models/load-config.test.ts +++ b/src/commands/models/load-config.test.ts @@ -1,16 +1,16 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ - loadConfig: vi.fn(), - readConfigFileSnapshotForWrite: vi.fn(), + getRuntimeConfig: vi.fn(), + readSourceConfigSnapshotForWrite: vi.fn(), setRuntimeConfigSnapshot: vi.fn(), resolveCommandSecretRefsViaGateway: vi.fn(), getModelsCommandSecretTargetIds: vi.fn(), })); vi.mock("../../config/config.js", () => ({ - loadConfig: mocks.loadConfig, - readConfigFileSnapshotForWrite: mocks.readConfigFileSnapshotForWrite, + getRuntimeConfig: mocks.getRuntimeConfig, + readSourceConfigSnapshotForWrite: mocks.readSourceConfigSnapshotForWrite, setRuntimeConfigSnapshot: mocks.setRuntimeConfigSnapshot, })); @@ -34,9 +34,9 @@ describe("models load-config", () => { const targetIds = new Set(["models.providers.*.apiKey"]); function mockResolvedConfigFlow(params: { sourceConfig: unknown; diagnostics: string[] }) { - mocks.loadConfig.mockReturnValue(runtimeConfig); - mocks.readConfigFileSnapshotForWrite.mockResolvedValue({ - snapshot: { valid: true, resolved: params.sourceConfig }, + mocks.getRuntimeConfig.mockReturnValue(runtimeConfig); + mocks.readSourceConfigSnapshotForWrite.mockResolvedValue({ + snapshot: { valid: true, sourceConfig: params.sourceConfig, resolved: params.sourceConfig }, writeOptions: {}, }); mocks.getModelsCommandSecretTargetIds.mockReturnValue(targetIds); diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index 8eca2557c47..f0f92f10c78 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -62,7 +62,7 @@ describe("config io write", () => { } async function writeTokenAuthAndReadConfig(params: { - io: { writeConfigFile: (config: Record) => Promise }; + io: { writeConfigFile: (config: Record) => Promise }; snapshot: { config: Record }; configPath: string; }) { diff --git a/src/config/mutate.test.ts b/src/config/mutate.test.ts new file mode 100644 index 00000000000..185b843f44e --- /dev/null +++ b/src/config/mutate.test.ts @@ -0,0 +1,58 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + ConfigMutationConflictError, + mutateConfigFile, + readSourceConfigSnapshot, + replaceConfigFile, +} from "./config.js"; +import { withTempHome } from "./home-env.test-harness.js"; + +describe("config mutate helpers", () => { + it("mutates source config with optimistic hash protection", async () => { + await withTempHome("openclaw-config-mutate-source-", async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile(configPath, `${JSON.stringify({ gateway: { port: 18789 } }, null, 2)}\n`); + + const snapshot = await readSourceConfigSnapshot(); + await mutateConfigFile({ + baseHash: snapshot.hash, + base: "source", + mutate(draft) { + draft.gateway = { + ...draft.gateway, + auth: { mode: "token" }, + }; + }, + }); + + const persisted = JSON.parse(await fs.readFile(configPath, "utf8")) as { + gateway?: { port?: number; auth?: unknown }; + }; + expect(persisted.gateway).toEqual({ + port: 18789, + auth: { mode: "token" }, + }); + }); + }); + + it("rejects stale replace attempts when the base hash changed", async () => { + await withTempHome("openclaw-config-replace-conflict-", async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile(configPath, `${JSON.stringify({ gateway: { port: 18789 } }, null, 2)}\n`); + + const snapshot = await readSourceConfigSnapshot(); + await fs.writeFile(configPath, `${JSON.stringify({ gateway: { port: 19001 } }, null, 2)}\n`); + + await expect( + replaceConfigFile({ + baseHash: snapshot.hash, + nextConfig: { gateway: { port: 19002 } }, + }), + ).rejects.toBeInstanceOf(ConfigMutationConflictError); + }); + }); +}); diff --git a/src/config/redact-snapshot.test-helpers.ts b/src/config/redact-snapshot.test-helpers.ts index 1b0a26c84c2..12abf5b0597 100644 --- a/src/config/redact-snapshot.test-helpers.ts +++ b/src/config/redact-snapshot.test-helpers.ts @@ -5,7 +5,9 @@ import type { ConfigFileSnapshot } from "./types.openclaw.js"; export type TestSnapshot> = ConfigFileSnapshot & { parsed: TConfig; + sourceConfig: TConfig; resolved: TConfig; + runtimeConfig: TConfig; config: TConfig; }; @@ -18,8 +20,10 @@ export function makeSnapshot>( exists: true, raw: raw ?? JSON.stringify(config), parsed: config, + sourceConfig: config as ConfigFileSnapshot["sourceConfig"], resolved: config as ConfigFileSnapshot["resolved"], valid: true, + runtimeConfig: config as ConfigFileSnapshot["runtimeConfig"], config: config as ConfigFileSnapshot["config"], hash: "abc123", issues: [], diff --git a/src/config/redact-snapshot.test.ts b/src/config/redact-snapshot.test.ts index 991b1459bf9..2d1691b89f7 100644 --- a/src/config/redact-snapshot.test.ts +++ b/src/config/redact-snapshot.test.ts @@ -401,8 +401,10 @@ describe("redactConfigSnapshot", () => { exists: false, raw: null, parsed: null, + sourceConfig: {} as ConfigFileSnapshot["sourceConfig"], resolved: {} as ConfigFileSnapshot["resolved"], valid: false, + runtimeConfig: {} as ConfigFileSnapshot["runtimeConfig"], config: {} as ConfigFileSnapshot["config"], issues: [], warnings: [], @@ -419,8 +421,12 @@ describe("redactConfigSnapshot", () => { exists: true, raw: '{ "gateway": { "auth": { "token": "leaky-secret" } } }', parsed: { gateway: { auth: { token: "leaky-secret" } } }, + sourceConfig: { + gateway: { auth: { token: "leaky-secret" } }, + } as ConfigFileSnapshot["sourceConfig"], resolved: { gateway: { auth: { token: "leaky-secret" } } } as ConfigFileSnapshot["resolved"], valid: false, + runtimeConfig: {} as ConfigFileSnapshot["runtimeConfig"], config: {} as ConfigFileSnapshot["config"], issues: [{ path: "", message: "invalid config" }], warnings: [], diff --git a/src/config/runtime-schema.test.ts b/src/config/runtime-schema.test.ts index 7c53bd6ec5e..8704106977c 100644 --- a/src/config/runtime-schema.test.ts +++ b/src/config/runtime-schema.test.ts @@ -25,8 +25,10 @@ function makeSnapshot(params: { valid: boolean; config?: OpenClawConfig }): Conf raw: "{}", parsed: params.config ?? {}, resolved: params.config ?? {}, + sourceConfig: params.config ?? {}, valid: params.valid, config: params.config ?? {}, + runtimeConfig: params.config ?? {}, issues: params.valid ? [] : [{ path: "gateway", message: "invalid" }], warnings: [], legacyIssues: [], diff --git a/src/config/test-helpers.ts b/src/config/test-helpers.ts index 12c15838cc1..a8523234fbf 100644 --- a/src/config/test-helpers.ts +++ b/src/config/test-helpers.ts @@ -1,10 +1,15 @@ import fs from "node:fs/promises"; import path from "node:path"; import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; -import type { OpenClawConfig } from "./config.js"; +import { resetConfigRuntimeState, type OpenClawConfig } from "./config.js"; export async function withTempHome(fn: (home: string) => Promise): Promise { - return withTempHomeBase(fn, { prefix: "openclaw-config-" }); + resetConfigRuntimeState(); + try { + return await withTempHomeBase(fn, { prefix: "openclaw-config-" }); + } finally { + resetConfigRuntimeState(); + } } export async function writeOpenClawConfig(home: string, config: unknown): Promise { diff --git a/src/gateway/config-reload.test.ts b/src/gateway/config-reload.test.ts index 4548a7a669e..9dea3b5a41f 100644 --- a/src/gateway/config-reload.test.ts +++ b/src/gateway/config-reload.test.ts @@ -292,8 +292,10 @@ function makeSnapshot(partial: Partial = {}): ConfigFileSnap exists: true, raw: "{}", parsed: {}, + sourceConfig: {}, resolved: {}, valid: true, + runtimeConfig: {}, config: {}, issues: [], warnings: [], @@ -458,6 +460,8 @@ describe("startGatewayConfigReloader", () => { gateway: { reload: { debounceMs: 0 } }, hooks: { enabled: true }, }, + persistedHash: "internal-1", + writtenAtMs: Date.now(), }); await vi.runOnlyPendingTimersAsync(); @@ -471,8 +475,15 @@ describe("startGatewayConfigReloader", () => { expect(readSnapshot).not.toHaveBeenCalled(); expect(harness.onHotReload).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(400); readSnapshot.mockResolvedValueOnce( makeSnapshot({ + sourceConfig: { + gateway: { reload: { debounceMs: 0 }, port: 19001 }, + }, + runtimeConfig: { + gateway: { reload: { debounceMs: 0 }, port: 19001 }, + }, config: { gateway: { reload: { debounceMs: 0 }, port: 19001 }, },