From a4f17833ada0b285a2cfa0aeee92f4c15dc789bb Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:10:27 -0500 Subject: [PATCH] fix(gateway): make config revision tokens opaque (#126464) * fix(gateway): make config revision tokens opaque * test(gateway): cover config revision key startup phase --- apps/macos/Sources/OpenClaw/ConfigStore.swift | 34 ++++- .../OpenClawIPCTests/ConfigStoreTests.swift | 34 ++++- src/gateway/config-get-response.test.ts | 16 ++- src/gateway/config-get-response.ts | 31 ++++- src/gateway/config-revision-token.test.ts | 82 ++++++++++++ src/gateway/config-revision-token.ts | 120 ++++++++++++++++++ src/gateway/local-request-context.ts | 2 + src/gateway/server-kernel-request-runtime.ts | 8 ++ src/gateway/server-kernel.test.ts | 1 + .../server-methods/config.test-helpers.ts | 4 + src/gateway/server-methods/config.ts | 32 +++-- src/gateway/server-methods/shared-types.ts | 2 + src/gateway/server-reload-contracts.ts | 1 + ...-reload-handlers.hot-reload-status.test.ts | 6 +- src/gateway/server-reload-handlers.test.ts | 4 + src/gateway/server-reload-managed.ts | 8 +- src/gateway/server-request-context.test.ts | 4 + src/gateway/server-request-context.ts | 2 + src/gateway/server-startup-finish.ts | 1 + src/gateway/server.config-patch.test.ts | 28 ++++ src/gateway/server/health-state.test.ts | 20 ++- src/gateway/server/health-state.ts | 9 +- .../server/ws-connection/connect-hello.ts | 1 + src/state/openclaw-state-db-contract.ts | 1 + .../openclaw-state-db-schema-additive.ts | 14 ++ src/state/openclaw-state-db.generated.d.ts | 6 + ...penclaw-state-schema-compatibility.test.ts | 1 + src/state/openclaw-state-schema.sql | 5 + src/state/secret-state-tables.ts | 1 + ui/src/e2e/config-safe-write.e2e.test.ts | 70 ++++++++++ .../lib/config/config-gateway-operations.ts | 37 ++++++ .../config/config-write-coordinator.test.ts | 74 +++++++++++ ui/src/lib/config/config-write-coordinator.ts | 8 +- 33 files changed, 633 insertions(+), 34 deletions(-) create mode 100644 src/gateway/config-revision-token.test.ts create mode 100644 src/gateway/config-revision-token.ts diff --git a/apps/macos/Sources/OpenClaw/ConfigStore.swift b/apps/macos/Sources/OpenClaw/ConfigStore.swift index 6e1572119002..40160d148fd3 100644 --- a/apps/macos/Sources/OpenClaw/ConfigStore.swift +++ b/apps/macos/Sources/OpenClaw/ConfigStore.swift @@ -2,6 +2,10 @@ import Foundation import OpenClawProtocol enum ConfigStore { + private struct ConfigWriteAck: Decodable { + let hash: String? + } + struct Overrides { var isRemoteMode: (@Sendable () async -> Bool)? var loadLocal: (@MainActor @Sendable () -> [String: Any])? @@ -59,10 +63,17 @@ enum ConfigStore { { let overrides = await self.overrideStore.overrides if await self.isRemoteMode() { - if let override = overrides.saveRemote { - try await override(root) - } else { - try await self.saveToGateway(root) + do { + if let override = overrides.saveRemote { + try await override(root) + } else { + try await self.saveToGateway(root) + } + } catch { + if !self.shouldFallbackToLocalWrite(afterGatewaySaveError: error) { + self.lastHash = nil + } + throw error } } else { if let override = overrides.saveLocal { @@ -146,10 +157,13 @@ enum ConfigStore { if let baseHash = self.lastHash { params["baseHash"] = AnyCodable(baseHash) } - _ = try await GatewayConnection.shared.requestRaw( + let ack: ConfigWriteAck = try await GatewayConnection.shared.requestDecoded( method: .configSet, params: params, timeoutMs: 10000) + if let hash = ack.hash, !hash.isEmpty { + self.lastHash = hash + } _ = await self.loadFromGateway() } @@ -161,6 +175,16 @@ enum ConfigStore { static func _testClearOverrides() async { await self.overrideStore.setOverride(.init()) } + + @MainActor + static func _testSetLastHash(_ hash: String?) { + self.lastHash = hash + } + + @MainActor + static func _testLastHash() -> String? { + self.lastHash + } #endif } diff --git a/apps/macos/Tests/OpenClawIPCTests/ConfigStoreTests.swift b/apps/macos/Tests/OpenClawIPCTests/ConfigStoreTests.swift index e6f9ff6e88de..83b6096691e5 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ConfigStoreTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ConfigStoreTests.swift @@ -10,8 +10,12 @@ struct ConfigStoreTests { var remoteHit = false await ConfigStore._testSetOverrides(.init( isRemoteMode: { true }, - loadLocal: { localHit = true; return ["local": true] }, - loadRemote: { remoteHit = true; return ["remote": true] })) + loadLocal: { localHit = true + return ["local": true] + }, + loadRemote: { remoteHit = true + return ["remote": true] + })) let result = await ConfigStore.load() @@ -26,8 +30,12 @@ struct ConfigStoreTests { var remoteHit = false await ConfigStore._testSetOverrides(.init( isRemoteMode: { false }, - loadLocal: { localHit = true; return ["local": true] }, - loadRemote: { remoteHit = true; return ["remote": true] })) + loadLocal: { localHit = true + return ["local": true] + }, + loadRemote: { remoteHit = true + return ["remote": true] + })) let result = await ConfigStore.load() @@ -115,6 +123,24 @@ struct ConfigStoreTests { #expect(changeCount.value == 0) } + @Test func `remote stale-base rejection clears the cached revision`() async { + ConfigStore._testSetLastHash("legacy-raw-hash") + await self.withOverrides(.init( + isRemoteMode: { true }, + saveRemote: { _ in + throw NSError(domain: "Gateway", code: 0, userInfo: [ + NSLocalizedDescriptionKey: "config changed since last load; re-run config.get and retry", + ]) + })) { + do { + try await ConfigStore.save(["browser": ["enabled": false]]) + Issue.record("Expected save to fail") + } catch {} + } + + #expect(ConfigStore._testLastHash() == nil) + } + @Test func `local save does not fall back to direct write after stale gateway rejection`() async throws { let stateDir = FileManager().temporaryDirectory .appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true) diff --git a/src/gateway/config-get-response.test.ts b/src/gateway/config-get-response.test.ts index 21a670e412ea..0b7dd451b853 100644 --- a/src/gateway/config-get-response.test.ts +++ b/src/gateway/config-get-response.test.ts @@ -28,9 +28,20 @@ vi.mock("../plugins/runtime.js", () => ({ getActivePluginRegistryVersion: () => mocks.pluginRegistryVersion, })); -const { invalidateConfigGetResponseCache, readConfigGetResponse } = +const { invalidateConfigGetResponseCache, readConfigGetResponse: readConfigGetResponseImpl } = await import("./config-get-response.js"); +const revisionProjector = { + projectRawHash: (hash: string) => `raw-token:${hash}`, + projectResolvedHash: (hash: string) => `resolved-token:${hash}`, +}; + +function readConfigGetResponse( + params: Omit[0], "revisionProjector">, +) { + return readConfigGetResponseImpl({ ...params, revisionProjector }); +} + const activeWatcher = () => "active" as const; const disabledWatcher = () => "disabled" as const; @@ -39,6 +50,7 @@ function configSnapshot(sourceConfig: OpenClawConfig): ConfigFileSnapshot { path: "/tmp/openclaw.json", exists: true, raw: JSON.stringify(sourceConfig), + hash: "raw-1", parsed: sourceConfig, sourceConfig, resolved: sourceConfig, @@ -106,7 +118,7 @@ describe("config.get response cache", () => { ).rejects.toThrow("transient read failure"); await expect( readConfigGetResponse({ getHotReloadStatus: activeWatcher, loadUiHints }), - ).resolves.toMatchObject({ appliedConfigHash: "applied-1" }); + ).resolves.toMatchObject({ appliedConfigHash: "resolved-token:applied-1" }); expect(mocks.readConfigFileSnapshot).toHaveBeenCalledTimes(2); }); diff --git a/src/gateway/config-get-response.ts b/src/gateway/config-get-response.ts index a50b78b8568b..9b898d3f6935 100644 --- a/src/gateway/config-get-response.ts +++ b/src/gateway/config-get-response.ts @@ -4,11 +4,13 @@ import { getRuntimeConfigAppliedHash, hashRuntimeConfigValue } from "../config/r import type { ConfigFileSnapshot } from "../config/types.openclaw.js"; import { getActivePluginRegistryVersion } from "../plugins/runtime.js"; import type { GatewayHotReloadStatus } from "./config-reload-status.types.js"; +import type { GatewayConfigRevisionProjector } from "./config-revision-token.js"; type ConfigGetResponse = ReturnType; let configGetResponseCache: | { getHotReloadStatus: () => GatewayHotReloadStatus | undefined; + revisionProjector: GatewayConfigRevisionProjector; appliedConfigHash: string | null; pluginRegistryVersion: number; promise: Promise; @@ -18,11 +20,19 @@ let configGetResponseCache: function createConfigGetResponse( snapshot: ConfigFileSnapshot, uiHints: Parameters[1], + revisionProjector: GatewayConfigRevisionProjector, ) { + const redacted = redactConfigSnapshot(snapshot, uiHints); + const appliedConfigHash = getRuntimeConfigAppliedHash(); return { - ...redactConfigSnapshot(snapshot, uiHints), - configRevisionHash: hashRuntimeConfigValue(snapshot.sourceConfig), - appliedConfigHash: getRuntimeConfigAppliedHash(), + ...redacted, + hash: redacted.hash ? revisionProjector.projectRawHash(redacted.hash) : redacted.hash, + configRevisionHash: revisionProjector.projectResolvedHash( + hashRuntimeConfigValue(snapshot.sourceConfig), + ), + appliedConfigHash: appliedConfigHash + ? revisionProjector.projectResolvedHash(appliedConfigHash) + : null, }; } @@ -30,10 +40,15 @@ function createConfigGetResponse( export async function readConfigGetResponse(params: { getHotReloadStatus?: () => GatewayHotReloadStatus | undefined; loadUiHints: () => Parameters[1]; + revisionProjector: GatewayConfigRevisionProjector; }): Promise { const getHotReloadStatus = params.getHotReloadStatus; if (!getHotReloadStatus || getHotReloadStatus() !== "active") { - return createConfigGetResponse(await readConfigFileSnapshot(), params.loadUiHints()); + return createConfigGetResponse( + await readConfigFileSnapshot(), + params.loadUiHints(), + params.revisionProjector, + ); } const appliedConfigHash = getRuntimeConfigAppliedHash(); const pluginRegistryVersion = getActivePluginRegistryVersion(); @@ -41,6 +56,7 @@ export async function readConfigGetResponse(params: { // become visible after its successful commit; the write path invalidates early. if ( configGetResponseCache?.getHotReloadStatus === getHotReloadStatus && + configGetResponseCache.revisionProjector === params.revisionProjector && configGetResponseCache.appliedConfigHash === appliedConfigHash && configGetResponseCache.pluginRegistryVersion === pluginRegistryVersion ) { @@ -48,9 +64,14 @@ export async function readConfigGetResponse(params: { } const promise = (async () => - createConfigGetResponse(await readConfigFileSnapshot(), params.loadUiHints()))(); + createConfigGetResponse( + await readConfigFileSnapshot(), + params.loadUiHints(), + params.revisionProjector, + ))(); configGetResponseCache = { getHotReloadStatus, + revisionProjector: params.revisionProjector, appliedConfigHash, // Metadata notification precedes registry activation; this version changes at handoff. pluginRegistryVersion, diff --git a/src/gateway/config-revision-token.test.ts b/src/gateway/config-revision-token.test.ts new file mode 100644 index 000000000000..ca652e4015f1 --- /dev/null +++ b/src/gateway/config-revision-token.test.ts @@ -0,0 +1,82 @@ +import { randomBytes } from "node:crypto"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; +import { assertSqliteSchemaContains } from "../infra/sqlite-schema-contract.js"; +import { tableExists } from "../state/openclaw-state-db-schema-helpers.js"; +import { + closeOpenClawStateDatabaseForTest, + openOpenClawStateDatabase, +} from "../state/openclaw-state-db.js"; +import { getOpenClawStateRuntimeSchema } from "../state/openclaw-state-schema-compatibility.js"; +import { loadGatewayConfigRevisionProjector } from "./config-revision-token.js"; + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +function stateOptions() { + return { path: join(tempDirs.make("openclaw-config-revision-"), "openclaw.sqlite") }; +} + +afterEach(() => { + closeOpenClawStateDatabaseForTest(); +}); + +describe("Gateway config revision tokens", () => { + it("lazily persists one opaque domain-separated key without changing schema version", () => { + const options = stateOptions(); + const database = openOpenClawStateDatabase(options).db; + const schemaVersion = database.prepare("PRAGMA user_version").get()?.user_version; + database.exec("DROP TABLE config_revision_keys;"); + closeOpenClawStateDatabaseForTest(); + const reopened = openOpenClawStateDatabase(options).db; + expect(tableExists(reopened, "config_revision_keys")).toBe(false); + + const projector = loadGatewayConfigRevisionProjector(options); + const rawHash = "9ef81838b8fc191a44f1d20308dbb4e6d961dc7ee1294f9d4bd92471bde9475a"; + const rawToken = projector.projectRawHash(rawHash); + const resolvedToken = projector.projectResolvedHash(rawHash); + const keyRow = reopened + .prepare("SELECT hmac_key FROM config_revision_keys WHERE id = 1") + .get() as { hmac_key: Uint8Array }; + + expect(reopened.prepare("PRAGMA user_version").get()?.user_version).toBe(schemaVersion); + expect(keyRow.hmac_key).toHaveLength(32); + expect(rawToken).toMatch(/^hmac-sha256:v1:[A-Za-z0-9_-]{43}$/u); + expect(rawToken).not.toContain(rawHash); + expect(rawToken).not.toContain(Buffer.from(keyRow.hmac_key).toString("hex")); + expect(rawToken).not.toContain(Buffer.from(keyRow.hmac_key).toString("base64url")); + expect(resolvedToken).not.toBe(rawToken); + expect(projector.projectRawHash(rawHash)).toBe(rawToken); + expect(projector.projectRawHash(`${rawHash}0`)).not.toBe(rawToken); + expect(() => + assertSqliteSchemaContains( + reopened, + "previous state-schema reader", + getOpenClawStateRuntimeSchema({ includeVersionLazyAdditiveTables: false }), + ), + ).not.toThrow(); + + closeOpenClawStateDatabaseForTest(); + expect(loadGatewayConfigRevisionProjector(options).projectRawHash(rawHash)).toBe(rawToken); + }); + + it("fails closed instead of replacing corrupt persisted key material", () => { + const options = stateOptions(); + loadGatewayConfigRevisionProjector(options); + const database = openOpenClawStateDatabase(options).db; + database.exec("PRAGMA ignore_check_constraints = ON;"); + database + .prepare("UPDATE config_revision_keys SET hmac_key = ? WHERE id = 1") + .run(randomBytes(31)); + database.exec("PRAGMA ignore_check_constraints = OFF;"); + + expect(() => loadGatewayConfigRevisionProjector(options)).toThrow( + "config revision key is corrupt", + ); + expect( + database + .prepare("SELECT length(hmac_key) AS size FROM config_revision_keys WHERE id = 1") + .get(), + ).toEqual({ size: 31 }); + }); +}); diff --git a/src/gateway/config-revision-token.ts b/src/gateway/config-revision-token.ts new file mode 100644 index 000000000000..83d2d2ec7a65 --- /dev/null +++ b/src/gateway/config-revision-token.ts @@ -0,0 +1,120 @@ +import { createHmac, randomBytes } from "node:crypto"; +import type { Selectable } from "kysely"; +import { + executeSqliteQuerySync, + executeSqliteQueryTakeFirstSync, + getNodeSqliteKysely, +} from "../infra/kysely-sync.js"; +import { registerSecretValueForRedaction } from "../logging/secret-redaction-registry.js"; +import { ensureConfigRevisionKeySchema } from "../state/openclaw-state-db-schema-additive.js"; +import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js"; +import { + runOpenClawStateWriteTransaction, + type OpenClawStateDatabaseOptions, +} from "../state/openclaw-state-db.js"; + +type ConfigRevisionKeyDatabase = Pick; +type ConfigRevisionKeyRow = Pick< + Selectable, + "hmac_key" +>; + +export type GatewayConfigRevisionProjector = { + projectRawHash: (hash: string) => string; + projectResolvedHash: (hash: string) => string; +}; + +const CONFIG_REVISION_SINGLETON_ID = 1; +const CONFIG_REVISION_KEY_BYTES = 32; +const CONFIG_REVISION_RAW_DOMAIN = "openclaw.gateway.config-revision.raw.v1"; +const CONFIG_REVISION_RESOLVED_DOMAIN = "openclaw.gateway.config-revision.resolved.v1"; + +function registerConfigRevisionKeyForRedaction(key: Uint8Array): void { + const bytes = Buffer.from(key); + registerSecretValueForRedaction(bytes.toString("hex")); + registerSecretValueForRedaction(bytes.toString("base64url")); +} + +function parseConfigRevisionKey(row: ConfigRevisionKeyRow): Uint8Array { + if ( + !(row.hmac_key instanceof Uint8Array) || + row.hmac_key.byteLength !== CONFIG_REVISION_KEY_BYTES + ) { + // Public revision tokens are a config-redaction boundary. Corrupt key material + // must fail closed instead of rotating or falling back to a deterministic digest. + throw new Error("config revision key is corrupt"); + } + const key = Buffer.from(row.hmac_key); + registerConfigRevisionKeyForRedaction(key); + return key; +} + +function loadOrCreateConfigRevisionKey( + database: Parameters[0], + candidateKey: Uint8Array, +): Uint8Array { + const db = getNodeSqliteKysely(database); + const existing = executeSqliteQueryTakeFirstSync( + database, + db + .selectFrom("config_revision_keys") + .select("hmac_key") + .where("id", "=", CONFIG_REVISION_SINGLETON_ID), + ); + if (existing) { + return parseConfigRevisionKey(existing); + } + executeSqliteQuerySync( + database, + db + .insertInto("config_revision_keys") + .values({ + id: CONFIG_REVISION_SINGLETON_ID, + hmac_key: candidateKey, + }) + .onConflict((conflict) => conflict.column("id").doNothing()), + ); + const stored = executeSqliteQueryTakeFirstSync( + database, + db + .selectFrom("config_revision_keys") + .select("hmac_key") + .where("id", "=", CONFIG_REVISION_SINGLETON_ID), + ); + if (!stored) { + throw new Error("config revision key could not be created"); + } + return parseConfigRevisionKey(stored); +} + +function projectRevision(key: Uint8Array, domain: string, hash: string): string { + const digest = createHmac("sha256", key) + .update(JSON.stringify([domain, hash]), "utf8") + .digest("base64url"); + return `hmac-sha256:v1:${digest}`; +} + +function createGatewayConfigRevisionProjector(key: Uint8Array): GatewayConfigRevisionProjector { + if (key.byteLength !== CONFIG_REVISION_KEY_BYTES) { + throw new Error("config revision key must be 32 bytes"); + } + return { + projectRawHash: (hash) => projectRevision(key, CONFIG_REVISION_RAW_DOMAIN, hash), + projectResolvedHash: (hash) => projectRevision(key, CONFIG_REVISION_RESOLVED_DOMAIN, hash), + }; +} + +/** Loads the durable installation key once for the Gateway request lifecycle. */ +export function loadGatewayConfigRevisionProjector( + options: OpenClawStateDatabaseOptions = {}, +): GatewayConfigRevisionProjector { + const candidateKey = randomBytes(CONFIG_REVISION_KEY_BYTES); + return runOpenClawStateWriteTransaction( + ({ db }) => { + ensureConfigRevisionKeySchema(db); + return createGatewayConfigRevisionProjector(loadOrCreateConfigRevisionKey(db, candidateKey)); + }, + options, + { operationLabel: "gateway.config-revision-key.load" }, + ); +} diff --git a/src/gateway/local-request-context.ts b/src/gateway/local-request-context.ts index 2870f66a1f6e..8c76299cf0c9 100644 --- a/src/gateway/local-request-context.ts +++ b/src/gateway/local-request-context.ts @@ -17,6 +17,7 @@ import { withPluginRuntimeGatewayRequestScope, } from "../plugins/runtime/gateway-request-scope.js"; import { normalizeAgentId } from "../routing/session-key.js"; +import { loadGatewayConfigRevisionProjector } from "./config-revision-token.js"; import { NodeRegistry } from "./node-registry.js"; import type { ChannelRuntimeSnapshot } from "./server-channel-runtime.types.js"; import { createChatRunState } from "./server-chat-state.js"; @@ -125,6 +126,7 @@ function createLocalGatewayRequestContext( }); return { deps: params.deps, + configRevisionProjector: loadGatewayConfigRevisionProjector({ env: process.env }), cron, cronStorePath: "", getRuntimeConfig: params.getRuntimeConfig, diff --git a/src/gateway/server-kernel-request-runtime.ts b/src/gateway/server-kernel-request-runtime.ts index 266a67ad17de..09964b163f81 100644 --- a/src/gateway/server-kernel-request-runtime.ts +++ b/src/gateway/server-kernel-request-runtime.ts @@ -108,10 +108,18 @@ export async function prepareGatewayKernelRequestRuntime(params: { minimalTestGateway, log, }); + const configRevisionProjector = await startupTrace.measure( + "gateway.config-revision-key", + async () => { + const { loadGatewayConfigRevisionProjector } = await import("./config-revision-token.js"); + return loadGatewayConfigRevisionProjector({ env: process.env }); + }, + ); const gatewayRequestContext = await startupTrace.measure("gateway.request-context", async () => { const { createGatewayRequestContext } = await import("./server-request-context.js"); return createGatewayRequestContext({ deps, + configRevisionProjector, runtimeState, sessionCompanion, getRuntimeConfig, diff --git a/src/gateway/server-kernel.test.ts b/src/gateway/server-kernel.test.ts index 38235e01b60d..eaa97aa58cc8 100644 --- a/src/gateway/server-kernel.test.ts +++ b/src/gateway/server-kernel.test.ts @@ -421,6 +421,7 @@ describe("createGatewayKernel", () => { "runtime.subscriptions", "runtime.services", "gateway.handlers", + "gateway.config-revision-key", "gateway.request-context", ]); } finally { diff --git a/src/gateway/server-methods/config.test-helpers.ts b/src/gateway/server-methods/config.test-helpers.ts index d1dbd7db285f..1746b40968f5 100644 --- a/src/gateway/server-methods/config.test-helpers.ts +++ b/src/gateway/server-methods/config.test-helpers.ts @@ -67,6 +67,10 @@ export function createConfigHandlerHarness(args?: { isWebchatConnect: () => false, respond, context: { + configRevisionProjector: { + projectRawHash: (hash: string) => hash, + projectResolvedHash: (hash: string) => hash, + }, logGateway, disconnectClientsUsingSharedGatewayAuth, ...args?.contextOverrides, diff --git a/src/gateway/server-methods/config.ts b/src/gateway/server-methods/config.ts index d3c07fe02485..394e68054166 100644 --- a/src/gateway/server-methods/config.ts +++ b/src/gateway/server-methods/config.ts @@ -57,6 +57,7 @@ import { import { diffConfigPaths } from "../config-diff.js"; import { invalidateConfigGetResponseCache, readConfigGetResponse } from "../config-get-response.js"; import { resolveConfigReloadMetadata } from "../config-reload-plan.js"; +import type { GatewayConfigRevisionProjector } from "../config-revision-token.js"; import { formatControlPlaneActor, resolveControlPlaneActor, @@ -100,6 +101,7 @@ function requireConfigBaseHash( params: unknown, snapshot: Awaited>, respond: RespondFn, + revisionProjector: GatewayConfigRevisionProjector, ): boolean { if (!snapshot.exists) { return true; @@ -128,7 +130,7 @@ function requireConfigBaseHash( ); return false; } - if (baseHash !== snapshotHash) { + if (baseHash !== revisionProjector.projectRawHash(snapshotHash)) { respond( false, undefined, @@ -400,9 +402,10 @@ function rejectDestructiveArrayPatchWithoutIntent(params: { async function readConfigWriteSnapshotOrRespond( params: unknown, respond: RespondFn, + revisionProjector: GatewayConfigRevisionProjector, ): Promise> | null> { const result = await readConfigFileSnapshotForWrite(); - if (!requireConfigBaseHash(params, result.snapshot, respond)) { + if (!requireConfigBaseHash(params, result.snapshot, respond, revisionProjector)) { return null; } return result; @@ -683,7 +686,7 @@ async function respondWithConfigRestartWrite(params: { writeResult: ConfigWriteCommitResult; changedPaths: string[]; actor: ReturnType; - context: GatewayRequestContext | undefined; + context: GatewayRequestContext; respond: RespondFn; uiHints: ConfigRedactionHints; preparedSecretsSnapshot: PreparedSecretsRuntimeSnapshot; @@ -706,7 +709,9 @@ async function respondWithConfigRestartWrite(params: { path: params.writeResult.path, // Additive ack hash: matches the hash config.get would report for the // persisted bytes, so writers can adopt it without a reload. - ...(params.writeResult.hash ? { hash: params.writeResult.hash } : {}), + ...(params.writeResult.hash + ? { hash: params.context.configRevisionProjector.projectRawHash(params.writeResult.hash) } + : {}), config: redactConfigObject(params.writeResult.config, params.uiHints), ...preparedSecretDegradationPayload(params.preparedSecretsSnapshot), restart, @@ -849,6 +854,7 @@ export const configHandlers: GatewayRequestHandlers = { await readConfigGetResponse({ getHotReloadStatus: context.getConfigReloaderHotReloadStatus, loadUiHints: () => loadSchemaWithPlugins().uiHints, + revisionProjector: context.configRevisionProjector, }), undefined, ); @@ -896,7 +902,11 @@ export const configHandlers: GatewayRequestHandlers = { if (!assertValidParams(params, validateConfigSetParams, "config.set", respond)) { return; } - const writeSnapshot = await readConfigWriteSnapshotOrRespond(params, respond); + const writeSnapshot = await readConfigWriteSnapshotOrRespond( + params, + respond, + context.configRevisionProjector, + ); if (!writeSnapshot) { return; } @@ -945,7 +955,9 @@ export const configHandlers: GatewayRequestHandlers = { path: writeResult.path, // Additive ack hash: matches the hash config.get would report for the // persisted bytes, so writers can adopt it without a reload. - ...(writeResult.hash ? { hash: writeResult.hash } : {}), + ...(writeResult.hash + ? { hash: context.configRevisionProjector.projectRawHash(writeResult.hash) } + : {}), config: redactConfigObject(writeResult.config, parsed.schema.uiHints), ...preparedSecretDegradationPayload(preparedSecretsSnapshot), }, @@ -963,7 +975,7 @@ export const configHandlers: GatewayRequestHandlers = { // commit stale state, an accepted residual instead of adding connection-liveness plumbing. const writeSnapshot = hashlessPatch ? await readConfigFileSnapshotForWrite() - : await readConfigWriteSnapshotOrRespond(params, respond); + : await readConfigWriteSnapshotOrRespond(params, respond, context.configRevisionProjector); if (!writeSnapshot) { return; } @@ -1167,7 +1179,11 @@ export const configHandlers: GatewayRequestHandlers = { if (!assertValidParams(params, validateConfigApplyParams, "config.apply", respond)) { return; } - const writeSnapshot = await readConfigWriteSnapshotOrRespond(params, respond); + const writeSnapshot = await readConfigWriteSnapshotOrRespond( + params, + respond, + context.configRevisionProjector, + ); if (!writeSnapshot) { return; } diff --git a/src/gateway/server-methods/shared-types.ts b/src/gateway/server-methods/shared-types.ts index 88b52c62d4ee..1fdbdc04a28f 100644 --- a/src/gateway/server-methods/shared-types.ts +++ b/src/gateway/server-methods/shared-types.ts @@ -30,6 +30,7 @@ import type { } from "../agent-runtime-identity-token.js"; import type { ChatAbortControllerEntry } from "../chat-abort.js"; import type { GatewayHotReloadStatus } from "../config-reload-status.types.js"; +import type { GatewayConfigRevisionProjector } from "../config-revision-token.js"; import type { ScopeUpgradeCoordinator } from "../device-scope-upgrade.js"; import type { ExecApprovalManager, ExecApprovalRecord } from "../exec-approval-manager.js"; import type { AuthenticatedGitHubIdentitySync } from "../github-user-identity.js"; @@ -211,6 +212,7 @@ type GatewaySystemAgentSession = { /** Kernel-owned services and state that can be constructed without binding sockets. */ type GatewayKernelContext = { deps: CliDeps; + configRevisionProjector: GatewayConfigRevisionProjector; cron: GatewayCronServiceContract; cronStorePath: string; getRuntimeConfig: () => OpenClawConfig; diff --git a/src/gateway/server-reload-contracts.ts b/src/gateway/server-reload-contracts.ts index 2c6bddda496c..d5d3051478bc 100644 --- a/src/gateway/server-reload-contracts.ts +++ b/src/gateway/server-reload-contracts.ts @@ -183,6 +183,7 @@ export type ManagedGatewayConfigReloaderParams = Omit< GatewayReloadHandlerParams, "assertRestartReady" | "createHealthMonitor" | "logReload" > & { + configRevisionProjector: import("./config-revision-token.js").GatewayConfigRevisionProjector; minimalTestGateway: boolean; initialConfig: OpenClawConfig; initialCompareConfig?: OpenClawConfig; diff --git a/src/gateway/server-reload-handlers.hot-reload-status.test.ts b/src/gateway/server-reload-handlers.hot-reload-status.test.ts index 38866783bcc6..5331108746dd 100644 --- a/src/gateway/server-reload-handlers.hot-reload-status.test.ts +++ b/src/gateway/server-reload-handlers.hot-reload-status.test.ts @@ -56,6 +56,10 @@ describe("startManagedGatewayConfigReloader hotReloadStatus plumbing", () => { const initialConfig = { session: { store: "/tmp/sessions.json" } } as OpenClawConfig; const broadcast = vi.fn(); const reloader = startManagedGatewayConfigReloader({ + configRevisionProjector: { + projectRawHash: (hash) => `opaque:${hash}`, + projectResolvedHash: (hash) => `resolved:${hash}`, + }, minimalTestGateway: false, initialConfig, initialCompareConfig: initialConfig, @@ -137,7 +141,7 @@ describe("startManagedGatewayConfigReloader hotReloadStatus plumbing", () => { expect(hoisted.invalidateConfigGetResponseCache).toHaveBeenCalledOnce(); expect(broadcast).toHaveBeenCalledWith( "config.changed", - { path: "/tmp/openclaw.json", hash: "persisted-1", ts: expect.any(Number) }, + { path: "/tmp/openclaw.json", hash: "opaque:persisted-1", ts: expect.any(Number) }, { dropIfSlow: true }, ); diff --git a/src/gateway/server-reload-handlers.test.ts b/src/gateway/server-reload-handlers.test.ts index e7adebddfbc6..e946cbdbfcf4 100644 --- a/src/gateway/server-reload-handlers.test.ts +++ b/src/gateway/server-reload-handlers.test.ts @@ -207,6 +207,10 @@ function startManagedGatewayConfigReloader(params: ManagedReloaderTestParams) { commitTerminalConfig: vi.fn(), acceptTerminalConfig: vi.fn(), ...params, + configRevisionProjector: params.configRevisionProjector ?? { + projectRawHash: (hash) => hash, + projectResolvedHash: (hash) => hash, + }, initialSnapshotRawHash: params.initialSnapshotRawHash ?? null, initialAuthoredConfig: params.initialAuthoredConfig ?? {}, initialSnapshotValid: params.initialSnapshotValid ?? true, diff --git a/src/gateway/server-reload-managed.ts b/src/gateway/server-reload-managed.ts index 3ac261160f8e..d381f59dfb82 100644 --- a/src/gateway/server-reload-managed.ts +++ b/src/gateway/server-reload-managed.ts @@ -338,7 +338,13 @@ export function startManagedGatewayConfigReloader( invalidateConfigGetResponseCache(); params.broadcast( "config.changed", - { path: info.path, hash: info.persistedHash, ts: Date.now() }, + { + path: info.path, + hash: info.persistedHash + ? params.configRevisionProjector.projectRawHash(info.persistedHash) + : null, + ts: Date.now(), + }, { dropIfSlow: true }, ); }, diff --git a/src/gateway/server-request-context.test.ts b/src/gateway/server-request-context.test.ts index a79a29d05d83..7d1c5fef550c 100644 --- a/src/gateway/server-request-context.test.ts +++ b/src/gateway/server-request-context.test.ts @@ -120,6 +120,10 @@ function makeContextParams( getConfigReloaderHotReloadStatus: vi.fn(() => undefined), unavailableGatewayMethods: new Set(), ...overrides, + configRevisionProjector: overrides.configRevisionProjector ?? { + projectRawHash: (hash) => hash, + projectResolvedHash: (hash) => hash, + }, }; } diff --git a/src/gateway/server-request-context.ts b/src/gateway/server-request-context.ts index 66eaade47f2d..a902f58254ae 100644 --- a/src/gateway/server-request-context.ts +++ b/src/gateway/server-request-context.ts @@ -28,6 +28,7 @@ type GatewayRequestContextClient = GatewayClient & { type GatewayRequestContextParams = { deps: GatewayRequestContext["deps"]; + configRevisionProjector: GatewayRequestContext["configRevisionProjector"]; runtimeState: Pick< GatewayServerLiveState, "cronState" | "controlUiSessionPullRequests" | "sessionViewerPresence" @@ -168,6 +169,7 @@ export function createGatewayRequestContext( const scopeUpgradeCoordinator = new ScopeUpgradeCoordinator(); const context: GatewayRequestContextWithClientLookup = { deps: params.deps, + configRevisionProjector: params.configRevisionProjector, // Keep cron reads live so config hot reload can swap cron/store state without rebuilding // every handler closure that already holds this request context. get cron() { diff --git a/src/gateway/server-startup-finish.ts b/src/gateway/server-startup-finish.ts index 6bf0abdd4eda..aaa815f36618 100644 --- a/src/gateway/server-startup-finish.ts +++ b/src/gateway/server-startup-finish.ts @@ -356,6 +356,7 @@ export async function finishGatewayStartup(params: { const { startManagedGatewayConfigReloader } = await import("./server-reload-handlers.js"); const configReloaderParams: Parameters[0] = { + configRevisionProjector: gatewayRequestContext.configRevisionProjector, minimalTestGateway, initialConfig: cfgAtStart, initialCompareConfig: startupLastGoodSnapshot.sourceConfig, diff --git a/src/gateway/server.config-patch.test.ts b/src/gateway/server.config-patch.test.ts index 0096201b911f..c243499c142c 100644 --- a/src/gateway/server.config-patch.test.ts +++ b/src/gateway/server.config-patch.test.ts @@ -276,6 +276,9 @@ describe("gateway config methods", () => { }); it("includes the active runtime config revision", async () => { + const { readConfigFileSnapshot } = await import("../config/config.js"); + const { getRuntimeConfigAppliedHash, hashRuntimeConfigValue } = + await import("../config/runtime-snapshot.js"); const current = await rpcReq<{ hash?: string; configRevisionHash?: string; @@ -285,6 +288,29 @@ describe("gateway config methods", () => { expect(current.ok).toBe(true); expect(current.payload).toHaveProperty("configRevisionHash"); expect(current.payload).toHaveProperty("appliedConfigHash"); + const internal = await readConfigFileSnapshot(); + expect(current.payload?.hash).not.toBe(internal.hash); + expect(current.payload?.configRevisionHash).not.toBe( + hashRuntimeConfigValue(internal.sourceConfig), + ); + const internalAppliedHash = getRuntimeConfigAppliedHash(); + if (internalAppliedHash === null) { + expect(current.payload?.appliedConfigHash).toBeNull(); + } else { + expect(current.payload?.appliedConfigHash).not.toBe(internalAppliedHash); + } + }); + + it("rejects the internal raw digest as a public config base hash", async () => { + const { readConfigFileSnapshot } = await import("../config/config.js"); + const current = await getCurrentConfigObject(); + const internal = await readConfigFileSnapshot(); + expect(typeof internal.hash).toBe("string"); + + const response = await sendConfigSet(configRawPayload(current.config, internal.hash)); + + expect(response.ok).toBe(false); + expect(response.error?.message).toContain("config changed since last load"); }); it("rejects config.set when SecretRef resolution fails", async () => { @@ -310,6 +336,7 @@ describe("gateway config methods", () => { const res = await rpcReq<{ ok?: boolean; path?: string; + hash?: string; config?: Record; }>(requireWs(), "config.set", { ...configRawPayload(current.config, current.hash), @@ -318,6 +345,7 @@ describe("gateway config methods", () => { expect(res.ok).toBe(true); expect(res.payload?.path).toBe(createConfigIO().configPath); requireConfigObject(res.payload?.config, "updated config"); + expect(res.payload?.hash).toBe(await getConfigHash()); }); it.each([ diff --git a/src/gateway/server/health-state.test.ts b/src/gateway/server/health-state.test.ts index 122e02b7e768..e63ccddce8f1 100644 --- a/src/gateway/server/health-state.test.ts +++ b/src/gateway/server/health-state.test.ts @@ -27,6 +27,10 @@ vi.mock("../../config/io.js", async (importOriginal) => ({ getRuntimeConfig: getRuntimeConfigMock, })); +vi.mock("../../config/runtime-snapshot.js", () => ({ + getRuntimeConfigAppliedHash: () => "internal-applied-hash", +})); + vi.mock("../../infra/update-startup.js", () => ({ getUpdateAvailable: getUpdateAvailableMock, getUpdateSchedule: getUpdateScheduleMock, @@ -63,6 +67,11 @@ function createHealthSummary(): HealthSummary { }; } +const revisionProjector = { + projectRawHash: (hash: string) => `raw-token:${hash}`, + projectResolvedHash: (hash: string) => `resolved-token:${hash}`, +}; + async function loadHealthState() { vi.resetModules(); collectGatewayHealthSnapshotMock.mockReset(); @@ -106,7 +115,10 @@ describe("buildGatewaySnapshot update metadata", () => { install: { kind: "git" }, }); - const snapshot = healthState.buildGatewaySnapshot({ includeUpdateDetails: false }); + const snapshot = healthState.buildGatewaySnapshot({ + includeUpdateDetails: false, + revisionProjector, + }); expect(snapshot.updateAvailable).toEqual({ currentVersion: "2026.8.7", @@ -115,6 +127,7 @@ describe("buildGatewaySnapshot update metadata", () => { }); expect(snapshot.updateSchedule).toBeUndefined(); expect(snapshot.sessionDefaults).toMatchObject({ ownership: "sole", selectionRequired: false }); + expect(snapshot.appliedConfigHash).toBe("resolved-token:internal-applied-hash"); expect(getUpdateScheduleMock).not.toHaveBeenCalled(); }); @@ -138,7 +151,10 @@ describe("buildGatewaySnapshot update metadata", () => { getUpdateAvailableMock.mockReturnValue(updateAvailable); getUpdateScheduleMock.mockReturnValue(updateSchedule); - const snapshot = healthState.buildGatewaySnapshot({ includeUpdateDetails: true }); + const snapshot = healthState.buildGatewaySnapshot({ + includeUpdateDetails: true, + revisionProjector, + }); expect(snapshot.updateAvailable).toBe(updateAvailable); expect(snapshot.updateSchedule).toBe(updateSchedule); diff --git a/src/gateway/server/health-state.ts b/src/gateway/server/health-state.ts index ffb501978e3c..bba6b39e8c0b 100644 --- a/src/gateway/server/health-state.ts +++ b/src/gateway/server/health-state.ts @@ -10,6 +10,7 @@ import { normalizeMainKey } from "../../routing/session-key.js"; import { resolveGatewayAgentSelectionState } from "../agent-list.js"; import { resolveGatewayAuth } from "../auth.js"; import type { GatewayHotReloadStatus } from "../config-reload-status.types.js"; +import type { GatewayConfigRevisionProjector } from "../config-revision-token.js"; import { projectUpdateAvailable } from "../events.js"; import { collectGatewayHealthSnapshot } from "../health/collector.js"; import type { HealthSummary } from "../health/types.js"; @@ -46,9 +47,10 @@ const healthRefreshStates: Record = { }, }; -export function buildGatewaySnapshot(opts?: { +export function buildGatewaySnapshot(opts: { includeSensitive?: boolean; includeUpdateDetails?: boolean; + revisionProjector: GatewayConfigRevisionProjector; }): Snapshot { const cfg = getRuntimeConfig(); const selection = resolveGatewayAgentSelectionState(cfg); @@ -63,6 +65,7 @@ export function buildGatewaySnapshot(opts?: { const updateAvailable = projectUpdateAvailable(getUpdateAvailable(), includeUpdateDetails) ?? undefined; const updateSchedule = includeUpdateDetails ? (getUpdateSchedule() ?? undefined) : undefined; + const appliedConfigHash = getRuntimeConfigAppliedHash(); // Health is async; the caller replaces this with the collected snapshot. const emptyHealth: Snapshot["health"] = {}; const snapshot: Snapshot = { @@ -70,7 +73,9 @@ export function buildGatewaySnapshot(opts?: { health: emptyHealth, stateVersion: { presence: presenceVersion, health: healthVersion }, uptimeMs, - appliedConfigHash: getRuntimeConfigAppliedHash(), + appliedConfigHash: appliedConfigHash + ? opts.revisionProjector.projectResolvedHash(appliedConfigHash) + : null, sessionDefaults: { defaultAgentId, ownership: selection.ownership, diff --git a/src/gateway/server/ws-connection/connect-hello.ts b/src/gateway/server/ws-connection/connect-hello.ts index e6a0ff3f7f7c..ba6534c522db 100644 --- a/src/gateway/server/ws-connection/connect-hello.ts +++ b/src/gateway/server/ws-connection/connect-hello.ts @@ -102,6 +102,7 @@ export async function sendGatewayHello( const snapshot = buildGatewaySnapshot({ includeSensitive: scopes.includes(ADMIN_SCOPE), includeUpdateDetails: canReadDetailedUpdateMetadata(role, scopes), + revisionProjector: buildRequestContext().configRevisionProjector, }); const cachedHealth = getHealthCache(); if (cachedHealth) { diff --git a/src/state/openclaw-state-db-contract.ts b/src/state/openclaw-state-db-contract.ts index a308061b3ab5..4bdd5db7c7f9 100644 --- a/src/state/openclaw-state-db-contract.ts +++ b/src/state/openclaw-state-db-contract.ts @@ -36,6 +36,7 @@ export const LAZY_ADDITIVE_STATE_TABLES = [ "agent_provenance", "cron_run_receipts", "cron_store_epochs", + "config_revision_keys", "model_catalog_remote", "secret_store_entries", "projects", diff --git a/src/state/openclaw-state-db-schema-additive.ts b/src/state/openclaw-state-db-schema-additive.ts index 32ae69c53d3f..8dc23d84a6d2 100644 --- a/src/state/openclaw-state-db-schema-additive.ts +++ b/src/state/openclaw-state-db-schema-additive.ts @@ -30,6 +30,8 @@ const MCP_OAUTH_PENDING_SCHEMA_END = "\n) STRICT;"; const DEVICE_PAIRING_JOIN_CODE_SCHEMA_START = "CREATE TABLE IF NOT EXISTS device_pairing_join_codes ("; const DEVICE_PAIRING_JOIN_CODE_SCHEMA_END = "\n) STRICT;"; +const CONFIG_REVISION_KEY_SCHEMA_START = "CREATE TABLE IF NOT EXISTS config_revision_keys ("; +const CONFIG_REVISION_KEY_SCHEMA_END = "\n) STRICT;"; function secretStoreSchemaSql(): string { const start = OPENCLAW_STATE_SCHEMA_SQL.indexOf(SECRET_STORE_SCHEMA_START); @@ -77,6 +79,18 @@ export function ensureDevicePairingJoinCodeSchema(database: DatabaseSync): void ); // sqlite-allow-raw -- Canonical additive DDL only. } +/** Lazily installs the Gateway's installation-local config revision key owner. */ +export function ensureConfigRevisionKeySchema(database: DatabaseSync): void { + const start = OPENCLAW_STATE_SCHEMA_SQL.indexOf(CONFIG_REVISION_KEY_SCHEMA_START); + const endMarkerStart = OPENCLAW_STATE_SCHEMA_SQL.indexOf(CONFIG_REVISION_KEY_SCHEMA_END, start); + if (start < 0 || endMarkerStart < start) { + throw new Error("OpenClaw config revision key schema marker is missing."); + } + database.exec( + OPENCLAW_STATE_SCHEMA_SQL.slice(start, endMarkerStart + CONFIG_REVISION_KEY_SCHEMA_END.length), + ); // sqlite-allow-raw -- Canonical additive DDL only; key rows use Kysely. +} + export function ensureAgentDeletionJournalSchema(database: DatabaseSync): void { database.exec(` CREATE TABLE IF NOT EXISTS agent_deletion_journal ( diff --git a/src/state/openclaw-state-db.generated.d.ts b/src/state/openclaw-state-db.generated.d.ts index a510c8ba5be6..a3ec3ce58843 100644 --- a/src/state/openclaw-state-db.generated.d.ts +++ b/src/state/openclaw-state-db.generated.d.ts @@ -390,6 +390,11 @@ export interface ConfigMachineState { value_json: string; } +export interface ConfigRevisionKeys { + hmac_key: Uint8Array; + id: Generated; +} + export interface CronJobRuntimeAuthorities { authority_input_fingerprint: string | null; authority_json: string | null; @@ -1840,6 +1845,7 @@ export interface DB { command_log_entries: CommandLogEntries; config_health_entries: ConfigHealthEntries; config_machine_state: ConfigMachineState; + config_revision_keys: ConfigRevisionKeys; cron_job_runtime_authorities: CronJobRuntimeAuthorities; cron_job_scratch: CronJobScratch; cron_jobs: CronJobs; diff --git a/src/state/openclaw-state-schema-compatibility.test.ts b/src/state/openclaw-state-schema-compatibility.test.ts index 03e0a79a87eb..85e8ec71b248 100644 --- a/src/state/openclaw-state-schema-compatibility.test.ts +++ b/src/state/openclaw-state-schema-compatibility.test.ts @@ -16,5 +16,6 @@ describe("OpenClaw state runtime schema projection", () => { expect(schema).not.toContain("outbound_message_progress_run_occurred_idx"); expect(schema).not.toContain("CREATE TABLE IF NOT EXISTS github_publication_requests"); expect(schema).not.toContain("idx_github_publication_requests_pending"); + expect(schema).not.toContain("CREATE TABLE IF NOT EXISTS config_revision_keys"); }); }); diff --git a/src/state/openclaw-state-schema.sql b/src/state/openclaw-state-schema.sql index f02098232382..8b43d94bbe31 100644 --- a/src/state/openclaw-state-schema.sql +++ b/src/state/openclaw-state-schema.sql @@ -258,6 +258,11 @@ CREATE TABLE IF NOT EXISTS audit_identity_keys ( created_at INTEGER NOT NULL ) STRICT; +CREATE TABLE IF NOT EXISTS config_revision_keys ( + id INTEGER NOT NULL PRIMARY KEY CHECK (id = 1), + hmac_key BLOB NOT NULL CHECK (length(hmac_key) = 32) +) STRICT; + CREATE TABLE IF NOT EXISTS execution_identity_contexts ( context_id TEXT NOT NULL PRIMARY KEY CHECK (length(context_id) BETWEEN 1 AND 256), execution_id TEXT NOT NULL UNIQUE CHECK (length(execution_id) BETWEEN 1 AND 256), diff --git a/src/state/secret-state-tables.ts b/src/state/secret-state-tables.ts index 3a10d6e5f99b..b21525ef6732 100644 --- a/src/state/secret-state-tables.ts +++ b/src/state/secret-state-tables.ts @@ -7,6 +7,7 @@ export const STATE_SECRET_TABLE_NAMES = [ "channel_ingress_events", "channel_pairing_requests", "clawhub_promotion_claims", + "config_revision_keys", "device_auth_tokens", "device_bootstrap_tokens", "device_identities", diff --git a/ui/src/e2e/config-safe-write.e2e.test.ts b/ui/src/e2e/config-safe-write.e2e.test.ts index a067c17c836d..87bc11785e6a 100644 --- a/ui/src/e2e/config-safe-write.e2e.test.ts +++ b/ui/src/e2e/config-safe-write.e2e.test.ts @@ -375,4 +375,74 @@ suite.define(() => { }, ); }); + + it("keeps a dirty draft and adopts an opaque revision after an unchanged reconnect", async () => { + await suite.withPage( + { + colorScheme: "dark", + locale: "en-US", + recordVideo: captureUiProofEnabled + ? { dir: uiProofArtifactDir, size: { height: 1000, width: 1440 } } + : undefined, + serviceWorkers: "block", + viewport: { height: 1000, width: 1440 }, + }, + async ({ page }) => { + const config = { + laboratory: { endpoint: "initial-api", retryBudget: 2 }, + tools: {}, + }; + const gateway = await installMockGateway(page, { + methodResponses: { + "config.get": configResponse(config, "legacy-raw-hash"), + "config.schema": configSchemaResponse(), + }, + }); + + expect( + ( + await page.goto(`${suite.server.baseUrl}settings/advanced?section=laboratory`) + )?.status(), + ).toBe(200); + const endpoint = page.getByRole("textbox", { name: "Endpoint", exact: true }); + await expect.poll(() => endpoint.inputValue()).toBe("initial-api"); + await endpoint.fill("retained-draft"); + + const getsBeforeReconnect = (await gateway.getRequests("config.get")).length; + await gateway.setMethodResponse( + "config.get", + configResponse(config, "hmac-sha256:v1:opaque-current"), + ); + await gateway.setOnline(false); + await gateway.setOnline(true); + await expect + .poll(async () => (await gateway.getRequests("config.get")).length) + .toBe(getsBeforeReconnect + 1); + await expect.poll(() => endpoint.inputValue()).toBe("retained-draft"); + + const saveIndicator = page.locator("openclaw-settings-save-indicator"); + await expect + .poll(() => saveIndicator.textContent()) + .toContain("Autosave paused after reconnect"); + await capture(page, "07-opaque-revision-reconnect.png"); + + await gateway.deferNext("config.set"); + await saveIndicator.getByRole("button", { name: "Save", exact: true }).click(); + const save = mutationParams(await gateway.waitForRequest("config.set")); + expect(save.baseHash).toBe("hmac-sha256:v1:opaque-current"); + expect(JSON.parse(String(save.raw))).toMatchObject({ + laboratory: { endpoint: "retained-draft", retryBudget: 2 }, + }); + await gateway.setMethodResponse( + "config.get", + configResponse( + { ...config, laboratory: { ...config.laboratory, endpoint: "retained-draft" } }, + "hmac-sha256:v1:opaque-next", + ), + ); + await gateway.resolveDeferred("config.set", { hash: "hmac-sha256:v1:opaque-next" }); + await expect.poll(() => endpoint.inputValue()).toBe("retained-draft"); + }, + ); + }); }); diff --git a/ui/src/lib/config/config-gateway-operations.ts b/ui/src/lib/config/config-gateway-operations.ts index b57a7824c997..a0b0a7b2e2be 100644 --- a/ui/src/lib/config/config-gateway-operations.ts +++ b/ui/src/lib/config/config-gateway-operations.ts @@ -18,11 +18,48 @@ import { isCurrentConfigConnection, isCurrentRequest, nextRequestVersion, + resolveEditableSnapshotConfig, type ConfigGatewayClient, type LoadConfigOptions, type RuntimeConfigState, } from "./config-state-model.ts"; +function comparableSnapshotRaw(snapshot: RuntimeConfigState["configSnapshot"]): string | null { + if (typeof snapshot?.raw === "string") { + return snapshot.raw; + } + const editable = resolveEditableSnapshotConfig(snapshot); + return editable ? serializeConfigForm(editable) : null; +} + +export async function refreshDraft( + state: RuntimeConfigState, + refreshConnectionState: () => Promise, + publish: () => void, + reconcileAppliedRefresh: () => void, +): Promise { + const previousRaw = + state.configFormMode === "form" && state.configFormDirty + ? comparableSnapshotRaw(state.configSnapshot) + : null; + const client = state.client; + const epoch = currentConfigConnectionEpoch(state); + const loaded = await refreshConnectionState(); + if ( + loaded && + client && + isCurrentConfigConnection(state, client, epoch) && + previousRaw !== null && + comparableSnapshotRaw(state.configSnapshot) === previousRaw + ) { + // Upgrade/restart may replace the public revision token without changing + // the redacted base. A changed or unavailable base must still conflict. + state.configDraftBaseHash = state.configSnapshot?.hash ?? state.configDraftBaseHash; + publish(); + } + reconcileAppliedRefresh(); +} + function readAckHash(ack: unknown): string | null { const hash = (ack as { hash?: unknown } | null | undefined)?.hash; return typeof hash === "string" && hash.length > 0 ? hash : null; diff --git a/ui/src/lib/config/config-write-coordinator.test.ts b/ui/src/lib/config/config-write-coordinator.test.ts index 0758b46efe66..54ce3a34ec41 100644 --- a/ui/src/lib/config/config-write-coordinator.test.ts +++ b/ui/src/lib/config/config-write-coordinator.test.ts @@ -15,6 +15,80 @@ import { } from "./config-test-harness.ts"; describe("config write coordinator", () => { + it("rebinds a retained draft to an opaque revision when the reconnect base is unchanged", async () => { + vi.useFakeTimers(); + let hash = "legacy-raw-hash"; + const raw = '{\n "count": 1\n}\n'; + const submissions: Array<{ raw: string; baseHash: string }> = []; + const request = vi.fn(async (method: string, params?: unknown) => { + if (method === "config.get") { + return { config: { count: 1 }, raw, hash, valid: true, issues: [] }; + } + if (method === "config.set") { + submissions.push(params as { raw: string; baseHash: string }); + return { hash: "opaque-next" }; + } + return {}; + }); + const { runtimeConfig, publish } = createConfigCapabilityHarness( + request as GatewayBrowserClient["request"], + ); + await runtimeConfig.ensureLoaded(); + runtimeConfig.patchForm(["count"], 2); + + publish(false); + hash = "opaque-current"; + publish(true); + await vi.advanceTimersByTimeAsync(0); + + expect(runtimeConfig.state.configForm).toEqual({ count: 2 }); + expect(runtimeConfig.state.configFormDirty).toBe(true); + expect(runtimeConfig.state.configDraftBaseHash).toBe("opaque-current"); + expect(runtimeConfig.state.configAutoSaveStatus).toBe("paused"); + await expect(runtimeConfig.save()).resolves.toBe(true); + expect(submissions).toEqual([{ raw: '{\n "count": 2\n}\n', baseHash: "opaque-current" }]); + runtimeConfig.dispose(); + }); + + it("keeps the old revision and conflicts when the reconnect base changed", async () => { + vi.useFakeTimers(); + let hash = "legacy-raw-hash"; + let raw = '{\n "count": 1\n}\n'; + const submissions: Array<{ raw: string; baseHash: string }> = []; + const request = vi.fn(async (method: string, params?: unknown) => { + if (method === "config.get") { + return { config: JSON.parse(raw), raw, hash, valid: true, issues: [] }; + } + if (method === "config.set") { + const submission = params as { raw: string; baseHash: string }; + submissions.push(submission); + if (submission.baseHash !== hash) { + throw new Error("config changed since last load; re-run config.get and retry"); + } + return { hash: "opaque-next" }; + } + return {}; + }); + const { runtimeConfig, publish } = createConfigCapabilityHarness( + request as GatewayBrowserClient["request"], + ); + await runtimeConfig.ensureLoaded(); + runtimeConfig.patchForm(["count"], 2); + + publish(false); + raw = '{\n "count": 9\n}\n'; + hash = "opaque-current"; + publish(true); + await vi.advanceTimersByTimeAsync(0); + + expect(runtimeConfig.state.configForm).toEqual({ count: 2 }); + expect(runtimeConfig.state.configDraftBaseHash).toBe("legacy-raw-hash"); + await expect(runtimeConfig.save()).resolves.toBe(false); + expect(submissions).toEqual([{ raw: '{\n "count": 2\n}\n', baseHash: "legacy-raw-hash" }]); + expect(runtimeConfig.state.configAutoSaveStatus).toBe("conflict"); + runtimeConfig.dispose(); + }); + it("surfaces an operator.admin reason when config mutations are out of scope", async () => { const server = createConfigServerMock(); const { runtimeConfig, publish } = createConfigCapabilityHarness( diff --git a/ui/src/lib/config/config-write-coordinator.ts b/ui/src/lib/config/config-write-coordinator.ts index a1629330dafd..201cae891464 100644 --- a/ui/src/lib/config/config-write-coordinator.ts +++ b/ui/src/lib/config/config-write-coordinator.ts @@ -18,6 +18,7 @@ import { executeConfigExternalMutation, loadConfig, patchConfig, + refreshDraft, saveConfig, teardownFlushConfigDraft, type ConfigPatchBuildResult, @@ -391,10 +392,9 @@ export function createConfigWriteCoordinator({ }; const stopGateway = gateway.subscribe((snapshot) => { const clientChanged = state.client !== snapshot.client; - const connected = snapshot.phase === "connected"; - const connectionChanged = state.connected !== connected; + const connectionChanged = state.connected !== (snapshot.phase === "connected"); state.client = snapshot.client; - state.connected = connected; + state.connected = snapshot.phase === "connected"; state.applySessionKey = snapshot.sessionKey; if (clientChanged || connectionChanged) { const draftBelongsToPreviousConnection = @@ -518,7 +518,7 @@ export function createConfigWriteCoordinator({ reconcileAppliedRefresh(); }); } else { - void refreshConnectionState().then(() => reconcileAppliedRefresh()); + void refreshDraft(state, refreshConnectionState, publish, reconcileAppliedRefresh); } } }