fix(gateway): make config revision tokens opaque (#126464)

* fix(gateway): make config revision tokens opaque

* test(gateway): cover config revision key startup phase
This commit is contained in:
Josh Avant
2026-08-19 18:10:27 -05:00
committed by GitHub
parent 88345d984b
commit a4f17833ad
33 changed files with 633 additions and 34 deletions
+29 -5
View File
@@ -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
}
@@ -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)
+14 -2
View File
@@ -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<Parameters<typeof readConfigGetResponseImpl>[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);
});
+26 -5
View File
@@ -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<typeof createConfigGetResponse>;
let configGetResponseCache:
| {
getHotReloadStatus: () => GatewayHotReloadStatus | undefined;
revisionProjector: GatewayConfigRevisionProjector;
appliedConfigHash: string | null;
pluginRegistryVersion: number;
promise: Promise<ConfigGetResponse>;
@@ -18,11 +20,19 @@ let configGetResponseCache:
function createConfigGetResponse(
snapshot: ConfigFileSnapshot,
uiHints: Parameters<typeof redactConfigSnapshot>[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<typeof redactConfigSnapshot>[1];
revisionProjector: GatewayConfigRevisionProjector;
}): Promise<ConfigGetResponse> {
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,
+82
View File
@@ -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 });
});
});
+120
View File
@@ -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<OpenClawStateKyselyDatabase, "config_revision_keys">;
type ConfigRevisionKeyRow = Pick<
Selectable<ConfigRevisionKeyDatabase["config_revision_keys"]>,
"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<typeof getNodeSqliteKysely>[0],
candidateKey: Uint8Array,
): Uint8Array {
const db = getNodeSqliteKysely<ConfigRevisionKeyDatabase>(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" },
);
}
+2
View File
@@ -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,
@@ -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,
+1
View File
@@ -421,6 +421,7 @@ describe("createGatewayKernel", () => {
"runtime.subscriptions",
"runtime.services",
"gateway.handlers",
"gateway.config-revision-key",
"gateway.request-context",
]);
} finally {
@@ -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,
+24 -8
View File
@@ -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<ReturnType<typeof readConfigFileSnapshot>>,
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<Awaited<ReturnType<typeof readConfigFileSnapshotForWrite>> | 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<typeof resolveControlPlaneActor>;
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;
}
@@ -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;
+1
View File
@@ -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;
@@ -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 },
);
@@ -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,
+7 -1
View File
@@ -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 },
);
},
@@ -120,6 +120,10 @@ function makeContextParams(
getConfigReloaderHotReloadStatus: vi.fn(() => undefined),
unavailableGatewayMethods: new Set(),
...overrides,
configRevisionProjector: overrides.configRevisionProjector ?? {
projectRawHash: (hash) => hash,
projectResolvedHash: (hash) => hash,
},
};
}
+2
View File
@@ -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() {
+1
View File
@@ -356,6 +356,7 @@ export async function finishGatewayStartup(params: {
const { startManagedGatewayConfigReloader } = await import("./server-reload-handlers.js");
const configReloaderParams: Parameters<typeof startManagedGatewayConfigReloader>[0] = {
configRevisionProjector: gatewayRequestContext.configRevisionProjector,
minimalTestGateway,
initialConfig: cfgAtStart,
initialCompareConfig: startupLastGoodSnapshot.sourceConfig,
+28
View File
@@ -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<string, unknown>;
}>(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([
+18 -2
View File
@@ -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);
+7 -2
View File
@@ -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<HealthAudience, HealthRefreshState> = {
},
};
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,
@@ -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) {
+1
View File
@@ -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",
@@ -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 (
+6
View File
@@ -390,6 +390,11 @@ export interface ConfigMachineState {
value_json: string;
}
export interface ConfigRevisionKeys {
hmac_key: Uint8Array;
id: Generated<number>;
}
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;
@@ -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");
});
});
+5
View File
@@ -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),
+1
View File
@@ -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",
+70
View File
@@ -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");
},
);
});
});
@@ -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<boolean>,
publish: () => void,
reconcileAppliedRefresh: () => void,
): Promise<void> {
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;
@@ -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(
@@ -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);
}
}
}