mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-23 19:08:22 -06:00
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:
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user