fix(gateway): make hot reload transactional (#105289)

* fix(gateway): make hot reload transactional

Replace partial reload side effects with a deferred transaction that publishes config, secrets, auth, and subsystem state together, and drains in-flight reload work before shutdown.

Co-authored-by: LZY3538 <293718838+LZY3538@users.noreply.github.com>

* fix(auth): preserve state-only credential ownership

Keep derived runtime snapshots in place for main-store state mutations so order refreshes do not look like credential replacement.

* fix(gateway): close reload transaction gaps

* fix(gateway): close merged reload gaps

* chore: move reload note to PR context

* fix(gateway): exclude restart emission root

---------

Co-authored-by: LZY3538 <293718838+LZY3538@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-07-12 18:16:15 -07:00
committed by GitHub
parent c93f87756a
commit 3616fba951
79 changed files with 18295 additions and 1026 deletions
@@ -0,0 +1,128 @@
// Auth-profile saves must not report a failed transaction after rows became durable.
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import type { AuthProfileStore } from "./auth-profiles/types.js";
const chmodFailHook = vi.hoisted(() => ({
error: undefined as Error | undefined,
}));
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
const chmodSync: typeof actual.chmodSync = ((target: unknown, mode: unknown) => {
if (chmodFailHook.error) {
throw chmodFailHook.error;
}
return (actual.chmodSync as (...args: unknown[]) => unknown)(target, mode);
}) as typeof actual.chmodSync;
return { ...actual, chmodSync, default: { ...actual, chmodSync } };
});
const {
readPersistedAuthProfileStoreRaw,
runAuthProfileWriteTransaction,
writePersistedAuthProfileStoreRaw,
} = await import("./auth-profiles/sqlite.js");
const {
captureAuthProfileStorePersistenceSnapshot,
clearRuntimeAuthProfileStoreSnapshots,
getRuntimeAuthProfileStoreSnapshot,
replaceRuntimeAuthProfileStoreSnapshots,
saveAuthProfileStore,
saveAuthProfileStoreIfPersistenceSnapshotMatches,
} = await import("./auth-profiles/store.js");
const { closeOpenClawAgentDatabasesForTest } = await import("../state/openclaw-agent-db.js");
const { closeOpenClawStateDatabaseForTest } = await import("../state/openclaw-state-db.js");
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
describe("auth-profile database permission repair", () => {
afterEach(() => {
chmodFailHook.error = undefined;
clearRuntimeAuthProfileStoreSnapshots();
closeOpenClawAgentDatabasesForTest();
closeOpenClawStateDatabaseForTest();
vi.unstubAllEnvs();
});
it("keeps captured auth rows when pre-commit permission repair fails", () => {
const stateDir = tempDirs.make("openclaw-auth-chmod-");
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
const agentDir = join(stateDir, "agents", "main", "agent");
const initial: AuthProfileStore = {
version: 1,
profiles: {
"openai:default": {
type: "api_key",
provider: "openai",
key: "fake-initial",
},
},
};
const next: AuthProfileStore = {
version: 1,
profiles: {
"openai:default": {
type: "api_key",
provider: "openai",
key: "fake-next",
},
},
};
writePersistedAuthProfileStoreRaw(initial, agentDir);
const snapshot = captureAuthProfileStorePersistenceSnapshot(agentDir);
const permissionError = Object.assign(new Error("EACCES: chmod failed"), {
code: "EACCES",
});
chmodFailHook.error = permissionError;
expect(() =>
saveAuthProfileStoreIfPersistenceSnapshotMatches({
agentDir,
snapshot,
store: next,
options: {
filterExternalAuthProfiles: false,
syncExternalCli: false,
},
}),
).toThrow(permissionError);
chmodFailHook.error = undefined;
expect(readPersistedAuthProfileStoreRaw(agentDir)).toEqual(initial);
});
it("does not publish a caller-owned save before permission repair commits", () => {
const stateDir = tempDirs.make("openclaw-auth-overload-chmod-");
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
const agentDir = join(stateDir, "agents", "main", "agent");
const initial: AuthProfileStore = {
version: 1,
profiles: {
"openai:default": { type: "api_key", provider: "openai", key: "fake-initial" },
},
};
const next: AuthProfileStore = {
version: 1,
profiles: {
"openai:default": { type: "api_key", provider: "openai", key: "fake-next" },
},
};
writePersistedAuthProfileStoreRaw(initial, agentDir);
replaceRuntimeAuthProfileStoreSnapshots([{ agentDir, store: initial }]);
const permissionError = Object.assign(new Error("EACCES: chmod failed"), {
code: "EACCES",
});
chmodFailHook.error = permissionError;
expect(() =>
runAuthProfileWriteTransaction(agentDir, (database) => {
saveAuthProfileStore(next, agentDir, undefined, database);
}),
).toThrow(permissionError);
chmodFailHook.error = undefined;
expect(readPersistedAuthProfileStoreRaw(agentDir)).toEqual(initial);
expect(getRuntimeAuthProfileStoreSnapshot(agentDir)).toEqual(initial);
});
});
+2 -2
View File
@@ -6,7 +6,7 @@
import type { AuthProfileStore } from "./types.js";
/** Deep-clones an auth profile store and rejects non-JSON values. */
export function cloneAuthProfileStore(store: AuthProfileStore): AuthProfileStore {
export function cloneAuthProfileStore<T extends AuthProfileStore>(store: T): T {
return JSON.parse(
JSON.stringify(store, (_key, value: unknown) => {
if (typeof value === "bigint" || typeof value === "function" || typeof value === "symbol") {
@@ -14,5 +14,5 @@ export function cloneAuthProfileStore(store: AuthProfileStore): AuthProfileStore
}
return value;
}),
) as AuthProfileStore;
) as T;
}
@@ -241,6 +241,7 @@ describe("persisted auth profile boundary", () => {
{
version: AUTH_STORE_VERSION,
runtimePersistedProfileIds: ["openai:added"],
runtimeLocalProfileIds: ["openai:added"],
profiles: {
"openai:overridden": {
type: "api_key",
@@ -257,6 +258,7 @@ describe("persisted auth profile boundary", () => {
);
expect(merged.runtimePersistedProfileIds).toEqual(["openai:added", "openai:base"]);
expect(merged.runtimeLocalProfileIds).toEqual(["openai:added"]);
});
it("preserves config-only order fallbacks during agent-store merges", () => {
+14 -4
View File
@@ -31,6 +31,7 @@ import type {
AuthProfileCredential,
AuthProfileSecretsStore,
AuthProfileStore,
RuntimeAuthProfileStore,
OAuthCredential,
OAuthCredentials,
} from "./types.js";
@@ -583,16 +584,18 @@ function reconcileMainStoreOAuthProfileDrift(params: {
/** Merges two auth profile stores, preserving valid runtime external profile metadata. */
export function mergeAuthProfileStores(
base: AuthProfileStore,
override: AuthProfileStore,
base: RuntimeAuthProfileStore,
override: RuntimeAuthProfileStore,
options?: { preserveBaseRuntimeExternalProfiles?: boolean },
): AuthProfileStore {
): RuntimeAuthProfileStore {
if (
Object.keys(override.profiles).length === 0 &&
!override.order &&
!override.lastGood &&
!override.usageStats &&
override.runtimePersistedProfileIds === undefined &&
override.runtimeLocalProfileIds === undefined &&
override.runtimeInheritsMainState === undefined &&
override.runtimeExternalProfileIds === undefined &&
override.runtimeExternalProfileIdsAuthoritative !== true
) {
@@ -660,6 +663,9 @@ export function mergeAuthProfileStores(
]
.filter((profileId) => merged.profiles[profileId])
.toSorted();
const runtimeLocalProfileIds = override.runtimeLocalProfileIds
?.filter((profileId) => merged.profiles[profileId])
.toSorted();
const baseRuntimeExternalProfileIds =
override.runtimeExternalProfileIdsAuthoritative === true &&
options?.preserveBaseRuntimeExternalProfiles !== true
@@ -693,9 +699,13 @@ export function mergeAuthProfileStores(
...(runtimePersistedProfileIds.length > 0
? { runtimePersistedProfileIds: [...new Set(runtimePersistedProfileIds)] }
: {}),
...(runtimeLocalProfileIds ? { runtimeLocalProfileIds } : {}),
...(override.runtimeInheritsMainState !== undefined
? { runtimeInheritsMainState: override.runtimeInheritsMainState }
: {}),
...runtimeExternalProfileMetadata,
},
});
}) as RuntimeAuthProfileStore;
}
/** Builds the persisted secrets store, stripping resolved literals when refs exist. */
+714 -4
View File
@@ -6,12 +6,16 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import { resolveOAuthDir } from "../../config/paths.js";
import { closeOpenClawAgentDatabasesForTest } from "../../state/openclaw-agent-db.js";
import {
closeOpenClawAgentDatabasesForTest,
openOpenClawAgentDatabase,
} from "../../state/openclaw-agent-db.js";
import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js";
import { withEnvAsync } from "../../test-utils/env.js";
import { AUTH_STORE_VERSION } from "./constants.js";
import { testing as externalAuthTesting } from "./external-auth.js";
import { loadPersistedAuthProfileStore } from "./persisted.js";
import {
clearLastGoodProfileWithLock,
@@ -19,14 +23,26 @@ import {
upsertAuthProfileWithLock,
} from "./profiles.js";
import {
getRuntimeAuthProfileStoreSnapshot as getInternalRuntimeAuthProfileStoreSnapshot,
getRuntimeAuthProfileStoreCredentialMutationRevision,
getRuntimeAuthProfileStoreCredentialsRevision,
getRuntimeAuthProfileStoreStateMutationRevision,
} from "./runtime-snapshots.js";
import { resolveAuthProfileDatabasePath, runAuthProfileWriteTransaction } from "./sqlite.js";
import {
captureAuthProfileStorePersistenceSnapshot,
clearRuntimeAuthProfileStoreSnapshots,
ensureAuthProfileStoreWithoutExternalProfiles,
getRuntimeAuthProfileStoreSnapshot,
loadAuthProfileStoreForRuntime,
loadAuthProfileStoreWithoutExternalProfiles,
replaceRuntimeAuthProfileStoreSnapshots,
restoreAuthProfileStorePersistenceSnapshot,
saveAuthProfileStoreIfPersistenceSnapshotMatches,
saveAuthProfileStore,
testing as storeTesting,
} from "./store.js";
import type { AuthProfileStore } from "./types.js";
import type { AuthProfileStore, RuntimeAuthProfileStore } from "./types.js";
type ExpectedOAuthCredentialFields = {
provider: string;
@@ -45,6 +61,11 @@ type AuthProfileTestState = {
agentDirFor: (agentId: string) => string;
};
afterEach(() => {
storeTesting.resetRuntimeSnapshotPublisherForTest();
clearRuntimeAuthProfileStoreSnapshots();
});
async function withAuthProfileTestState<T>(
prefix: string,
run: (state: AuthProfileTestState) => Promise<T> | T,
@@ -99,6 +120,689 @@ function expectOAuthCredentialFields(
}
describe("promoteAuthProfileInOrder", () => {
it("refreshes inherited main selection state without advancing credential ownership", async () => {
await withAuthProfileTestState(
"openclaw-auth-profile-main-selection-",
async ({ agentDirFor }) => {
const customAgentDir = agentDirFor("custom");
fs.mkdirSync(customAgentDir, { recursive: true });
const mainStore = (selected: string): AuthProfileStore => ({
version: AUTH_STORE_VERSION,
profiles: {
"openai:first": {
type: "api_key",
provider: "openai",
key: "sk-first",
},
"openai:second": {
type: "api_key",
provider: "openai",
key: "sk-second",
},
},
order: { openai: [selected] },
});
saveAuthProfileStore(mainStore("openai:first"));
replaceRuntimeAuthProfileStoreSnapshots([
{
agentDir: customAgentDir,
store: loadAuthProfileStoreForRuntime(customAgentDir),
},
]);
const credentialsRevision = getRuntimeAuthProfileStoreCredentialsRevision();
saveAuthProfileStore(mainStore("openai:second"));
expect(getRuntimeAuthProfileStoreCredentialsRevision()).toBe(credentialsRevision);
expect(getRuntimeAuthProfileStoreSnapshot(customAgentDir)?.order?.openai).toEqual([
"openai:second",
]);
},
{ clearOAuthDir: true },
);
});
it("rebuilds a derived custom-agent snapshot after locked main OAuth rotation", async () => {
await withAuthProfileTestState(
"openclaw-auth-profile-main-inheritance-",
async ({ agentDirFor }) => {
const customAgentDir = agentDirFor("custom");
fs.mkdirSync(customAgentDir, { recursive: true });
const mainStore = (access: string): AuthProfileStore => ({
version: AUTH_STORE_VERSION,
profiles: {
"openai:default": {
type: "oauth",
provider: "openai",
access,
refresh: `refresh-${access}`,
expires: Date.now() + 60_000,
},
},
});
saveAuthProfileStore(mainStore("old"));
saveAuthProfileStore(
{
version: AUTH_STORE_VERSION,
profiles: {
"anthropic:custom": {
type: "api_key",
provider: "anthropic",
keyRef: { source: "env", provider: "default", id: "ANTHROPIC_API_KEY" },
key: "sk-custom-resolved",
},
},
},
customAgentDir,
);
const derivedStore = loadAuthProfileStoreForRuntime(customAgentDir);
const customCredential = derivedStore.profiles["anthropic:custom"];
if (customCredential?.type !== "api_key") {
throw new Error("expected custom API-key profile");
}
customCredential.key = "sk-custom-resolved";
replaceRuntimeAuthProfileStoreSnapshots([
{
agentDir: customAgentDir,
store: derivedStore,
},
]);
expect(
getRuntimeAuthProfileStoreSnapshot(customAgentDir)?.profiles["openai:default"],
).toMatchObject({ access: "old" });
await upsertAuthProfileWithLock({
profileId: "openai:default",
credential: {
type: "oauth",
provider: "openai",
access: "new",
refresh: "refresh-new",
expires: Date.now() + 60_000,
},
});
expect(
getRuntimeAuthProfileStoreSnapshot(customAgentDir)?.profiles["openai:default"],
).toMatchObject({ access: "new", refresh: "refresh-new" });
expect(
ensureAuthProfileStoreWithoutExternalProfiles(customAgentDir).profiles[
"anthropic:custom"
],
).toMatchObject({
key: "sk-custom-resolved",
keyRef: { source: "env", provider: "default", id: "ANTHROPIC_API_KEY" },
});
},
{ clearOAuthDir: true },
);
});
it("keeps inherited resolved credentials when publishing a locked custom-agent save", async () => {
await withAuthProfileTestState(
"openclaw-auth-profile-custom-publication-",
async ({ agentDirFor }) => {
const customAgentDir = agentDirFor("custom");
fs.mkdirSync(customAgentDir, { recursive: true });
saveAuthProfileStore({
version: AUTH_STORE_VERSION,
profiles: {
"anthropic:inherited": {
type: "api_key",
provider: "anthropic",
keyRef: { source: "env", provider: "default", id: "ANTHROPIC_API_KEY" },
},
},
});
saveAuthProfileStore(
{
version: AUTH_STORE_VERSION,
profiles: {
"openai:local": {
type: "oauth",
provider: "openai",
access: "local-old",
refresh: "local-refresh-old",
expires: Date.now() + 60_000,
},
},
},
customAgentDir,
);
const runtimeStore = loadAuthProfileStoreForRuntime(customAgentDir);
const inherited = runtimeStore.profiles["anthropic:inherited"];
if (inherited?.type !== "api_key") {
throw new Error("expected inherited API-key profile");
}
inherited.key = "sk-inherited-resolved";
replaceRuntimeAuthProfileStoreSnapshots([
{ agentDir: customAgentDir, store: runtimeStore },
]);
externalAuthTesting.setResolveExternalAuthProfilesForTest(() => {
throw new Error("external auth hook must not run during postcommit rebuild");
});
try {
await upsertAuthProfileWithLock({
agentDir: customAgentDir,
profileId: "openai:local",
credential: {
type: "oauth",
provider: "openai",
access: "local-new",
refresh: "local-refresh-new",
expires: Date.now() + 120_000,
},
});
} finally {
externalAuthTesting.resetResolveExternalAuthProfilesForTest();
}
expect(
getRuntimeAuthProfileStoreSnapshot(customAgentDir)?.profiles["anthropic:inherited"],
).toMatchObject({
key: "sk-inherited-resolved",
keyRef: { source: "env", provider: "default", id: "ANTHROPIC_API_KEY" },
});
expect(
getRuntimeAuthProfileStoreSnapshot(customAgentDir)?.profiles["openai:local"],
).toMatchObject({ access: "local-new", refresh: "local-refresh-new" });
},
{ clearOAuthDir: true },
);
});
it("clears runtime snapshots when postcommit publication throws", () => {
replaceRuntimeAuthProfileStoreSnapshots([
{
store: {
version: AUTH_STORE_VERSION,
profiles: {
"openai:default": { type: "api_key", provider: "openai", key: "sk-runtime" },
},
},
},
]);
expect(
storeTesting.publishRuntimeSnapshotsAfterCommit(() => {
throw new Error("postcommit publication failed");
}),
).toBe(false);
expect(getRuntimeAuthProfileStoreSnapshot()).toBeUndefined();
});
it("keeps a direct save committed when postcommit publication throws", async () => {
await withAuthProfileTestState("openclaw-auth-direct-publication-", async ({ agentDir }) => {
const store = (key: string): AuthProfileStore => ({
version: AUTH_STORE_VERSION,
profiles: {
"openai:default": { type: "api_key", provider: "openai", key },
},
});
saveAuthProfileStore(store("sk-old"), agentDir);
replaceRuntimeAuthProfileStoreSnapshots([
{ agentDir, store: loadAuthProfileStoreForRuntime(agentDir) },
]);
storeTesting.setRuntimeSnapshotPublisherForTest((publish) => {
publish();
throw new Error("postcommit publication failed");
});
let result: ReturnType<typeof saveAuthProfileStore> = undefined;
try {
expect(() => {
result = saveAuthProfileStore(store("sk-new"), agentDir);
}).not.toThrow();
} finally {
storeTesting.resetRuntimeSnapshotPublisherForTest();
}
expect(result).toBeUndefined();
expect(loadPersistedAuthProfileStore(agentDir)?.profiles["openai:default"]).toMatchObject({
key: "sk-new",
});
expect(getRuntimeAuthProfileStoreSnapshot(agentDir)).toBeUndefined();
});
});
it("publishes a caller-owned database transaction from the supplied store", async () => {
await withAuthProfileTestState("openclaw-auth-caller-transaction-", async ({ agentDir }) => {
const store = (key: string): AuthProfileStore => ({
version: AUTH_STORE_VERSION,
profiles: {
"openai:default": { type: "api_key", provider: "openai", key },
"openai:backup": { type: "api_key", provider: "openai", key: "sk-backup" },
},
order: {
openai:
key === "sk-old"
? ["openai:default", "openai:backup"]
: ["openai:backup", "openai:default"],
},
});
saveAuthProfileStore(store("sk-old"), agentDir);
replaceRuntimeAuthProfileStoreSnapshots([
{ agentDir, store: loadAuthProfileStoreForRuntime(agentDir) },
]);
const credentialRevision = getRuntimeAuthProfileStoreCredentialMutationRevision(agentDir);
const stateRevision = getRuntimeAuthProfileStoreStateMutationRevision(agentDir);
runAuthProfileWriteTransaction(agentDir, (database) => {
saveAuthProfileStore(store("sk-new"), agentDir, undefined, database);
});
expect(loadPersistedAuthProfileStore(agentDir)?.profiles["openai:default"]).toMatchObject({
key: "sk-new",
});
expect(
getRuntimeAuthProfileStoreSnapshot(agentDir)?.profiles["openai:default"],
).toMatchObject({ key: "sk-new" });
expect(getRuntimeAuthProfileStoreSnapshot(agentDir)?.order?.openai).toEqual([
"openai:backup",
"openai:default",
]);
expect(getRuntimeAuthProfileStoreCredentialMutationRevision(agentDir)).toBeGreaterThan(
credentialRevision,
);
expect(getRuntimeAuthProfileStoreStateMutationRevision(agentDir)).toBeGreaterThan(
stateRevision,
);
});
});
it("preserves derived runtime snapshots on a caller-owned main-store no-op", async () => {
await withAuthProfileTestState(
"openclaw-auth-caller-noop-",
async ({ agentDir, agentDirFor }) => {
const derivedAgentDir = agentDirFor("worker");
const mainStore: AuthProfileStore = {
version: AUTH_STORE_VERSION,
profiles: {
"openai:default": { type: "api_key", provider: "openai", key: "sk-main" },
},
};
saveAuthProfileStore(mainStore, agentDir);
const derivedStore = loadAuthProfileStoreForRuntime(derivedAgentDir);
replaceRuntimeAuthProfileStoreSnapshots([
{ agentDir, store: loadAuthProfileStoreForRuntime(agentDir) },
{ agentDir: derivedAgentDir, store: derivedStore },
]);
runAuthProfileWriteTransaction(agentDir, (database) => {
saveAuthProfileStore(mainStore, agentDir, undefined, database);
});
expect(getRuntimeAuthProfileStoreSnapshot(derivedAgentDir)).toEqual(derivedStore);
},
);
});
it("drops caller-owned publication when a nested savepoint rolls back", async () => {
await withAuthProfileTestState("openclaw-auth-caller-savepoint-", async ({ agentDir }) => {
const initial: AuthProfileStore = {
version: AUTH_STORE_VERSION,
profiles: {
"openai:default": { type: "api_key", provider: "openai", key: "sk-initial" },
},
};
const candidate: AuthProfileStore = {
version: AUTH_STORE_VERSION,
profiles: {
"openai:default": { type: "api_key", provider: "openai", key: "sk-candidate" },
},
};
saveAuthProfileStore(initial, agentDir);
replaceRuntimeAuthProfileStoreSnapshots([{ agentDir, store: initial }]);
runAuthProfileWriteTransaction(agentDir, () => {
expect(() =>
runAuthProfileWriteTransaction(agentDir, (database) => {
saveAuthProfileStore(candidate, agentDir, undefined, database);
throw new Error("rollback savepoint");
}),
).toThrow("rollback savepoint");
});
expect(loadPersistedAuthProfileStore(agentDir)).toMatchObject(initial);
expect(getRuntimeAuthProfileStoreSnapshot(agentDir)).toEqual(initial);
});
});
it("rolls back credentials when the state write fails", async () => {
await withAuthProfileTestState("openclaw-auth-atomic-save-", async ({ agentDir }) => {
const oldStore: AuthProfileStore = {
version: AUTH_STORE_VERSION,
profiles: {
"openai:old": { type: "api_key", provider: "openai", key: "sk-old" },
},
order: { openai: ["openai:old"] },
};
saveAuthProfileStore(oldStore, agentDir);
const credentialRevision = getRuntimeAuthProfileStoreCredentialMutationRevision(agentDir);
const stateRevision = getRuntimeAuthProfileStoreStateMutationRevision(agentDir);
const database = openOpenClawAgentDatabase({
agentId: "main",
path: resolveAuthProfileDatabasePath(agentDir),
});
database.db.exec(`
CREATE TRIGGER reject_auth_profile_state_update
BEFORE UPDATE ON auth_profile_state
BEGIN
SELECT RAISE(ABORT, 'injected auth state write failure');
END;
`);
expect(() =>
saveAuthProfileStore(
{
version: AUTH_STORE_VERSION,
profiles: {
"openai:new": { type: "api_key", provider: "openai", key: "sk-new" },
},
order: { openai: ["openai:new"] },
},
agentDir,
),
).toThrow("injected auth state write failure");
database.db.exec("DROP TRIGGER reject_auth_profile_state_update;");
expect(loadAuthProfileStoreWithoutExternalProfiles(agentDir)).toMatchObject(oldStore);
expect(getRuntimeAuthProfileStoreCredentialMutationRevision(agentDir)).toBe(
credentialRevision,
);
expect(getRuntimeAuthProfileStoreStateMutationRevision(agentDir)).toBe(stateRevision);
});
});
it("restores materialized and runtime-external snapshot credentials after a temporary write", async () => {
await withAuthProfileTestState("openclaw-auth-runtime-restore-", async ({ agentDir }) => {
const keyRef = { source: "env", provider: "default", id: "OPENAI_API_KEY" } as const;
saveAuthProfileStore(
{
version: AUTH_STORE_VERSION,
profiles: {
"openai:default": {
type: "api_key",
provider: "openai",
key: "sk-materialized",
keyRef,
},
},
},
agentDir,
);
const runtimeStore: AuthProfileStore = {
version: AUTH_STORE_VERSION,
profiles: {
"openai:default": {
type: "api_key",
provider: "openai",
key: "sk-materialized",
keyRef,
},
"anthropic:external": {
type: "oauth",
provider: "anthropic",
access: "external-access",
refresh: "external-refresh",
expires: Date.now() + 60_000,
},
},
runtimeExternalProfileIds: ["anthropic:external"],
};
replaceRuntimeAuthProfileStoreSnapshots([{ agentDir, store: runtimeStore }]);
const snapshot = captureAuthProfileStorePersistenceSnapshot(agentDir);
const committed = saveAuthProfileStoreIfPersistenceSnapshotMatches({
snapshot,
agentDir,
store: {
version: AUTH_STORE_VERSION,
profiles: {
"openai:temporary": {
type: "api_key",
provider: "openai",
key: "sk-temporary",
},
},
},
});
expect(committed.publishRuntimeSnapshots()).toBe(true);
const { owned } = committed;
restoreAuthProfileStorePersistenceSnapshot(snapshot, owned, agentDir);
expect(getRuntimeAuthProfileStoreSnapshot(agentDir)).toMatchObject(runtimeStore);
expect(
getRuntimeAuthProfileStoreSnapshot(agentDir)?.profiles["openai:temporary"],
).toBeUndefined();
});
});
it.each(["before save", "before publication"] as const)(
"preserves a runtime-only OAuth mutation %s",
async (mutationTiming) => {
await withAuthProfileTestState(
"openclaw-auth-runtime-edge-ownership-",
async ({ agentDir }) => {
const baselineStore: AuthProfileStore = {
version: AUTH_STORE_VERSION,
profiles: {
"openai:baseline": {
type: "api_key",
provider: "openai",
key: "sk-baseline",
},
"anthropic:external": {
type: "oauth",
provider: "anthropic",
access: "external-before-capture",
refresh: "external-refresh",
expires: Date.now() + 60_000,
},
},
runtimeExternalProfileIds: ["anthropic:external"],
};
saveAuthProfileStore(baselineStore, agentDir);
replaceRuntimeAuthProfileStoreSnapshots([{ agentDir, store: baselineStore }]);
const snapshot = captureAuthProfileStorePersistenceSnapshot(agentDir);
const mutateRuntimeStore = () => {
replaceRuntimeAuthProfileStoreSnapshots([
{
agentDir,
store: {
...baselineStore,
profiles: {
...baselineStore.profiles,
"anthropic:external": {
type: "oauth",
provider: "anthropic",
access: "external-after-capture",
refresh: "external-refresh-new",
expires: Date.now() + 120_000,
},
},
},
},
]);
};
if (mutationTiming === "before save") {
mutateRuntimeStore();
}
const committed = saveAuthProfileStoreIfPersistenceSnapshotMatches({
snapshot,
agentDir,
store: {
version: AUTH_STORE_VERSION,
profiles: {
"openai:temporary": {
type: "api_key",
provider: "openai",
key: "sk-temporary",
},
},
},
});
if (mutationTiming === "before publication") {
storeTesting.setRuntimeSnapshotPublisherForTest((publish) => {
storeTesting.resetRuntimeSnapshotPublisherForTest();
mutateRuntimeStore();
publish();
});
}
expect(committed.publishRuntimeSnapshots()).toBe(true);
const { owned } = committed;
restoreAuthProfileStorePersistenceSnapshot(snapshot, owned, agentDir);
expect(getRuntimeAuthProfileStoreSnapshot(agentDir)?.profiles).toMatchObject({
"openai:baseline": { key: "sk-baseline" },
"anthropic:external": {
access: "external-after-capture",
refresh: "external-refresh-new",
},
});
expect(
getRuntimeAuthProfileStoreSnapshot(agentDir)?.profiles["openai:temporary"],
).toBeUndefined();
},
{ clearOAuthDir: true },
);
},
);
it("restores captured and rebuilds newer derived snapshots after main rollback", async () => {
await withAuthProfileTestState(
"openclaw-auth-main-derived-rollback-",
async ({ agentDirFor }) => {
const capturedAgentDir = agentDirFor("captured");
const newerAgentDir = agentDirFor("newer");
const keyRef = { source: "env", provider: "default", id: "OPENAI_API_KEY" } as const;
saveAuthProfileStore({
version: AUTH_STORE_VERSION,
profiles: {
"openai:baseline": {
type: "api_key",
provider: "openai",
keyRef,
},
},
});
const capturedRuntime = loadAuthProfileStoreForRuntime(capturedAgentDir);
const capturedProfile = capturedRuntime.profiles["openai:baseline"];
if (capturedProfile?.type !== "api_key") {
throw new Error("expected captured derived API-key profile");
}
capturedProfile.key = "sk-captured-resolved";
capturedRuntime.profiles["anthropic:captured-external"] = {
type: "oauth",
provider: "anthropic",
access: "captured-external-access",
refresh: "captured-external-refresh",
expires: Date.now() + 60_000,
};
capturedRuntime.runtimeExternalProfileIds = ["anthropic:captured-external"];
replaceRuntimeAuthProfileStoreSnapshots([
{ agentDir: capturedAgentDir, store: capturedRuntime },
]);
const snapshot = captureAuthProfileStorePersistenceSnapshot();
const committed = saveAuthProfileStoreIfPersistenceSnapshotMatches({
snapshot,
store: {
version: AUTH_STORE_VERSION,
profiles: {
"openai:temporary": {
type: "api_key",
provider: "openai",
key: "sk-temporary",
},
},
},
});
capturedRuntime.profiles["anthropic:captured-external"] = {
type: "oauth",
provider: "anthropic",
access: "captured-publication-edge-access",
refresh: "captured-publication-edge-refresh",
expires: Date.now() + 120_000,
};
replaceRuntimeAuthProfileStoreSnapshots([
{ agentDir: capturedAgentDir, store: capturedRuntime },
]);
expect(committed.publishRuntimeSnapshots()).toBe(true);
const { owned } = committed;
const ownedCapturedRuntime = getRuntimeAuthProfileStoreSnapshot(capturedAgentDir);
if (!ownedCapturedRuntime) {
throw new Error("expected apply-owned derived runtime snapshot");
}
expect(ownedCapturedRuntime.profiles["openai:baseline"]).toBeUndefined();
expect(ownedCapturedRuntime.profiles["anthropic:captured-external"]).toMatchObject({
access: "captured-publication-edge-access",
refresh: "captured-publication-edge-refresh",
});
const newerRuntime = loadAuthProfileStoreForRuntime(newerAgentDir);
newerRuntime.profiles["anthropic:newer-external"] = {
type: "oauth",
provider: "anthropic",
access: "newer-external-access",
refresh: "newer-external-refresh",
expires: Date.now() + 60_000,
};
newerRuntime.runtimeExternalProfileIds = ["anthropic:newer-external"];
replaceRuntimeAuthProfileStoreSnapshots([
{ agentDir: capturedAgentDir, store: ownedCapturedRuntime },
{ agentDir: newerAgentDir, store: newerRuntime },
]);
restoreAuthProfileStorePersistenceSnapshot(snapshot, owned);
expect(getRuntimeAuthProfileStoreSnapshot(capturedAgentDir)?.profiles).toMatchObject({
"openai:baseline": { key: "sk-captured-resolved", keyRef },
"anthropic:captured-external": {
access: "captured-publication-edge-access",
refresh: "captured-publication-edge-refresh",
},
});
expect(
getRuntimeAuthProfileStoreSnapshot(capturedAgentDir)?.profiles["openai:temporary"],
).toBeUndefined();
expect(getRuntimeAuthProfileStoreSnapshot(newerAgentDir)?.profiles).toMatchObject({
"openai:baseline": { keyRef },
"anthropic:newer-external": { access: "newer-external-access" },
});
expect(
getRuntimeAuthProfileStoreSnapshot(newerAgentDir)?.profiles["openai:temporary"],
).toBeUndefined();
},
{ clearOAuthDir: true },
);
});
it("tracks state-only saves without advancing credential ownership", async () => {
await withAuthProfileTestState("openclaw-auth-state-lineage-", async ({ agentDir }) => {
const store: AuthProfileStore = {
version: AUTH_STORE_VERSION,
profiles: {
"openai:default": { type: "api_key", provider: "openai", key: "sk-stable" },
},
};
saveAuthProfileStore(store, agentDir);
const credentialRevision = getRuntimeAuthProfileStoreCredentialsRevision();
const stateRevision = getRuntimeAuthProfileStoreStateMutationRevision(agentDir);
saveAuthProfileStore(
{ ...store, usageStats: { "openai:default": { lastUsed: 42 } } },
agentDir,
);
expect(getRuntimeAuthProfileStoreCredentialsRevision()).toBe(credentialRevision);
expect(getRuntimeAuthProfileStoreStateMutationRevision(agentDir)).toBeGreaterThan(
stateRevision,
);
});
});
it("marks newly saved runtime snapshot profiles as persisted", async () => {
await withAuthProfileTestState(
"openclaw-auth-profile-runtime-persisted-",
@@ -134,6 +838,9 @@ describe("promoteAuthProfileInOrder", () => {
expect(getRuntimeAuthProfileStoreSnapshot(agentDir)?.runtimePersistedProfileIds).toEqual([
"openai:work",
]);
expect(
getInternalRuntimeAuthProfileStoreSnapshot(agentDir)?.runtimeLocalProfileIds,
).toEqual(["openai:work"]);
} finally {
clearRuntimeAuthProfileStoreSnapshots();
}
@@ -167,8 +874,11 @@ describe("promoteAuthProfileInOrder", () => {
agentDir,
});
const store = loadAuthProfileStoreWithoutExternalProfiles(agentDir);
const store = loadAuthProfileStoreWithoutExternalProfiles(
agentDir,
) as RuntimeAuthProfileStore;
expect(store.runtimePersistedProfileIds).toEqual(["anthropic:key", "openai:manual"]);
expect(store.runtimeLocalProfileIds).toEqual(["anthropic:key", "openai:manual"]);
expect(store.runtimeExternalProfileIds).toBeUndefined();
expect(store.runtimeExternalProfileIdsAuthoritative).toBeUndefined();
const profiles = store.profiles;
@@ -8,8 +8,11 @@ import { describe, expect, it, vi } from "vitest";
import {
clearRuntimeAuthProfileStoreSnapshots,
getRuntimeAuthProfileStoreSnapshot,
getRuntimeAuthProfileStoreCredentialsRevision,
noteRuntimeAuthProfileStorePersistedMutation,
replaceRuntimeAuthProfileStoreSnapshots,
setRuntimeAuthProfileStoreSnapshot,
testing,
} from "./runtime-snapshots.js";
import type { AuthProfileStore } from "./types.js";
@@ -54,6 +57,22 @@ function expectOpenAICodexSnapshotCredential(
}
describe("runtime auth profile snapshots", () => {
it("advances credential revision without coupling to usage bookkeeping", () => {
const initialRevision = getRuntimeAuthProfileStoreCredentialsRevision();
const store = createStore("set");
setRuntimeAuthProfileStoreSnapshot(store);
expect(getRuntimeAuthProfileStoreCredentialsRevision()).toBe(initialRevision + 1);
setRuntimeAuthProfileStoreSnapshot({
...store,
usageStats: { "openai:default": { lastUsed: 2 } },
});
expect(getRuntimeAuthProfileStoreCredentialsRevision()).toBe(initialRevision + 1);
clearRuntimeAuthProfileStoreSnapshots();
expect(getRuntimeAuthProfileStoreCredentialsRevision()).toBe(initialRevision + 2);
});
it("isolates set/get/replace snapshot mutations without structuredClone", () => {
const structuredCloneSpy = vi.spyOn(globalThis, "structuredClone");
const agentDir = "/tmp/openclaw-auth-runtime-snapshot-agent";
@@ -101,4 +120,26 @@ describe("runtime auth profile snapshots", () => {
clearRuntimeAuthProfileStoreSnapshots();
}
});
it("bounds persisted mutation lineage by owner and profile", () => {
for (let index = 0; index <= testing.MAX_PERSISTED_MUTATION_OWNERS; index += 1) {
noteRuntimeAuthProfileStorePersistedMutation(`/tmp/openclaw-mutation-owner-${index}`, {
credentialsChanged: true,
stateChanged: false,
profileIds: ["openai:default"],
});
}
for (let index = 0; index <= testing.MAX_PERSISTED_MUTATION_PROFILES_PER_OWNER; index += 1) {
noteRuntimeAuthProfileStorePersistedMutation("/tmp/openclaw-mutation-profile-owner", {
credentialsChanged: true,
stateChanged: false,
profileIds: [`openai:${index}`],
});
}
const counts = testing.getPersistedMutationRecordCounts();
expect(counts.owners).toBeLessThanOrEqual(testing.MAX_PERSISTED_MUTATION_OWNERS);
expect(counts.profiles).toBeLessThanOrEqual(testing.MAX_PERSISTED_MUTATION_PROFILES_PER_OWNER);
testing.resetPersistedMutationLineage();
});
});
+346 -6
View File
@@ -1,12 +1,143 @@
import path from "node:path";
/**
* Process-local auth profile snapshots used by prepared runtimes and tests.
* Snapshots are cloned at boundaries so callers cannot mutate shared state.
*/
import { isDeepStrictEqual } from "node:util";
import { cloneAuthProfileStore } from "./clone.js";
import { resolveAuthStorePath } from "./path-resolve.js";
import type { AuthProfileStore } from "./types.js";
import type { AuthProfileStore, RuntimeAuthProfileStore } from "./types.js";
const runtimeAuthStoreSnapshots = new Map<string, AuthProfileStore>();
const runtimeAuthStoreSnapshots = new Map<string, RuntimeAuthProfileStore>();
let runtimeAuthStoreCredentialsRevision = 0;
let runtimeAuthStoreSnapshotsRevision = 0;
// Per-store generations isolate rollback ownership; the global counter remains
// the deletion generation for keys no longer present in this map.
const runtimeAuthStoreSnapshotRevisions = new Map<string, number>();
let persistedMutationRevision = 0;
let evictedOwnerMutationFloor = 0;
const MAX_PERSISTED_MUTATION_OWNERS = 256;
const MAX_PERSISTED_MUTATION_PROFILES_PER_OWNER = 256;
type PersistedMutationRecord = {
credentialRevision: number;
credentialRevisionKnown: boolean;
profileSetRevision: number;
profileSetRevisionKnown: boolean;
stateRevision: number;
stateRevisionKnown: boolean;
mutationFloor: number;
profileRevisions: Map<string, number>;
};
const persistedMutationRecords = new Map<string, PersistedMutationRecord>();
function maxMutationRevision(record: PersistedMutationRecord): number {
return Math.max(
record.credentialRevision,
record.profileSetRevision,
record.stateRevision,
record.mutationFloor,
...record.profileRevisions.values(),
);
}
function getOrCreatePersistedMutationRecord(ownerKey: string): PersistedMutationRecord {
const existing = persistedMutationRecords.get(ownerKey);
if (existing) {
// Mutations, rather than reads, drive LRU recency so observation cannot
// retain dormant owners forever.
persistedMutationRecords.delete(ownerKey);
persistedMutationRecords.set(ownerKey, existing);
return existing;
}
const record: PersistedMutationRecord = {
credentialRevision: evictedOwnerMutationFloor,
credentialRevisionKnown: evictedOwnerMutationFloor === 0,
profileSetRevision: evictedOwnerMutationFloor,
profileSetRevisionKnown: evictedOwnerMutationFloor === 0,
stateRevision: evictedOwnerMutationFloor,
stateRevisionKnown: evictedOwnerMutationFloor === 0,
mutationFloor: evictedOwnerMutationFloor,
profileRevisions: new Map(),
};
persistedMutationRecords.set(ownerKey, record);
while (persistedMutationRecords.size > MAX_PERSISTED_MUTATION_OWNERS) {
const oldestOwnerKey = persistedMutationRecords.keys().next().value;
if (oldestOwnerKey === undefined) {
break;
}
const oldest = persistedMutationRecords.get(oldestOwnerKey);
persistedMutationRecords.delete(oldestOwnerKey);
if (oldest) {
// A floor trades false-positive rollback fences for bounded memory; it
// must never let an evicted persisted mutation look unchanged.
evictedOwnerMutationFloor = Math.max(evictedOwnerMutationFloor, maxMutationRevision(oldest));
}
}
record.mutationFloor = Math.max(record.mutationFloor, evictedOwnerMutationFloor);
return record;
}
function setProfileMutationRevision(
record: PersistedMutationRecord,
profileId: string,
revision: number,
): void {
record.profileRevisions.delete(profileId);
record.profileRevisions.set(profileId, revision);
while (record.profileRevisions.size > MAX_PERSISTED_MUTATION_PROFILES_PER_OWNER) {
const oldestProfileId = record.profileRevisions.keys().next().value;
if (oldestProfileId === undefined) {
break;
}
const oldestRevision = record.profileRevisions.get(oldestProfileId) ?? 0;
record.profileRevisions.delete(oldestProfileId);
record.mutationFloor = Math.max(record.mutationFloor, oldestRevision);
}
}
function getPersistedMutationRecord(ownerKey: string): PersistedMutationRecord | undefined {
return persistedMutationRecords.get(ownerKey);
}
function credentialState(
entries: Iterable<[string, RuntimeAuthProfileStore]>,
): Array<readonly [string, AuthProfileStore["profiles"]]> {
return Array.from(entries)
.filter(([, store]) => Object.keys(store.profiles).length > 0)
.map(([key, store]) => [key, store.profiles] as const)
.toSorted(([left], [right]) => left.localeCompare(right));
}
function replaceChangesCredentials(
entries: Array<{ agentDir?: string; store: RuntimeAuthProfileStore }>,
): boolean {
const next = new Map(
entries.map((entry) => [resolveRuntimeStoreKey(entry.agentDir), entry.store] as const),
);
return !isDeepStrictEqual(credentialState(runtimeAuthStoreSnapshots), credentialState(next));
}
function recordChangedSnapshotRevisions(
entries: Array<{ agentDir?: string; store: RuntimeAuthProfileStore }>,
): void {
const next = new Map(
entries.map((entry) => [resolveRuntimeStoreKey(entry.agentDir), entry.store] as const),
);
const keys = new Set([...runtimeAuthStoreSnapshots.keys(), ...next.keys()]);
for (const key of keys) {
if (isDeepStrictEqual(runtimeAuthStoreSnapshots.get(key), next.get(key))) {
continue;
}
runtimeAuthStoreSnapshotsRevision += 1;
if (next.has(key)) {
runtimeAuthStoreSnapshotRevisions.set(key, runtimeAuthStoreSnapshotsRevision);
} else {
runtimeAuthStoreSnapshotRevisions.delete(key);
}
}
}
// Runtime snapshots are keyed by the resolved auth store path so default-agent
// and per-agent stores do not overwrite each other.
@@ -17,11 +148,22 @@ function resolveRuntimeStoreKey(agentDir?: string): string {
/** Reads a cloned runtime auth profile store snapshot for an agent dir. */
export function getRuntimeAuthProfileStoreSnapshot(
agentDir?: string,
): AuthProfileStore | undefined {
): RuntimeAuthProfileStore | undefined {
const store = runtimeAuthStoreSnapshots.get(resolveRuntimeStoreKey(agentDir));
return store ? cloneAuthProfileStore(store) : undefined;
}
/** Lists cloned live snapshots for transactional rollback composition. */
export function listRuntimeAuthProfileStoreSnapshots(): Array<{
agentDir: string;
store: RuntimeAuthProfileStore;
}> {
return Array.from(runtimeAuthStoreSnapshots, ([key, store]) => ({
agentDir: path.dirname(key),
store: cloneAuthProfileStore(store),
}));
}
/** Returns true when a runtime snapshot exists for an agent dir. */
export function hasRuntimeAuthProfileStoreSnapshot(agentDir?: string): boolean {
return runtimeAuthStoreSnapshots.has(resolveRuntimeStoreKey(agentDir));
@@ -42,8 +184,12 @@ export function hasAnyRuntimeAuthProfileStoreSource(agentDir?: string): boolean
/** Replaces all runtime auth profile snapshots with cloned entries. */
export function replaceRuntimeAuthProfileStoreSnapshots(
entries: Array<{ agentDir?: string; store: AuthProfileStore }>,
entries: Array<{ agentDir?: string; store: RuntimeAuthProfileStore }>,
): void {
if (replaceChangesCredentials(entries)) {
runtimeAuthStoreCredentialsRevision += 1;
}
recordChangedSnapshotRevisions(entries);
runtimeAuthStoreSnapshots.clear();
for (const entry of entries) {
runtimeAuthStoreSnapshots.set(
@@ -55,13 +201,207 @@ export function replaceRuntimeAuthProfileStoreSnapshots(
/** Clears all runtime auth profile snapshots. */
export function clearRuntimeAuthProfileStoreSnapshots(): void {
if (credentialState(runtimeAuthStoreSnapshots).length > 0) {
runtimeAuthStoreCredentialsRevision += 1;
}
if (runtimeAuthStoreSnapshots.size > 0) {
runtimeAuthStoreSnapshotsRevision += 1;
}
runtimeAuthStoreSnapshots.clear();
runtimeAuthStoreSnapshotRevisions.clear();
}
/** Stores a cloned runtime auth profile snapshot for an agent dir. */
export function setRuntimeAuthProfileStoreSnapshot(
store: AuthProfileStore,
store: RuntimeAuthProfileStore,
agentDir?: string,
): void {
runtimeAuthStoreSnapshots.set(resolveRuntimeStoreKey(agentDir), cloneAuthProfileStore(store));
const key = resolveRuntimeStoreKey(agentDir);
if (!isDeepStrictEqual(runtimeAuthStoreSnapshots.get(key)?.profiles ?? {}, store.profiles)) {
runtimeAuthStoreCredentialsRevision += 1;
}
if (!isDeepStrictEqual(runtimeAuthStoreSnapshots.get(key), store)) {
runtimeAuthStoreSnapshotsRevision += 1;
runtimeAuthStoreSnapshotRevisions.set(key, runtimeAuthStoreSnapshotsRevision);
}
runtimeAuthStoreSnapshots.set(key, cloneAuthProfileStore(store));
}
/**
* Invalidates prepared credential ownership after a persisted owner-store write.
* Main-store credentials are inherited by custom-agent snapshots, so those
* derived snapshots must be dropped even when no exact main snapshot exists.
* State-only saves refresh them in the publisher without changing credential ownership.
*/
export function noteRuntimeAuthProfileStorePersistedMutation(
agentDir: string | undefined,
mutation: {
credentialsChanged: boolean;
profileSetChanged?: boolean;
stateChanged: boolean;
profileIds: Iterable<string>;
},
): void {
if (!mutation.credentialsChanged && !mutation.profileSetChanged && !mutation.stateChanged) {
return;
}
persistedMutationRevision += 1;
if (mutation.credentialsChanged) {
runtimeAuthStoreCredentialsRevision += 1;
}
const ownerKey = resolveRuntimeStoreKey(agentDir);
const record = getOrCreatePersistedMutationRecord(ownerKey);
if (mutation.profileSetChanged) {
record.profileSetRevision = persistedMutationRevision;
record.profileSetRevisionKnown = true;
}
if (mutation.credentialsChanged) {
record.credentialRevision = persistedMutationRevision;
record.credentialRevisionKnown = true;
for (const profileId of mutation.profileIds) {
setProfileMutationRevision(record, profileId, persistedMutationRevision);
}
}
if (mutation.stateChanged) {
record.stateRevision = persistedMutationRevision;
record.stateRevisionKnown = true;
}
const mainKey = resolveRuntimeStoreKey(undefined);
if (ownerKey !== mainKey || (!mutation.credentialsChanged && !mutation.profileSetChanged)) {
return;
}
let deletedDerivedSnapshot = false;
for (const key of runtimeAuthStoreSnapshots.keys()) {
if (key !== mainKey) {
runtimeAuthStoreSnapshots.delete(key);
runtimeAuthStoreSnapshotRevisions.delete(key);
deletedDerivedSnapshot = true;
}
}
if (deletedDerivedSnapshot) {
runtimeAuthStoreSnapshotsRevision += 1;
}
}
/** Persisted mutation token for one store or profile credential. */
export function getRuntimeAuthProfileStoreCredentialMutationRevision(
agentDir?: string,
profileId?: string,
options?: { includeMain?: boolean },
): number {
return getRuntimeAuthProfileStoreCredentialMutationToken(agentDir, profileId, options).revision;
}
export type RuntimeAuthProfileStoreMutationToken = {
revision: number;
known: boolean;
};
function combineMutationTokens(
tokens: RuntimeAuthProfileStoreMutationToken[],
): RuntimeAuthProfileStoreMutationToken {
return {
revision: Math.max(0, ...tokens.map((token) => token.revision)),
known: tokens.every((token) => token.known),
};
}
/** Bounded persisted credential lineage; unknown means its exact token was evicted. */
export function getRuntimeAuthProfileStoreCredentialMutationToken(
agentDir?: string,
profileId?: string,
options?: { includeMain?: boolean },
): RuntimeAuthProfileStoreMutationToken {
const requestedKey = resolveRuntimeStoreKey(agentDir);
if (!profileId) {
const record = getPersistedMutationRecord(requestedKey);
return record
? { revision: record.credentialRevision, known: record.credentialRevisionKnown }
: { revision: evictedOwnerMutationFloor, known: evictedOwnerMutationFloor === 0 };
}
const mainKey = resolveRuntimeStoreKey(undefined);
const keys =
requestedKey === mainKey || options?.includeMain !== true
? [requestedKey]
: [requestedKey, mainKey];
return combineMutationTokens(
keys.map((key) => {
const record = getPersistedMutationRecord(key);
if (!record) {
return { revision: evictedOwnerMutationFloor, known: evictedOwnerMutationFloor === 0 };
}
const revision = record.profileRevisions.get(profileId);
return revision === undefined
? { revision: record.mutationFloor, known: record.mutationFloor === 0 }
: { revision, known: true };
}),
);
}
/** Persisted token for profile-id additions and removals in one owner store. */
export function getRuntimeAuthProfileStoreProfileSetMutationToken(
agentDir?: string,
): RuntimeAuthProfileStoreMutationToken {
const ownerKey = resolveRuntimeStoreKey(agentDir);
const record = getPersistedMutationRecord(ownerKey);
return record
? { revision: record.profileSetRevision, known: record.profileSetRevisionKnown }
: { revision: evictedOwnerMutationFloor, known: evictedOwnerMutationFloor === 0 };
}
/** Persisted mutation token for non-secret selection state in one owner store. */
export function getRuntimeAuthProfileStoreStateMutationToken(
agentDir?: string,
options?: { includeMain?: boolean },
): RuntimeAuthProfileStoreMutationToken {
const requestedKey = resolveRuntimeStoreKey(agentDir);
const mainKey = resolveRuntimeStoreKey(undefined);
const keys =
requestedKey === mainKey || options?.includeMain !== true
? [requestedKey]
: [requestedKey, mainKey];
return combineMutationTokens(
keys.map((key) => {
const record = getPersistedMutationRecord(key);
return record
? { revision: record.stateRevision, known: record.stateRevisionKnown }
: { revision: evictedOwnerMutationFloor, known: evictedOwnerMutationFloor === 0 };
}),
);
}
export function getRuntimeAuthProfileStoreStateMutationRevision(agentDir?: string): number {
return getRuntimeAuthProfileStoreStateMutationToken(agentDir).revision;
}
/** Stable token for credential ownership without coupling to usage bookkeeping. */
export function getRuntimeAuthProfileStoreCredentialsRevision(): number {
return runtimeAuthStoreCredentialsRevision;
}
/** Process-local generation for one exact runtime snapshot rollback owner. */
export function getRuntimeAuthProfileStoreSnapshotRevision(agentDir?: string): number {
return (
runtimeAuthStoreSnapshotRevisions.get(resolveRuntimeStoreKey(agentDir)) ??
runtimeAuthStoreSnapshotsRevision
);
}
export const testing = {
MAX_PERSISTED_MUTATION_OWNERS,
MAX_PERSISTED_MUTATION_PROFILES_PER_OWNER,
getPersistedMutationRecordCounts(): { owners: number; profiles: number } {
return {
owners: persistedMutationRecords.size,
profiles: Math.max(
0,
...Array.from(persistedMutationRecords.values(), (record) => record.profileRevisions.size),
),
};
},
resetPersistedMutationLineage(): void {
persistedMutationRecords.clear();
persistedMutationRevision = 0;
evictedOwnerMutationFloor = 0;
},
};
+683 -61
View File
@@ -5,8 +5,12 @@
*/
import { isDeepStrictEqual } from "node:util";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { isSecretRef } from "../../config/types.secrets.js";
import { asDateTimestampMs } from "../../shared/number-coercion.js";
import type { OpenClawAgentDatabase } from "../../state/openclaw-agent-db.js";
import {
deferOpenClawAgentPostCommitPublication,
type OpenClawAgentDatabase,
} from "../../state/openclaw-agent-db.js";
import { isRecord } from "../../utils.js";
import { cloneAuthProfileStore } from "./clone.js";
import { AUTH_STORE_VERSION, log } from "./constants.js";
@@ -31,22 +35,22 @@ import {
import {
clearRuntimeAuthProfileStoreSnapshots as clearRuntimeAuthProfileStoreSnapshotsImpl,
getRuntimeAuthProfileStoreSnapshot as getRuntimeAuthProfileStoreSnapshotImpl,
hasRuntimeAuthProfileStoreSnapshot,
getRuntimeAuthProfileStoreSnapshotRevision,
noteRuntimeAuthProfileStorePersistedMutation,
listRuntimeAuthProfileStoreSnapshots,
replaceRuntimeAuthProfileStoreSnapshots as replaceRuntimeAuthProfileStoreSnapshotsImpl,
setRuntimeAuthProfileStoreSnapshot,
} from "./runtime-snapshots.js";
import {
deletePersistedAuthProfileStoreRaw,
readPersistedAuthProfileStoreRaw,
writePersistedAuthProfileStateRaw,
readPersistedAuthProfileStateRaw,
runAuthProfileWriteTransaction,
writePersistedAuthProfileStateRaw,
writePersistedAuthProfileStoreRaw,
} from "./sqlite.js";
import {
buildPersistedAuthProfileState,
loadPersistedAuthProfileState,
savePersistedAuthProfileState,
} from "./state.js";
import type { AuthProfileStore } from "./types.js";
import { buildPersistedAuthProfileState, loadPersistedAuthProfileState } from "./state.js";
import type { AuthProfileStore, RuntimeAuthProfileStore } from "./types.js";
type LoadAuthProfileStoreOptions = {
allowKeychainPrompt?: boolean;
@@ -136,6 +140,36 @@ type ExternalCliSyncResult = {
cacheable: boolean;
};
let runtimeSnapshotPublisherForTest: ((publish: () => void) => void) | undefined;
function publishRuntimeSnapshotsAfterCommit(publish: (() => void) | undefined): boolean {
if (!publish) {
return true;
}
try {
if (runtimeSnapshotPublisherForTest) {
runtimeSnapshotPublisherForTest(publish);
} else {
publish();
}
return true;
} catch (err) {
clearRuntimeAuthProfileStoreSnapshotsImpl();
log.warn("auth profile store committed but runtime snapshot publication failed", { err });
return false;
}
}
export const testing = {
publishRuntimeSnapshotsAfterCommit,
resetRuntimeSnapshotPublisherForTest(): void {
runtimeSnapshotPublisherForTest = undefined;
},
setRuntimeSnapshotPublisherForTest(publisher: (publish: () => void) => void): void {
runtimeSnapshotPublisherForTest = publisher;
},
};
function resolvePersistedLoadOptions(
options: Pick<LoadAuthProfileStoreOptions, "allowKeychainPrompt" | "database"> | undefined,
): { allowKeychainPrompt?: boolean; database?: OpenClawAgentDatabase } {
@@ -232,7 +266,14 @@ function resolveRuntimeAuthProfileStore(
});
}
if (mainStore) {
return mainStore;
const persistedRequestedStore = loadAuthProfileStoreForAgent(agentDir, {
readOnly: true,
syncExternalCli: false,
...resolvePersistedLoadOptions(options),
});
return mergeAuthProfileStores(mainStore, persistedRequestedStore, {
preserveBaseRuntimeExternalProfiles: true,
});
}
return null;
@@ -316,10 +357,12 @@ function maybeSyncPersistedExternalCliAuthProfiles(params: {
return { store: synced, cacheable: true };
}
// External CLI sync writes only profiles that still match the loaded
// baseline, avoiding overwrite of concurrent local auth changes.
let publishRuntimeSnapshots: (() => void) | undefined;
let result: ExternalCliSyncResult;
try {
// External CLI sync writes only profiles that still match the loaded
// baseline, avoiding overwrite of concurrent local auth changes.
return runAuthProfileWriteTransaction(params.agentDir, (database) => {
result = runAuthProfileWriteTransaction(params.agentDir, (database) => {
const latestStore = loadPersistedAuthProfileStore(params.agentDir, {
...resolvePersistedLoadOptions(params.options),
database,
@@ -341,7 +384,7 @@ function maybeSyncPersistedExternalCliAuthProfiles(params: {
changed = true;
}
if (changed) {
saveAuthProfileStore(
publishRuntimeSnapshots = saveAuthProfileStoreInTransaction(
latestStore,
params.agentDir,
{
@@ -358,6 +401,9 @@ function maybeSyncPersistedExternalCliAuthProfiles(params: {
});
return { store: params.store, cacheable: false };
}
return publishRuntimeSnapshotsAfterCommit(publishRuntimeSnapshots)
? result
: { store: result.store, cacheable: false };
}
function shouldKeepProfileInLocalStore(params: {
@@ -411,7 +457,7 @@ function shouldKeepProfileInLocalStore(params: {
}
function pruneAuthProfileStoreReferences(
store: AuthProfileStore,
store: RuntimeAuthProfileStore,
keptProfileIds: Set<string>,
keptOrderProfileIds = keptProfileIds,
): void {
@@ -441,6 +487,9 @@ function pruneAuthProfileStoreReferences(
if (store.runtimePersistedProfileIds?.length === 0) {
store.runtimePersistedProfileIds = undefined;
}
store.runtimeLocalProfileIds = store.runtimeLocalProfileIds
?.filter((profileId) => keptProfileIds.has(profileId))
.toSorted();
store.runtimeExternalProfileIds = store.runtimeExternalProfileIds
?.filter((profileId) => keptProfileIds.has(profileId))
.toSorted();
@@ -577,6 +626,45 @@ function buildRuntimeAuthProfileStoreForSave(params: {
});
}
function setRuntimeLocalProfileMetadata(
store: AuthProfileStore,
localProfileIds: Iterable<string>,
runtimeInheritsMainState = false,
): RuntimeAuthProfileStore {
return {
...store,
runtimeLocalProfileIds: [...new Set(localProfileIds)].toSorted(),
...(runtimeInheritsMainState ? { runtimeInheritsMainState: true } : {}),
};
}
function runtimeStoreInheritsMainState(
store: AuthProfileStore,
localStore: AuthProfileStore,
): boolean {
const state = ({ order, lastGood, usageStats }: AuthProfileStore) => ({
order,
lastGood,
usageStats,
});
return !isDeepStrictEqual(state(store), state(localStore));
}
function listRuntimeLocalProfileIds(
store: AuthProfileStore,
mainStore?: AuthProfileStore,
): string[] {
return Object.entries(store.profiles).flatMap(([profileId, credential]) =>
mainStore &&
shouldUseMainOwnerForLocalOAuthCredential({
local: credential,
main: mainStore.profiles[profileId],
})
? []
: [profileId],
);
}
function setRuntimeExternalProfileMetadata(params: {
store: AuthProfileStore;
profileIds: ReadonlySet<string>;
@@ -663,6 +751,36 @@ function mergeRuntimeExternalProfileReferences(params: {
return merged;
}
function preserveResolvedSecretBackedCredentials(params: {
next: AuthProfileStore;
existing: AuthProfileStore;
}): AuthProfileStore {
const next = cloneAuthProfileStore(params.next);
for (const [profileId, credential] of Object.entries(next.profiles)) {
const existing = params.existing.profiles[profileId];
if (
credential.type === "api_key" &&
existing?.type === "api_key" &&
credential.key === undefined &&
existing.key !== undefined &&
isSecretRef(credential.keyRef) &&
isDeepStrictEqual(credential.keyRef, existing.keyRef)
) {
next.profiles[profileId] = { ...credential, key: existing.key };
} else if (
credential.type === "token" &&
existing?.type === "token" &&
credential.token === undefined &&
existing.token !== undefined &&
isSecretRef(credential.tokenRef) &&
isDeepStrictEqual(credential.tokenRef, existing.tokenRef)
) {
next.profiles[profileId] = { ...credential, token: existing.token };
}
}
return next;
}
function mergeRuntimeExternalProfileState(params: {
next: AuthProfileStore;
existing: AuthProfileStore;
@@ -745,18 +863,25 @@ export async function updateAuthProfileStoreWithLock(params: {
saveOptions?: SaveAuthProfileStoreOptions;
updater: (store: AuthProfileStore) => boolean;
}): Promise<AuthProfileStore | null> {
let publishRuntimeSnapshots: (() => void) | undefined;
let store: AuthProfileStore;
try {
return runAuthProfileWriteTransaction(params.agentDir, (database) => {
const store = loadAuthProfileStoreForAgent(params.agentDir, {
store = runAuthProfileWriteTransaction(params.agentDir, (database) => {
const loadedStore = loadAuthProfileStoreForAgent(params.agentDir, {
database,
readOnly: true,
syncExternalCli: false,
});
const shouldSave = params.updater(store);
const shouldSave = params.updater(loadedStore);
if (shouldSave) {
saveAuthProfileStore(store, params.agentDir, params.saveOptions, database);
publishRuntimeSnapshots = saveAuthProfileStoreInTransaction(
loadedStore,
params.agentDir,
params.saveOptions,
database,
);
}
return store;
return loadedStore;
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
@@ -766,6 +891,8 @@ export async function updateAuthProfileStoreWithLock(params: {
});
return null;
}
publishRuntimeSnapshotsAfterCommit(publishRuntimeSnapshots);
return store;
}
/** Load the main auth profile store with runtime external profiles overlaid. */
@@ -824,21 +951,26 @@ export function loadAuthProfileStoreForRuntime(
const mainAuthPath = resolveAuthStorePath();
const externalCli = resolveExternalCliOverlayOptions(options);
if (!agentDir || authPath === mainAuthPath) {
return overlayExternalAuthProfiles(store, {
agentDir,
...externalCli,
});
return setRuntimeLocalProfileMetadata(
overlayExternalAuthProfiles(store, {
agentDir,
...externalCli,
}),
listRuntimeLocalProfileIds(store),
);
}
const mainStore = loadAuthProfileStoreForAgent(undefined, options);
return overlayExternalAuthProfiles(
mergeAuthProfileStores(mainStore, store, {
preserveBaseRuntimeExternalProfiles: true,
}),
{
const mergedStore = mergeAuthProfileStores(mainStore, store, {
preserveBaseRuntimeExternalProfiles: true,
});
return setRuntimeLocalProfileMetadata(
overlayExternalAuthProfiles(mergedStore, {
agentDir,
...externalCli,
},
}),
listRuntimeLocalProfileIds(store, mainStore),
runtimeStoreInheritsMainState(mergedStore, store),
);
}
@@ -870,14 +1002,20 @@ export function loadAuthProfileStoreWithoutExternalProfiles(
const authPath = resolveAuthStorePath(agentDir);
const mainAuthPath = resolveAuthStorePath();
if (!agentDir || authPath === mainAuthPath) {
return stripRuntimeExternalProfileMetadata(store);
return setRuntimeLocalProfileMetadata(
stripRuntimeExternalProfileMetadata(store),
listRuntimeLocalProfileIds(store),
);
}
const mainStore = loadAuthProfileStoreForAgent(undefined, options);
return stripRuntimeExternalProfileMetadata(
mergeAuthProfileStores(mainStore, store, {
preserveBaseRuntimeExternalProfiles: true,
}),
const mergedStore = mergeAuthProfileStores(mainStore, store, {
preserveBaseRuntimeExternalProfiles: true,
});
return setRuntimeLocalProfileMetadata(
stripRuntimeExternalProfileMetadata(mergedStore),
listRuntimeLocalProfileIds(store, mainStore),
runtimeStoreInheritsMainState(mergedStore, store),
);
}
@@ -1040,6 +1178,106 @@ export function clearRuntimeAuthProfileStoreSnapshots(): void {
clearRuntimeAuthProfileStoreSnapshotsImpl();
}
function saveAuthProfileStoreInTransaction(
store: AuthProfileStore,
agentDir: string | undefined,
options: SaveAuthProfileStoreOptions | undefined,
database: OpenClawAgentDatabase,
publishFromSuppliedStore = false,
): () => void {
const savedAuthPath = resolveAuthStorePath(agentDir);
const mainAuthPath = resolveAuthStorePath();
const savesMainStore = savedAuthPath === mainAuthPath;
const localStore = buildLocalAuthProfileStoreForSave({ store, agentDir, options });
const existingRaw = readPersistedAuthProfileStoreRaw(agentDir, database);
const payload = preserveLegacyOAuthRefsOnSave({
payload: buildPersistedAuthProfileSecretsStore(localStore),
existingRaw,
});
const existingProfiles =
isRecord(existingRaw) && isRecord(existingRaw.profiles) ? existingRaw.profiles : {};
const changedProfileIds = [
...new Set([...Object.keys(existingProfiles), ...Object.keys(payload.profiles)]),
].filter(
(profileId) => !isDeepStrictEqual(existingProfiles[profileId], payload.profiles[profileId]),
);
const profileSetChanged = changedProfileIds.some(
(profileId) =>
Object.hasOwn(existingProfiles, profileId) !== Object.hasOwn(payload.profiles, profileId),
);
const credentialsChanged = !isDeepStrictEqual(existingRaw, payload);
const statePayload = buildPersistedAuthProfileState(localStore);
const stateChanged = !isDeepStrictEqual(
readPersistedAuthProfileStateRaw(agentDir, database),
statePayload,
);
const suppliedRuntimeStore = publishFromSuppliedStore
? markRuntimePersistedProfiles(
buildRuntimeAuthProfileStoreForSave({ store, agentDir, options }),
localStore,
)
: undefined;
if (credentialsChanged) {
writePersistedAuthProfileStoreRaw(payload, agentDir, database);
}
if (stateChanged) {
writePersistedAuthProfileStateRaw(statePayload, agentDir, database);
}
const publishRuntimeSnapshots = () => {
// Main-store publication invalidates derived stores. Capture the latest
// overlays at the publication edge so post-commit refreshes are retained.
const derivedSnapshots = savesMainStore
? listRuntimeAuthProfileStoreSnapshots().filter(
(entry) => resolveAuthStorePath(entry.agentDir) !== mainAuthPath,
)
: [];
if (credentialsChanged || stateChanged) {
noteRuntimeAuthProfileStorePersistedMutation(agentDir, {
credentialsChanged,
profileSetChanged,
stateChanged,
profileIds: changedProfileIds,
});
}
if (suppliedRuntimeStore) {
const existing = getRuntimeAuthProfileStoreSnapshot(agentDir);
if (existing) {
setRuntimeAuthProfileStoreSnapshot(
mergeRuntimeExternalProfileReferences({ next: suppliedRuntimeStore, existing }),
agentDir,
);
}
if (savesMainStore && (credentialsChanged || stateChanged)) {
for (const derived of derivedSnapshots) {
const refreshed = loadAuthProfileStoreWithoutExternalProfiles(derived.agentDir);
const materialized = preserveResolvedSecretBackedCredentials({
next: refreshed,
existing: derived.store,
});
setRuntimeAuthProfileStoreSnapshot(
mergeRuntimeExternalProfileReferences({ next: materialized, existing: derived.store }),
derived.agentDir,
);
}
}
return;
}
refreshRuntimeAuthProfileStoreSnapshot(agentDir);
for (const derived of derivedSnapshots) {
const refreshed = loadAuthProfileStoreWithoutExternalProfiles(derived.agentDir);
const materialized = preserveResolvedSecretBackedCredentials({
next: refreshed,
existing: derived.store,
});
setRuntimeAuthProfileStoreSnapshot(
mergeRuntimeExternalProfileReferences({ next: materialized, existing: derived.store }),
derived.agentDir,
);
}
};
return publishRuntimeSnapshots;
}
/** Save the auth profile store plus sidecar state, preserving runtime overlay metadata. */
export function saveAuthProfileStore(
store: AuthProfileStore,
@@ -1047,38 +1285,422 @@ export function saveAuthProfileStore(
options?: SaveAuthProfileStoreOptions,
database?: OpenClawAgentDatabase,
): void {
const localStore = buildLocalAuthProfileStoreForSave({ store, agentDir, options });
const existingRaw = readPersistedAuthProfileStoreRaw(agentDir, database);
const payload = preserveLegacyOAuthRefsOnSave({
payload: buildPersistedAuthProfileSecretsStore(localStore),
existingRaw,
});
if (!isDeepStrictEqual(existingRaw, payload)) {
writePersistedAuthProfileStoreRaw(payload, agentDir, database);
}
if (database) {
writePersistedAuthProfileStateRaw(
buildPersistedAuthProfileState(localStore),
const publishRuntimeSnapshots = saveAuthProfileStoreInTransaction(
store,
agentDir,
options,
database,
true,
);
} else {
savePersistedAuthProfileState(localStore, agentDir);
const publishAfterCommit = () => {
publishRuntimeSnapshotsAfterCommit(publishRuntimeSnapshots);
};
if (!deferOpenClawAgentPostCommitPublication(database, publishAfterCommit)) {
// A supplied connection outside the transaction wrapper autocommits each write.
publishAfterCommit();
}
return;
}
if (hasRuntimeAuthProfileStoreSnapshot(agentDir)) {
const existingRuntimeStore = getRuntimeAuthProfileStoreSnapshot(agentDir);
const nextRuntimeStore = markRuntimePersistedProfiles(
buildRuntimeAuthProfileStoreForSave({ store, agentDir, options }),
localStore,
);
setRuntimeAuthProfileStoreSnapshot(
existingRuntimeStore
? mergeRuntimeExternalProfileReferences({
next: nextRuntimeStore,
existing: existingRuntimeStore,
})
: nextRuntimeStore,
let publishRuntimeSnapshots: (() => void) | undefined;
runAuthProfileWriteTransaction(agentDir, (transactionDatabase) => {
publishRuntimeSnapshots = saveAuthProfileStoreInTransaction(
store,
agentDir,
options,
transactionDatabase,
);
});
publishRuntimeSnapshotsAfterCommit(publishRuntimeSnapshots);
}
export type AuthProfileStorePersistenceSnapshot = {
credentialsRaw: unknown;
stateRaw: unknown;
runtimeCaptured: boolean;
runtimeRevision?: number;
runtimeRevisionAtSaveEdge?: number;
runtimeRevisionBeforePublication?: number;
runtimeStore?: AuthProfileStore;
derivedRuntimeStores?: Array<{
agentDir: string;
store: AuthProfileStore;
runtimeRevision?: number;
}>;
derivedRuntimeRevisionsAtSaveEdge?: Array<{ agentDir: string; runtimeRevision: number }>;
derivedRuntimeRevisionsBeforePublication?: Array<{
agentDir: string;
runtimeRevision: number;
}>;
};
export type CommittedAuthProfileStoreSave = {
owned: AuthProfileStorePersistenceSnapshot;
publishRuntimeSnapshots: () => boolean;
};
function captureRuntimeAuthProfileStorePersistenceSnapshot(
agentDir?: string,
): Pick<
AuthProfileStorePersistenceSnapshot,
"runtimeCaptured" | "runtimeRevision" | "runtimeStore" | "derivedRuntimeStores"
> {
const capturedAuthPath = resolveAuthStorePath(agentDir);
const mainAuthPath = resolveAuthStorePath(undefined);
return {
runtimeCaptured: true,
runtimeRevision: getRuntimeAuthProfileStoreSnapshotRevision(agentDir),
runtimeStore: getRuntimeAuthProfileStoreSnapshot(agentDir),
derivedRuntimeStores:
capturedAuthPath === mainAuthPath
? listRuntimeAuthProfileStoreSnapshots()
.filter((entry) => resolveAuthStorePath(entry.agentDir) !== mainAuthPath)
.map(({ agentDir: derivedAgentDir, store }) => ({
agentDir: derivedAgentDir,
store,
runtimeRevision: getRuntimeAuthProfileStoreSnapshotRevision(derivedAgentDir),
}))
: [],
};
}
function recordRuntimeAuthProfileStoreOwnership(
owned: AuthProfileStorePersistenceSnapshot,
runtime: ReturnType<typeof captureRuntimeAuthProfileStorePersistenceSnapshot>,
): void {
// The raw rows are the compare-and-swap token captured under the SQLite
// transaction. Never replace them with a later persistence read.
owned.runtimeCaptured = runtime.runtimeCaptured;
if (runtime.runtimeRevision !== undefined) {
owned.runtimeRevision = runtime.runtimeRevision;
}
if (runtime.runtimeStore !== undefined) {
owned.runtimeStore = runtime.runtimeStore;
}
if (runtime.derivedRuntimeStores !== undefined) {
owned.derivedRuntimeStores = runtime.derivedRuntimeStores;
}
}
function recordRuntimeAuthProfileStorePublicationEdge(
owned: AuthProfileStorePersistenceSnapshot,
runtime: ReturnType<typeof captureRuntimeAuthProfileStorePersistenceSnapshot>,
): void {
if (runtime.runtimeRevision !== undefined) {
owned.runtimeRevisionBeforePublication = runtime.runtimeRevision;
}
if (runtime.derivedRuntimeStores !== undefined) {
owned.derivedRuntimeRevisionsBeforePublication = runtime.derivedRuntimeStores.flatMap((entry) =>
typeof entry.runtimeRevision === "number"
? [{ agentDir: entry.agentDir, runtimeRevision: entry.runtimeRevision }]
: [],
);
}
}
function replaceRuntimeAuthProfileStoreSnapshot(
store: AuthProfileStore | undefined,
agentDir?: string,
): void {
if (store) {
setRuntimeAuthProfileStoreSnapshot(store, agentDir);
return;
}
const replacedAuthPath = resolveAuthStorePath(agentDir);
replaceRuntimeAuthProfileStoreSnapshotsImpl(
listRuntimeAuthProfileStoreSnapshots().filter(
(entry) => resolveAuthStorePath(entry.agentDir) !== replacedAuthPath,
),
);
}
function refreshRuntimeAuthProfileStoreSnapshot(agentDir?: string): void {
const existing = getRuntimeAuthProfileStoreSnapshot(agentDir);
if (!existing) {
return;
}
rebuildRuntimeAuthProfileStoreSnapshot(agentDir, existing);
}
function rebuildRuntimeAuthProfileStoreSnapshot(
agentDir: string | undefined,
existing: AuthProfileStore,
predecessor?: AuthProfileStore,
): void {
const refreshed = loadAuthProfileStoreWithoutExternalProfiles(agentDir);
const currentMaterialized = preserveResolvedSecretBackedCredentials({
next: refreshed,
existing,
});
const materialized = predecessor
? preserveResolvedSecretBackedCredentials({
next: currentMaterialized,
existing: predecessor,
})
: currentMaterialized;
const rebuilt = mergeRuntimeExternalProfileReferences({ next: materialized, existing });
setRuntimeAuthProfileStoreSnapshot(rebuilt, agentDir);
}
/** Capture both persisted auth rows under one database lock. */
export function captureAuthProfileStorePersistenceSnapshot(
agentDir?: string,
): AuthProfileStorePersistenceSnapshot {
return runAuthProfileWriteTransaction(agentDir, (database) => {
return {
credentialsRaw: readPersistedAuthProfileStoreRaw(agentDir, database),
stateRaw: readPersistedAuthProfileStateRaw(agentDir, database),
...captureRuntimeAuthProfileStorePersistenceSnapshot(agentDir),
};
});
}
/**
* Commit only while both persisted auth rows still match the captured baseline.
* The caller claims `owned` before publishing because publication is fallible.
*/
export function saveAuthProfileStoreIfPersistenceSnapshotMatches(params: {
store: AuthProfileStore;
snapshot: AuthProfileStorePersistenceSnapshot;
agentDir?: string;
options?: SaveAuthProfileStoreOptions;
}): CommittedAuthProfileStoreSave {
let publishRuntimeSnapshots: (() => void) | undefined;
const owned: AuthProfileStorePersistenceSnapshot = {
credentialsRaw: null,
stateRaw: null,
runtimeCaptured: false,
};
runAuthProfileWriteTransaction(params.agentDir, (database) => {
const currentCredentials = readPersistedAuthProfileStoreRaw(params.agentDir, database);
const currentState = readPersistedAuthProfileStateRaw(params.agentDir, database);
if (
!isDeepStrictEqual(currentCredentials, params.snapshot.credentialsRaw) ||
!isDeepStrictEqual(currentState, params.snapshot.stateRaw)
) {
throw new Error("auth profile store changed after secrets apply captured it");
}
const runtimeAtSaveEdge = captureRuntimeAuthProfileStorePersistenceSnapshot(params.agentDir);
owned.runtimeRevisionAtSaveEdge = runtimeAtSaveEdge.runtimeRevision;
owned.derivedRuntimeRevisionsAtSaveEdge = runtimeAtSaveEdge.derivedRuntimeStores?.flatMap(
(entry) =>
typeof entry.runtimeRevision === "number"
? [{ agentDir: entry.agentDir, runtimeRevision: entry.runtimeRevision }]
: [],
);
publishRuntimeSnapshots = saveAuthProfileStoreInTransaction(
params.store,
params.agentDir,
params.options,
database,
);
owned.credentialsRaw = readPersistedAuthProfileStoreRaw(params.agentDir, database);
owned.stateRaw = readPersistedAuthProfileStateRaw(params.agentDir, database);
});
return {
owned,
publishRuntimeSnapshots: () =>
publishRuntimeSnapshotsAfterCommit(() => {
recordRuntimeAuthProfileStorePublicationEdge(
owned,
captureRuntimeAuthProfileStorePersistenceSnapshot(params.agentDir),
);
publishRuntimeSnapshots?.();
recordRuntimeAuthProfileStoreOwnership(
owned,
captureRuntimeAuthProfileStorePersistenceSnapshot(params.agentDir),
);
}),
};
}
function reconcileRuntimeAuthProfileStorePersistenceSnapshot(params: {
snapshot: AuthProfileStorePersistenceSnapshot;
owned: AuthProfileStorePersistenceSnapshot;
agentDir?: string;
credentialsOwned: boolean;
stateOwned: boolean;
credentialsRestored: boolean;
stateRestored: boolean;
currentRuntimeStores: Array<{
agentDir: string;
store: AuthProfileStore;
runtimeRevision: number;
}>;
currentRuntimeRevision: number;
}): void {
if (!params.snapshot.runtimeCaptured || !params.owned.runtimeCaptured) {
return;
}
const rowsFullyOwned = params.credentialsOwned && params.stateOwned;
const rowsRestored = params.credentialsRestored || params.stateRestored;
const reconcileOne = (
agentDir: string | undefined,
snapshotStore: AuthProfileStore | undefined,
snapshotRuntimeRevision: number | undefined,
runtimeRevisionAtSaveEdge: number | undefined,
runtimeRevisionBeforePublication: number | undefined,
ownedStore: AuthProfileStore | undefined,
ownedRuntimeRevision: number | undefined,
currentStore: AuthProfileStore | undefined,
currentRuntimeRevision: number,
) => {
const runtimeGenerationOwned =
typeof snapshotRuntimeRevision === "number" &&
typeof runtimeRevisionAtSaveEdge === "number" &&
typeof runtimeRevisionBeforePublication === "number" &&
typeof ownedRuntimeRevision === "number" &&
snapshotRuntimeRevision === runtimeRevisionAtSaveEdge &&
runtimeRevisionAtSaveEdge === runtimeRevisionBeforePublication &&
currentRuntimeRevision === ownedRuntimeRevision;
if (rowsFullyOwned && runtimeGenerationOwned && isDeepStrictEqual(currentStore, ownedStore)) {
replaceRuntimeAuthProfileStoreSnapshot(snapshotStore, agentDir);
} else if (rowsRestored && currentStore) {
// Current overlays win, while the predecessor can still supply materialized
// values for final keyRefs that the candidate temporarily removed.
rebuildRuntimeAuthProfileStoreSnapshot(agentDir, currentStore, snapshotStore);
}
};
const restoredAuthPath = resolveAuthStorePath(params.agentDir);
const mainAuthPath = resolveAuthStorePath(undefined);
const currentRuntimeStores = new Map(
params.currentRuntimeStores.map((entry) => [resolveAuthStorePath(entry.agentDir), entry]),
);
reconcileOne(
params.agentDir,
params.snapshot.runtimeStore,
params.snapshot.runtimeRevision,
params.owned.runtimeRevisionAtSaveEdge,
params.owned.runtimeRevisionBeforePublication,
params.owned.runtimeStore,
params.owned.runtimeRevision,
currentRuntimeStores.get(restoredAuthPath)?.store,
params.currentRuntimeRevision,
);
if (restoredAuthPath !== mainAuthPath) {
return;
}
const snapshotDerived = new Map(
(params.snapshot.derivedRuntimeStores ?? []).map((entry) => [
resolveAuthStorePath(entry.agentDir),
entry,
]),
);
const ownedDerived = new Map(
(params.owned.derivedRuntimeStores ?? []).map((entry) => [
resolveAuthStorePath(entry.agentDir),
entry,
]),
);
const saveEdgeDerivedRevisions = new Map(
(params.owned.derivedRuntimeRevisionsAtSaveEdge ?? []).map((entry) => [
resolveAuthStorePath(entry.agentDir),
entry.runtimeRevision,
]),
);
const publicationEdgeDerivedRevisions = new Map(
(params.owned.derivedRuntimeRevisionsBeforePublication ?? []).map((entry) => [
resolveAuthStorePath(entry.agentDir),
entry.runtimeRevision,
]),
);
for (const [pathname, currentEntry] of currentRuntimeStores) {
if (pathname === mainAuthPath) {
continue;
}
const snapshotEntry = snapshotDerived.get(pathname);
const ownedEntry = ownedDerived.get(pathname);
reconcileOne(
currentEntry.agentDir,
snapshotEntry?.store,
snapshotEntry?.runtimeRevision,
saveEdgeDerivedRevisions.get(pathname),
publicationEdgeDerivedRevisions.get(pathname),
ownedEntry?.store,
ownedEntry?.runtimeRevision,
currentEntry.store,
currentEntry.runtimeRevision,
);
}
}
/** Restore each persisted row and runtime snapshot only while apply still owns it. */
export function restoreAuthProfileStorePersistenceSnapshot(
snapshot: AuthProfileStorePersistenceSnapshot,
owned: AuthProfileStorePersistenceSnapshot,
agentDir?: string,
): void {
let credentialsOwned = false;
let stateOwned = false;
let credentialsRestored = false;
let stateRestored = false;
let publishRuntimeSnapshots: (() => void) | undefined;
runAuthProfileWriteTransaction(agentDir, (database) => {
const existingRaw = readPersistedAuthProfileStoreRaw(agentDir, database);
const existingState = readPersistedAuthProfileStateRaw(agentDir, database);
credentialsOwned = isDeepStrictEqual(existingRaw, owned.credentialsRaw);
stateOwned = isDeepStrictEqual(existingState, owned.stateRaw);
const beforeProfiles =
isRecord(existingRaw) && isRecord(existingRaw.profiles) ? existingRaw.profiles : {};
const restoredProfiles =
isRecord(snapshot.credentialsRaw) && isRecord(snapshot.credentialsRaw.profiles)
? snapshot.credentialsRaw.profiles
: {};
const changedProfileIds = [
...new Set([...Object.keys(beforeProfiles), ...Object.keys(restoredProfiles)]),
].filter(
(profileId) => !isDeepStrictEqual(beforeProfiles[profileId], restoredProfiles[profileId]),
);
const profileSetChanged = changedProfileIds.some(
(profileId) =>
Object.hasOwn(beforeProfiles, profileId) !== Object.hasOwn(restoredProfiles, profileId),
);
credentialsRestored =
credentialsOwned && !isDeepStrictEqual(existingRaw, snapshot.credentialsRaw);
stateRestored = stateOwned && !isDeepStrictEqual(existingState, snapshot.stateRaw);
if (credentialsRestored) {
if (snapshot.credentialsRaw === null) {
deletePersistedAuthProfileStoreRaw(agentDir, database);
} else {
writePersistedAuthProfileStoreRaw(snapshot.credentialsRaw, agentDir, database);
}
}
if (stateRestored) {
writePersistedAuthProfileStateRaw(snapshot.stateRaw, agentDir, database);
}
publishRuntimeSnapshots = () => {
// Main credential mutation lineage invalidates derived snapshots. Capture
// them first so exact-owned entries can restore and newer entries rebuild.
const currentRuntimeStores = listRuntimeAuthProfileStoreSnapshots().map(
({ agentDir: runtimeAgentDir, store }) => ({
agentDir: runtimeAgentDir,
store,
runtimeRevision: getRuntimeAuthProfileStoreSnapshotRevision(runtimeAgentDir),
}),
);
const currentRuntimeRevision = getRuntimeAuthProfileStoreSnapshotRevision(agentDir);
if (credentialsRestored || stateRestored) {
noteRuntimeAuthProfileStorePersistedMutation(agentDir, {
credentialsChanged: credentialsRestored,
profileSetChanged: credentialsRestored && profileSetChanged,
stateChanged: stateRestored,
profileIds: credentialsRestored ? changedProfileIds : [],
});
}
reconcileRuntimeAuthProfileStorePersistenceSnapshot({
snapshot,
owned,
agentDir,
credentialsOwned,
stateOwned,
credentialsRestored,
stateRestored,
currentRuntimeStores,
currentRuntimeRevision,
});
};
});
publishRuntimeSnapshotsAfterCommit(publishRuntimeSnapshots);
}
+6
View File
@@ -151,6 +151,12 @@ export type AuthProfileStore = AuthProfileSecretsStore &
runtimeExternalProfileIdsAuthoritative?: boolean;
};
/** Internal effective-store ownership metadata; never exposed through the plugin SDK. */
export type RuntimeAuthProfileStore = AuthProfileStore & {
runtimeLocalProfileIds?: string[];
runtimeInheritsMainState?: boolean;
};
/** Result returned by config/store auth profile id repair. */
export type AuthProfileIdRepairResult = {
config: OpenClawConfig;
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { resetConfigRuntimeState, setRuntimeConfigSnapshot } from "../config/config.js";
import { activateSecretsRuntimeSnapshot, clearSecretsRuntimeSnapshot } from "../secrets/runtime.js";
import { getRuntimeAuthProfileStoreCredentialsRevision } from "./auth-profiles/runtime-snapshots.js";
import { resolveOpenClawPluginToolsForOptions } from "./openclaw-plugin-tools.js";
const hoisted = vi.hoisted(() => ({
@@ -257,6 +258,7 @@ describe("createOpenClawTools browser plugin integration", () => {
sourceConfig: staleSourceConfig,
config: staleRuntimeConfig,
authStores: [],
authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(),
warnings: [],
webTools: {
search: {
+2
View File
@@ -22,6 +22,7 @@ export {
markGatewaySigusr1RestartHandled,
peekGatewaySigusr1RestartReason,
resetGatewayRestartStateForInProcessRestart,
requestGatewayRestartWithSignalAdmission,
rollbackGatewayRestartSignalAdmission,
scheduleGatewaySigusr1Restart,
} from "../../infra/restart.js";
@@ -46,6 +47,7 @@ export {
resetAllLanes,
waitForActiveTasks,
} from "../../process/command-queue.js";
export { waitForActiveGatewayRootWork } from "../../process/gateway-work-admission.js";
export { getInspectableActiveTaskRestartBlockers } from "../../tasks/task-registry.maintenance.js";
export { reloadTaskRuntimeStateFromStore } from "../../tasks/runtime-internal.js";
export { abortPendingChannelReloads } from "../../gateway/server-reload-handlers.js";
+25 -25
View File
@@ -1,5 +1,7 @@
import { resetPublishedConfigRuntimeEnv } from "../../config/config-env-vars.js";
// Gateway startup checks that must run before shared CLI bootstrap can migrate state.
import { ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS_ENV } from "../../config/future-version-guard.js";
import { GATEWAY_CONFIG_SELECTION_ENV_KEYS } from "../../config/gateway-env-selection.js";
import type { ConfigFileSnapshot } from "../../config/types.js";
import { ExitError, type RuntimeEnv } from "../../runtime.js";
import type { GatewayRunPreBootstrapOptions } from "./future-config-guard.js";
@@ -38,27 +40,6 @@ async function pinGatewayRunRuntimePaths(): Promise<void> {
pinConfigDir(process.env);
}
const GATEWAY_CONFIG_SELECTION_ENV_KEYS = new Set([
"ANDROID_DATA",
"HOME",
"HOMEDRIVE",
"HOMEPATH",
"OPENCLAW_AGENT_DIR",
"OPENCLAW_CONFIG_PATH",
"OPENCLAW_HOME",
"OPENCLAW_INCLUDE_ROOTS",
"OPENCLAW_NIX_MODE",
"OPENCLAW_OAUTH_DIR",
"OPENCLAW_PACKAGE_DIR",
"OPENCLAW_PROFILE",
"OPENCLAW_STATE_DIR",
"OPENCLAW_TEST_FAST",
"OPENCLAW_WORKSPACE_DIR",
"PI_CODING_AGENT_DIR",
"PREFIX",
"USERPROFILE",
]);
const GATEWAY_RESET_SELECTION_ENV_KEYS = new Set([
...GATEWAY_CONFIG_SELECTION_ENV_KEYS,
"OPENCLAW_PROFILE",
@@ -267,7 +248,7 @@ async function guardGatewayRunSelectedConfig(
{ resolveConfigDir },
] = await Promise.all([
import("node:path"),
import("../../config/env-vars.js"),
import("../../config/config-env-vars.js"),
import("../../infra/dotenv-global.js"),
import("../../infra/env.js"),
import("../../config/paths.js"),
@@ -484,12 +465,17 @@ export async function applyFinalGatewayRunConfigEnv(params: {
const envBeforeApply = { ...process.env };
const selectionSignature = resolveGatewayConfigSelectionSignature(process.env);
const [
{ applyConfigEnvVars, collectConfigRuntimeEnvVars },
{
applyConfigEnvVars,
collectConfigRuntimeEnvOwnership,
collectConfigRuntimeEnvVars,
initializePublishedConfigRuntimeEnv,
},
{ normalizeEnv },
{ normalizeStateDirEnv },
{ clearShellEnvAppliedKeys },
] = await Promise.all([
import("../../config/env-vars.js"),
import("../../config/config-env-vars.js"),
import("../../infra/env.js"),
import("../../config/paths.js"),
import("../../infra/shell-env.js"),
@@ -508,9 +494,14 @@ export async function applyFinalGatewayRunConfigEnv(params: {
return false;
}
restoreAppliedGatewayRunConfigEnvironment();
const envBeforeConfigApply = { ...process.env };
const replacedLowerPrecedenceKeys: string[] = [];
applyConfigEnvVars(params.snapshot.sourceConfig, process.env, {
lowerPrecedenceEnv: params.lowerPrecedenceEnv,
onLowerPrecedenceKeysReplaced: clearShellEnvAppliedKeys,
onLowerPrecedenceKeysReplaced: (keys) => {
replacedLowerPrecedenceKeys.push(...keys);
clearShellEnvAppliedKeys(keys);
},
});
normalizeStateDirEnv(process.env);
normalizeEnv();
@@ -520,6 +511,14 @@ export async function applyFinalGatewayRunConfigEnv(params: {
after: { ...process.env },
};
if (resolveGatewayConfigSelectionSignature(process.env) === selectionSignature) {
initializePublishedConfigRuntimeEnv(params.snapshot.sourceConfig, {
ownedEnv: collectConfigRuntimeEnvOwnership(
params.snapshot.sourceConfig,
envBeforeConfigApply,
process.env,
{ replacedLowerPrecedenceKeys },
),
});
return true;
}
appliedGatewayRunConfigEnvironment = undefined;
@@ -533,6 +532,7 @@ export async function applyFinalGatewayRunConfigEnv(params: {
export function clearGatewayRunConfigEnvironment(): void {
restoreAppliedGatewayRunConfigEnvironment();
resetPublishedConfigRuntimeEnv();
}
export async function reloadTrustedGatewayRunEnvironment(params: {
+22 -1
View File
@@ -20,6 +20,7 @@ const markGatewaySigusr1RestartHandled = vi.fn();
const peekGatewaySigusr1RestartReason = vi.fn<() => string | undefined>(() => undefined);
const resetGatewayRestartStateForInProcessRestart = vi.fn();
const rollbackGatewayRestartSignalAdmission = vi.fn();
const requestGatewayRestartWithSignalAdmission = vi.fn(() => ({ status: "emitted" as const }));
const writeGatewayRestartHandoffSync = vi.fn((_opts: unknown) => ({
kind: "gateway-supervisor-restart-handoff" as const,
version: 1 as const,
@@ -54,6 +55,10 @@ const getInspectableActiveTaskRestartBlockers = vi.fn(
);
const markGatewayDraining = vi.fn();
const waitForActiveTasks = vi.fn(async (_timeoutMs?: number) => ({ drained: true }));
const waitForActiveGatewayRootWork = vi.fn(async (_timeoutMs?: number) => ({
drained: true,
active: 0,
}));
const resetAllLanes = vi.fn();
const advanceCronActiveJobGeneration = vi.fn();
const resetCronActiveJobs = vi.fn();
@@ -132,6 +137,7 @@ vi.mock("../../infra/restart.js", () => ({
peekGatewaySigusr1RestartReason: () => peekGatewaySigusr1RestartReason(),
resetGatewayRestartStateForInProcessRestart: () => resetGatewayRestartStateForInProcessRestart(),
rollbackGatewayRestartSignalAdmission: () => rollbackGatewayRestartSignalAdmission(),
requestGatewayRestartWithSignalAdmission,
resolveGatewayRestartDeferralTimeoutMs: (timeoutMs: unknown) => {
if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs)) {
return DEFAULT_RESTART_DEFERRAL_TIMEOUT_MS;
@@ -167,6 +173,10 @@ vi.mock("../../process/command-queue.js", () => ({
resetAllLanes: () => resetAllLanes(),
}));
vi.mock("../../process/gateway-work-admission.js", () => ({
waitForActiveGatewayRootWork: (timeoutMs?: number) => waitForActiveGatewayRootWork(timeoutMs),
}));
vi.mock("../../cron/active-jobs.js", () => ({
advanceCronActiveJobGeneration: () => advanceCronActiveJobGeneration(),
resetCronActiveJobs: () => resetCronActiveJobs(),
@@ -440,7 +450,7 @@ describe("runGatewayLoop", () => {
vi.clearAllMocks();
await withIsolatedSignals(async ({ captureSignal }) => {
const { close, runtime, exited } = await createSignaledLoopHarness();
const { close, start, runtime, exited } = await createSignaledLoopHarness();
const sigterm = captureSignal("SIGTERM");
sigterm();
@@ -450,6 +460,10 @@ describe("runGatewayLoop", () => {
reason: "gateway stopping",
restartExpectedMs: null,
});
expect(start).toHaveBeenCalledWith({
startupStartedAt: expect.any(Number),
requestHotReloadRecovery: requestGatewayRestartWithSignalAdmission,
});
expect(runtime.exit).toHaveBeenCalledWith(0);
});
});
@@ -672,6 +686,8 @@ describe("runGatewayLoop", () => {
expect(waitForActiveTasks).toHaveBeenCalledWith(90_000);
expect(waitForActiveEmbeddedRuns).toHaveBeenCalledWith(90_000);
expect(waitForActiveGatewayRootWork).toHaveBeenCalledOnce();
expect(waitForActiveGatewayRootWork.mock.calls[0]?.[0]).toBeLessThanOrEqual(90_000);
expect(abortEmbeddedAgentRun).toHaveBeenCalledWith(undefined, {
mode: "compacting",
reason: "restart",
@@ -715,6 +731,7 @@ describe("runGatewayLoop", () => {
getActiveEmbeddedRunCount.mockReturnValueOnce(1).mockReturnValue(0);
listActiveEmbeddedRunSessionIds.mockReturnValueOnce(["session-deferral-timeout"]);
listActiveEmbeddedRunSessionKeys.mockReturnValueOnce(["agent:main:deferral-timeout"]);
markRestartAbortedMainSessions.mockRejectedValueOnce(new Error("store read-only"));
await withIsolatedSignals(async ({ captureSignal }) => {
const { close, start, exited } = await createSignaledLoopHarness();
@@ -731,6 +748,7 @@ describe("runGatewayLoop", () => {
expect(waitForActiveTasks).not.toHaveBeenCalled();
expect(waitForActiveEmbeddedRuns).not.toHaveBeenCalled();
expect(waitForActiveGatewayRootWork).not.toHaveBeenCalled();
expect(abortEmbeddedAgentRun).toHaveBeenCalledWith(undefined, {
mode: "compacting",
reason: "restart",
@@ -751,6 +769,9 @@ describe("runGatewayLoop", () => {
sessionKeys: new Set(["agent:main:deferral-timeout"]),
reason: "gateway restart drain",
});
expect(gatewayLog.warn).toHaveBeenCalledWith(
"failed to mark interrupted main sessions for restart recovery: Error: store read-only",
);
expect(markGatewaySigusr1RestartHandled).toHaveBeenCalledOnce();
expectRestartCloseCall(close, 0);
expect(start).toHaveBeenCalledTimes(2);
+21 -1
View File
@@ -14,6 +14,7 @@ import type { startGatewayServer } from "../../gateway/server.js";
import { formatErrorMessage } from "../../infra/errors.js";
import type { GatewayBootLifecycleCompletion } from "../../infra/gateway-boot-lifecycle.js";
import { acquireGatewayLock } from "../../infra/gateway-lock.js";
import type { GatewayRestartEmitter } from "../../infra/restart.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import type { RuntimeEnv } from "../../runtime.js";
import { createLazyImportLoader } from "../../shared/lazy-promise.js";
@@ -108,6 +109,7 @@ async function waitForHealthyGatewayChild(
export async function runGatewayLoop(params: {
start: (params?: {
startupStartedAt?: number;
requestHotReloadRecovery?: GatewayRestartEmitter;
}) => Promise<Awaited<ReturnType<typeof startGatewayServer>>>;
runtime: RuntimeEnv;
lockPort?: number;
@@ -516,6 +518,7 @@ export async function runGatewayLoop(params: {
listActiveEmbeddedRunSessionIds,
listActiveEmbeddedRunSessionKeys,
markRestartAbortedMainSessions,
waitForActiveGatewayRootWork,
waitForActiveEmbeddedRuns,
waitForActiveTasks,
} = await loadGatewayLifecycleRuntimeModule();
@@ -571,6 +574,13 @@ export async function runGatewayLoop(params: {
// Reject new enqueues immediately during the drain window so
// sessions get an explicit restart error instead of silent task loss.
markRestartDraining();
const rootDrainTimeoutMs =
restartDrainDeadlineAt === undefined
? undefined
: Math.max(0, restartDrainDeadlineAt - Date.now());
const rootDrainPromise = restartIntent?.force
? Promise.resolve({ drained: true, active: 0 })
: waitForActiveGatewayRootWork(rootDrainTimeoutMs);
const activeTasks = getActiveTaskCount();
const activeRuns = getActiveEmbeddedRunCount();
activeTasksAtDrainStart = activeTasks;
@@ -640,6 +650,13 @@ export async function runGatewayLoop(params: {
}
}
}
const rootDrain = await rootDrainPromise;
if (!rootDrain.drained) {
drainTimedOut = true;
gatewayLog.warn(
`gateway root transaction drain timeout reached with ${rootDrain.active} root(s) still active; proceeding with restart`,
);
}
},
() => [
["activeTasks", activeTasksAtDrainStart],
@@ -926,7 +943,10 @@ export async function runGatewayLoop(params: {
await onIteration();
startupStartedAt = Date.now();
await params.beginBoot?.(startupStartedAt);
server = await params.start({ startupStartedAt });
server = await params.start({
startupStartedAt,
requestHotReloadRecovery: eagerLifecycleRuntime.requestGatewayRestartWithSignalAdmission,
});
startupFailedWithoutServerHandle = false;
isFirstStart = false;
} catch (err) {
+2 -1
View File
@@ -1034,7 +1034,7 @@ export async function runGatewayCommand(opts: GatewayRunOpts, hooks: GatewayRunR
healthHost,
beginBoot,
completeBoot,
start: async ({ startupStartedAt } = {}) => {
start: async ({ startupStartedAt, requestHotReloadRecovery } = {}) => {
const startupConfigSnapshotReadForThisStart = startupConfigSnapshotReadForNextStart;
startupConfigSnapshotReadForNextStart = undefined;
return await startGatewayServer(port, {
@@ -1042,6 +1042,7 @@ export async function runGatewayCommand(opts: GatewayRunOpts, hooks: GatewayRunR
auth: authOverride,
tailscale: tailscaleOverride,
startupStartedAt,
...(requestHotReloadRecovery ? { hotReloadRecovery: requestHotReloadRecovery } : {}),
...(startupConfigSnapshotReadForThisStart
? { startupConfigSnapshotRead: startupConfigSnapshotReadForThisStart }
: {}),
+3 -3
View File
@@ -5,6 +5,7 @@ import path from "node:path";
import type { DatabaseSync } from "node:sqlite";
import { expectDefined } from "@openclaw/normalization-core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import {
loadExactSqliteSessionEntry,
loadSqliteTranscriptEventsSync,
@@ -44,6 +45,7 @@ const previousEnv = {
OPENCLAW_CONFIG_PATH: process.env.OPENCLAW_CONFIG_PATH,
OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR,
};
const autoCleanupTempDirs = useAutoCleanupTempDirTracker(afterEach);
const lexicalTempDir = path.resolve(os.tmpdir());
const realTempDir = fs.realpathSync.native(os.tmpdir());
const hasPlatformTempAlias = lexicalTempDir !== realTempDir;
@@ -2319,9 +2321,7 @@ function createLegacyStore(
transcriptLines?: string[];
} = {},
): TestStore {
const tempDir = fs.mkdtempSync(
path.join(params.tempRoot ?? os.tmpdir(), "openclaw-doctor-session-sqlite-"),
);
const tempDir = autoCleanupTempDirs.make("openclaw-doctor-session-sqlite-", params.tempRoot);
const stateDir = path.join(tempDir, "state");
const configPath = path.join(tempDir, "openclaw.json");
const sessionDir = params.customStore
+381
View File
@@ -79,6 +79,50 @@ function findCaseInsensitiveEnvKey(env: NodeJS.ProcessEnv, key: string): string
return Object.keys(env).find((candidate) => candidate.toUpperCase() === upperKey);
}
type EnvSnapshotEntry = {
key: string;
value: string | undefined;
};
function envSnapshotKey(key: string): string {
return process.platform === "win32" ? key.toUpperCase() : key;
}
function snapshotEnvByPlatformKey(
env: Readonly<Record<string, string | undefined>>,
): Map<string, EnvSnapshotEntry> {
// Windows has one logical slot per case-insensitive key. Retain its exact spelling so
// publication and rollback can compare-and-swap the slot without losing the original key.
const snapshot = new Map<string, EnvSnapshotEntry>();
for (const [key, value] of Object.entries(env)) {
const platformKey = envSnapshotKey(key);
if (!snapshot.has(platformKey)) {
snapshot.set(platformKey, { key, value });
}
}
return snapshot;
}
function envSnapshotEntriesEqual(
left: EnvSnapshotEntry | undefined,
right: EnvSnapshotEntry | undefined,
): boolean {
return left?.key === right?.key && left?.value === right?.value;
}
function replaceEnvSnapshotEntry(
env: NodeJS.ProcessEnv,
current: EnvSnapshotEntry | undefined,
next: EnvSnapshotEntry | undefined,
): void {
if (current) {
delete env[current.key];
}
if (next?.value !== undefined) {
env[next.key] = next.value;
}
}
export function cloneEnvWithPlatformSemantics(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const cloned = { ...env } as NodeJS.ProcessEnv;
if (process.platform !== "win32") {
@@ -151,6 +195,343 @@ export function createConfigRuntimeEnv(
return env;
}
/** Config-owned runtime env staged for one acceptance transaction. */
export type ConfigRuntimeEnvPublication = (() => void) & {
commit: () => void;
};
export type PreparedConfigRuntimeEnv = {
env: NodeJS.ProcessEnv;
publish: () => ConfigRuntimeEnvPublication;
};
type PublishedConfigRuntimeEnvState = {
generation: number;
ownedEnv: Readonly<Record<string, string>>;
sourceConfig: OpenClawConfig | null;
};
type PublishedConfigRuntimeEnvChange = {
before: EnvSnapshotEntry | undefined;
after: EnvSnapshotEntry | undefined;
preparedBefore: EnvSnapshotEntry | undefined;
};
type PendingConfigRuntimeEnvPublication = {
epoch: number;
previous: PendingConfigRuntimeEnvPublication | null;
previousState: PublishedConfigRuntimeEnvState;
changes: ReadonlyMap<string, PublishedConfigRuntimeEnvChange>;
committed: boolean;
rollbackRequested: boolean;
};
let publishedConfigRuntimeEnvState: PublishedConfigRuntimeEnvState = {
generation: 0,
ownedEnv: {},
sourceConfig: null,
};
let publishedConfigRuntimeEnvEpoch = 0;
// Only uncommitted publications stay linked. Commit severs the chain so successful reloads
// cannot retain superseded rollback state, while overlapping failures can still unwind in order.
let pendingConfigRuntimeEnvPublication: PendingConfigRuntimeEnvPublication | null = null;
function applyPublishedConfigRuntimeEnvRollback(
publication: PendingConfigRuntimeEnvPublication,
): void {
for (const [key, change] of publication.changes) {
const currentEntry = snapshotEnvByPlatformKey(process.env).get(key);
if (!envSnapshotEntriesEqual(currentEntry, change.after)) {
continue;
}
replaceEnvSnapshotEntry(process.env, currentEntry, change.before);
}
publishedConfigRuntimeEnvState = {
generation: publishedConfigRuntimeEnvState.generation + 1,
ownedEnv: publication.previousState.ownedEnv,
sourceConfig: publication.previousState.sourceConfig,
};
}
function isPendingConfigRuntimeEnvPublication(
publication: PendingConfigRuntimeEnvPublication,
): boolean {
let current = pendingConfigRuntimeEnvPublication;
while (current) {
if (current === publication) {
return true;
}
current = current.previous;
}
return false;
}
function unwindRequestedConfigRuntimeEnvPublications(): void {
while (pendingConfigRuntimeEnvPublication?.rollbackRequested) {
const publication = pendingConfigRuntimeEnvPublication;
applyPublishedConfigRuntimeEnvRollback(publication);
const previous = publication.previous;
if (!previous || previous.committed) {
pendingConfigRuntimeEnvPublication = null;
return;
}
pendingConfigRuntimeEnvPublication = previous;
}
}
export function getPublishedConfigRuntimeEnvState(): PublishedConfigRuntimeEnvState {
return publishedConfigRuntimeEnvState;
}
export function collectConfigRuntimeEnvOwnership(
sourceConfig: OpenClawConfig,
before: Readonly<Record<string, string | undefined>>,
after: Readonly<Record<string, string | undefined>>,
options: { replacedLowerPrecedenceKeys?: readonly string[] } = {},
): Record<string, string> {
const ownedEnv: Record<string, string> = {};
// Equal bytes cannot reveal that config replaced a lower-precedence layer.
// Carry the apply-time replacement signal so later reloads can remove that owned value.
const replacedLowerPrecedenceKeys = new Set(
(options.replacedLowerPrecedenceKeys ?? []).map(envSnapshotKey),
);
for (const [key, value] of Object.entries(collectConfigRuntimeEnvVars(sourceConfig))) {
for (const normalizedKey of resolveEnvNormalizationKeys(key)) {
const afterKey = findCaseInsensitiveEnvKey(after, normalizedKey);
if (!afterKey || after[afterKey] !== value) {
continue;
}
const beforeKey = findCaseInsensitiveEnvKey(before, normalizedKey);
if (
beforeKey &&
before[beforeKey] === value &&
!replacedLowerPrecedenceKeys.has(envSnapshotKey(afterKey))
) {
continue;
}
ownedEnv[afterKey] = value;
}
}
return ownedEnv;
}
function filterConfigRuntimeEnvOwnership(
sourceConfig: OpenClawConfig,
env: NodeJS.ProcessEnv,
ownedEnv: Readonly<Record<string, string>>,
): Record<string, string> {
const allowedValues = new Map<string, Set<string>>();
for (const [key, value] of Object.entries(collectConfigRuntimeEnvVars(sourceConfig))) {
for (const normalizedKey of resolveEnvNormalizationKeys(key)) {
const values = allowedValues.get(normalizedKey) ?? new Set<string>();
values.add(value);
allowedValues.set(normalizedKey, values);
}
}
const filtered: Record<string, string> = {};
for (const [key, value] of Object.entries(ownedEnv)) {
const normalizedKey = resolveEnvNormalizationKeys(key)[0] ?? key;
const actualKey = findCaseInsensitiveEnvKey(env, key);
if (actualKey && env[actualKey] === value && allowedValues.get(normalizedKey)?.has(value)) {
filtered[actualKey] = value;
}
}
return filtered;
}
export function initializePublishedConfigRuntimeEnv(
sourceConfig: OpenClawConfig,
options: {
ownedEnv?: Readonly<Record<string, string>>;
preserveExistingOwnership?: boolean;
} = {},
): void {
const ownedEnv = filterConfigRuntimeEnvOwnership(
sourceConfig,
process.env,
options.preserveExistingOwnership
? { ...publishedConfigRuntimeEnvState.ownedEnv, ...options.ownedEnv }
: (options.ownedEnv ?? {}),
);
publishedConfigRuntimeEnvState = {
generation: publishedConfigRuntimeEnvState.generation + 1,
ownedEnv,
sourceConfig,
};
publishedConfigRuntimeEnvEpoch += 1;
pendingConfigRuntimeEnvPublication = null;
}
export function resetPublishedConfigRuntimeEnv(): void {
publishedConfigRuntimeEnvState = { generation: 0, ownedEnv: {}, sourceConfig: null };
publishedConfigRuntimeEnvEpoch += 1;
pendingConfigRuntimeEnvPublication = null;
}
/** Removes the active config-owned layer from an isolated read environment. */
export function createConfigRuntimeEnvBase(
activeConfig: OpenClawConfig,
env: NodeJS.ProcessEnv = process.env,
options: {
ownedEnv?: Readonly<Record<string, string>>;
preservedKeys?: ReadonlySet<string>;
} = {},
): NodeJS.ProcessEnv {
const isolated = cloneEnvWithPlatformSemantics(env);
const ownedEnv = filterConfigRuntimeEnvOwnership(
activeConfig,
env,
options.ownedEnv ?? (env === process.env ? publishedConfigRuntimeEnvState.ownedEnv : {}),
);
for (const [key, ownedValue] of Object.entries(ownedEnv)) {
if (options.preservedKeys?.has(key.toUpperCase())) {
continue;
}
if (isolated[key] === ownedValue) {
delete isolated[key];
}
}
return isolated;
}
/** Prepares a config-owned env layer without mutating the live process. */
export function prepareConfigRuntimeEnv(params: {
previousConfig: OpenClawConfig;
nextConfig: OpenClawConfig;
env?: NodeJS.ProcessEnv;
previousOwnedEnv?: Readonly<Record<string, string>>;
}): PreparedConfigRuntimeEnv {
const targetEnv = params.env ?? process.env;
const before = snapshotEnvByPlatformKey(targetEnv);
const preparedEnv = createConfigRuntimeEnvBase(
params.previousConfig,
targetEnv,
params.previousOwnedEnv ? { ownedEnv: params.previousOwnedEnv } : {},
);
const base = { ...preparedEnv } as Record<string, string | undefined>;
applyConfigEnvVars(params.nextConfig, preparedEnv);
const after = { ...preparedEnv } as Record<string, string | undefined>;
const afterByPlatformKey = snapshotEnvByPlatformKey(after);
const preparedOwnedEnv = collectConfigRuntimeEnvOwnership(params.nextConfig, base, after);
return {
env: preparedEnv,
publish: () => {
const processPublication = targetEnv === process.env;
const previousPublishedState = publishedConfigRuntimeEnvState;
const previousPublication = processPublication ? pendingConfigRuntimeEnvPublication : null;
const published = new Map<string, PublishedConfigRuntimeEnvChange>();
const keys = new Set([
...before.keys(),
...afterByPlatformKey.keys(),
...(previousPublication?.changes.keys() ?? []),
]);
for (const key of keys) {
const beforeEntry = before.get(key);
const afterEntry = afterByPlatformKey.get(key);
const currentEntry = snapshotEnvByPlatformKey(targetEnv).get(key);
const previousChange = previousPublication?.changes.get(key);
const continuesPreviousPublication =
previousChange !== undefined &&
envSnapshotEntriesEqual(currentEntry, previousChange.after) &&
envSnapshotEntriesEqual(beforeEntry, previousChange.preparedBefore);
const appliesToPreparedSnapshot =
!envSnapshotEntriesEqual(beforeEntry, afterEntry) &&
envSnapshotEntriesEqual(currentEntry, beforeEntry);
if (!continuesPreviousPublication && !appliesToPreparedSnapshot) {
continue;
}
published.set(key, {
before: currentEntry,
after: afterEntry,
preparedBefore: beforeEntry,
});
if (!envSnapshotEntriesEqual(currentEntry, afterEntry)) {
replaceEnvSnapshotEntry(targetEnv, currentEntry, afterEntry);
}
}
const publicationGeneration = processPublication
? publishedConfigRuntimeEnvState.generation + 1
: null;
const publicationEpoch = publishedConfigRuntimeEnvEpoch;
let processPublicationState: PendingConfigRuntimeEnvPublication | null = null;
if (publicationGeneration !== null) {
const ownedEnv: Record<string, string> = {};
for (const [key, value] of Object.entries(preparedOwnedEnv)) {
const platformKey = envSnapshotKey(key);
const currentEntry = snapshotEnvByPlatformKey(targetEnv).get(platformKey);
const preparedEntry = afterByPlatformKey.get(platformKey);
const previousOwnedKey = findCaseInsensitiveEnvKey(previousPublishedState.ownedEnv, key);
if (
currentEntry?.value === value &&
envSnapshotEntriesEqual(currentEntry, preparedEntry) &&
(published.has(platformKey) ||
(previousOwnedKey !== undefined &&
previousPublishedState.ownedEnv[previousOwnedKey] === value))
) {
ownedEnv[currentEntry.key] = value;
}
}
publishedConfigRuntimeEnvState = {
generation: publicationGeneration,
ownedEnv,
sourceConfig: params.nextConfig,
};
processPublicationState = {
epoch: publicationEpoch,
previous: previousPublication,
previousState: previousPublishedState,
changes: published,
committed: false,
rollbackRequested: false,
};
pendingConfigRuntimeEnvPublication = processPublicationState;
}
let active = true;
const rollback = (() => {
if (!active) {
return;
}
active = false;
if (processPublicationState) {
if (processPublicationState.epoch !== publishedConfigRuntimeEnvEpoch) {
return;
}
processPublicationState.rollbackRequested = true;
if (!isPendingConfigRuntimeEnvPublication(processPublicationState)) {
return;
}
unwindRequestedConfigRuntimeEnvPublications();
return;
}
for (const [key, publication] of published) {
const currentEntry = snapshotEnvByPlatformKey(targetEnv).get(key);
if (!envSnapshotEntriesEqual(currentEntry, publication.after)) {
continue;
}
replaceEnvSnapshotEntry(targetEnv, currentEntry, publication.before);
}
}) as ConfigRuntimeEnvPublication;
rollback.commit = () => {
if (!active) {
return;
}
active = false;
if (!processPublicationState) {
return;
}
processPublicationState.committed = true;
processPublicationState.rollbackRequested = false;
processPublicationState.previous = null;
if (pendingConfigRuntimeEnvPublication === processPublicationState) {
pendingConfigRuntimeEnvPublication = null;
}
};
return rollback;
},
};
}
/** Applies config env vars to an environment without overwriting existing non-empty values. */
export function applyConfigEnvVars(
cfg: OpenClawConfig,
+330 -4
View File
@@ -3,14 +3,20 @@ import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { loadDotEnv } from "../infra/dotenv.js";
import { resolveConfigEnvVars } from "./env-substitution.js";
import {
applyConfigEnvVars,
collectDurableServiceEnvVars,
collectConfigRuntimeEnvOwnership,
collectConfigRuntimeEnvVars,
createConfigRuntimeEnv,
readStateDirDotEnvVars,
} from "./env-vars.js";
createConfigRuntimeEnvBase,
getPublishedConfigRuntimeEnvState,
initializePublishedConfigRuntimeEnv,
prepareConfigRuntimeEnv,
resetPublishedConfigRuntimeEnv,
} from "./config-env-vars.js";
import { resolveConfigEnvVars } from "./env-substitution.js";
import { assertGatewayConfigEnvSelectionUnchanged } from "./gateway-env-selection.js";
import { collectDurableServiceEnvVars, readStateDirDotEnvVars } from "./state-dir-dotenv.js";
import { withEnvOverride, withTempHome, writeStateDirDotEnv } from "./test-helpers.js";
import type { OpenClawConfig } from "./types.js";
@@ -134,6 +140,223 @@ describe("config env vars", () => {
});
});
it("prepares config env updates and removals without mutating the target", () => {
const env = { UPDATE_ME: "old", REMOVE_ME: "owned", KEEP_OVERRIDE: "ambient" };
const prepared = prepareConfigRuntimeEnv({
previousConfig: {
env: {
vars: {
UPDATE_ME: "old",
REMOVE_ME: "owned",
KEEP_OVERRIDE: "owned",
},
},
},
nextConfig: { env: { vars: { UPDATE_ME: "new" } } },
env,
previousOwnedEnv: { UPDATE_ME: "old", REMOVE_ME: "owned" },
});
expect(env).toEqual({ UPDATE_ME: "old", REMOVE_ME: "owned", KEEP_OVERRIDE: "ambient" });
expect(prepared.env).toEqual({ UPDATE_ME: "new", KEEP_OVERRIDE: "ambient" });
const rollback = prepared.publish();
expect(env).toEqual({ UPDATE_ME: "new", KEEP_OVERRIDE: "ambient" });
rollback();
expect(env).toEqual({ UPDATE_ME: "old", REMOVE_ME: "owned", KEEP_OVERRIDE: "ambient" });
});
it("removes the accepted config layer from isolated candidate reads", () => {
const env: NodeJS.ProcessEnv = { OWNED: "old", AMBIENT: "override" };
const base = createConfigRuntimeEnvBase(
{ env: { vars: { OWNED: "old", AMBIENT: "owned" } } },
env,
{ ownedEnv: { OWNED: "old" } },
);
expect(base).toEqual({ AMBIENT: "override" });
expect(env).toEqual({ OWNED: "old", AMBIENT: "override" });
});
it("preserves concurrent env overrides during publication and rollback", () => {
const env: NodeJS.ProcessEnv = { CONFIG_VALUE: "old" };
const prepared = prepareConfigRuntimeEnv({
previousConfig: { env: { vars: { CONFIG_VALUE: "old" } } },
nextConfig: { env: { vars: { CONFIG_VALUE: "new", ADDED_VALUE: "added" } } },
env,
previousOwnedEnv: { CONFIG_VALUE: "old" },
});
env.CONFIG_VALUE = "concurrent";
const rollback = prepared.publish();
expect(env).toEqual({ CONFIG_VALUE: "concurrent", ADDED_VALUE: "added" });
env.ADDED_VALUE = "newer";
rollback();
expect(env).toEqual({ CONFIG_VALUE: "concurrent", ADDED_VALUE: "newer" });
});
it("does not infer an equal-valued ambient env entry as config-owned", async () => {
const key = "OPENCLAW_TEST_EQUAL_AMBIENT_ENV";
await withEnvOverride({ [key]: "shared" }, async () => {
try {
const previousConfig = { env: { vars: { [key]: "shared" } } };
initializePublishedConfigRuntimeEnv(previousConfig, { ownedEnv: {} });
const prepared = prepareConfigRuntimeEnv({
previousConfig,
nextConfig: { env: { vars: { [key]: "config-next" } } },
});
expect(prepared.env[key]).toBe("shared");
const rollback = prepared.publish();
expect(process.env[key]).toBe("shared");
rollback();
expect(process.env[key]).toBe("shared");
} finally {
resetPublishedConfigRuntimeEnv();
}
});
});
it("unwinds overlapping same-value publications after both roll back", async () => {
const key = "OPENCLAW_TEST_OVERLAPPING_ENV";
await withEnvOverride({ [key]: "old" }, async () => {
try {
const previousConfig = { env: { vars: { [key]: "old" } } };
const nextConfig = { env: { vars: { [key]: "new" } } };
initializePublishedConfigRuntimeEnv(previousConfig, {
ownedEnv: { [key]: "old" },
});
const older = prepareConfigRuntimeEnv({ previousConfig, nextConfig });
const newer = prepareConfigRuntimeEnv({ previousConfig, nextConfig });
const rollbackOlder = older.publish();
const rollbackNewer = newer.publish();
expect(process.env[key]).toBe("new");
rollbackOlder();
expect(process.env[key]).toBe("new");
rollbackNewer();
expect(process.env[key]).toBe("old");
expect(getPublishedConfigRuntimeEnvState()).toMatchObject({
ownedEnv: { [key]: "old" },
sourceConfig: previousConfig,
});
} finally {
resetPublishedConfigRuntimeEnv();
}
});
});
it.each(["older-first", "newer-first"] as const)(
"unwinds different-value publications in %s rollback order",
async (rollbackOrder) => {
const key = "OPENCLAW_TEST_OVERLAPPING_DIFFERENT_ENV";
await withEnvOverride({ [key]: "old" }, async () => {
try {
const previousConfig = { env: { vars: { [key]: "old" } } };
const olderConfig = { env: { vars: { [key]: "older" } } };
const newerConfig = { env: { vars: { [key]: "newer" } } };
initializePublishedConfigRuntimeEnv(previousConfig, {
ownedEnv: { [key]: "old" },
});
const older = prepareConfigRuntimeEnv({
previousConfig,
nextConfig: olderConfig,
});
const newer = prepareConfigRuntimeEnv({
previousConfig,
nextConfig: newerConfig,
});
const rollbackOlder = older.publish();
const rollbackNewer = newer.publish();
expect(process.env[key]).toBe("newer");
if (rollbackOrder === "older-first") {
rollbackOlder();
expect(process.env[key]).toBe("newer");
rollbackNewer();
} else {
rollbackNewer();
expect(process.env[key]).toBe("older");
rollbackOlder();
}
expect(process.env[key]).toBe("old");
expect(getPublishedConfigRuntimeEnvState()).toMatchObject({
ownedEnv: { [key]: "old" },
sourceConfig: previousConfig,
});
} finally {
resetPublishedConfigRuntimeEnv();
}
});
},
);
it("lets a newer committed publication supersede an older late rollback", async () => {
const key = "OPENCLAW_TEST_COMMITTED_OVERLAPPING_ENV";
await withEnvOverride({ [key]: "old" }, async () => {
try {
const previousConfig = { env: { vars: { [key]: "old" } } };
const olderConfig = { env: { vars: { [key]: "older" } } };
const newerConfig = { env: { vars: { [key]: "newer" } } };
initializePublishedConfigRuntimeEnv(previousConfig, {
ownedEnv: { [key]: "old" },
});
const older = prepareConfigRuntimeEnv({ previousConfig, nextConfig: olderConfig });
const newer = prepareConfigRuntimeEnv({ previousConfig, nextConfig: newerConfig });
const rollbackOlder = older.publish();
const committedNewer = newer.publish();
committedNewer.commit();
rollbackOlder();
expect(process.env[key]).toBe("newer");
expect(getPublishedConfigRuntimeEnvState()).toMatchObject({
ownedEnv: { [key]: "newer" },
sourceConfig: newerConfig,
});
} finally {
resetPublishedConfigRuntimeEnv();
}
});
});
it("lets a newer publication remove a key added by an overlapping predecessor", async () => {
const key = "OPENCLAW_TEST_OVERLAPPING_REMOVED_ENV";
await withEnvOverride({ [key]: undefined }, async () => {
try {
const previousConfig = {};
const addedConfig = { env: { vars: { [key]: "added" } } };
initializePublishedConfigRuntimeEnv(previousConfig);
const added = prepareConfigRuntimeEnv({ previousConfig, nextConfig: addedConfig });
const removed = prepareConfigRuntimeEnv({ previousConfig, nextConfig: previousConfig });
const rollbackAdded = added.publish();
const committedRemoval = removed.publish();
expect(process.env[key]).toBeUndefined();
committedRemoval.commit();
rollbackAdded();
expect(process.env[key]).toBeUndefined();
} finally {
resetPublishedConfigRuntimeEnv();
}
});
});
it("rejects process-stable Gateway selector changes during reload", () => {
expect(() =>
assertGatewayConfigEnvSelectionUnchanged(
{},
{ env: { vars: { OPENCLAW_CONFIG_PATH: "/tmp/other.json" } } },
),
).toThrow("process-stable Gateway selector OPENCLAW_CONFIG_PATH");
});
it("preserves Windows case-insensitive env precedence in merged runtime env", () => {
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
try {
@@ -149,6 +372,82 @@ describe("config env vars", () => {
}
});
it("restores the original Windows env spelling after a case-only publication rename", () => {
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
try {
const env: NodeJS.ProcessEnv = { Config_Value: "old" };
const prepared = prepareConfigRuntimeEnv({
previousConfig: { env: { vars: { Config_Value: "old" } } },
nextConfig: { env: { vars: { CONFIG_VALUE: "old" } } },
env,
previousOwnedEnv: { Config_Value: "old" },
});
const rollback = prepared.publish();
expect(env).toEqual({ CONFIG_VALUE: "old" });
rollback();
expect(env).toEqual({ Config_Value: "old" });
} finally {
platformSpy.mockRestore();
}
});
it("preserves a concurrent Windows case-only rename when rollback is rejected", () => {
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
try {
const env: NodeJS.ProcessEnv = { Config_Value: "old" };
const prepared = prepareConfigRuntimeEnv({
previousConfig: { env: { vars: { Config_Value: "old" } } },
nextConfig: { env: { vars: { CONFIG_VALUE: "new" } } },
env,
previousOwnedEnv: { Config_Value: "old" },
});
const rollback = prepared.publish();
delete env.CONFIG_VALUE;
env.config_value = "new";
rollback();
expect(env).toEqual({ config_value: "new" });
} finally {
platformSpy.mockRestore();
}
});
it("does not adopt a concurrent Windows case-only rename as config-owned", () => {
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
const originalKey = "OpenClaw_Test_Windows_Owned_Case";
const concurrentKey = originalKey.toLowerCase();
const config = { env: { vars: { [originalKey]: "owned" } } };
try {
delete process.env[originalKey];
delete process.env[concurrentKey];
process.env[originalKey] = "owned";
initializePublishedConfigRuntimeEnv(config, {
ownedEnv: { [originalKey]: "owned" },
});
const unchanged = prepareConfigRuntimeEnv({ previousConfig: config, nextConfig: config });
delete process.env[originalKey];
process.env[concurrentKey] = "owned";
const unchangedPublication = unchanged.publish();
unchangedPublication.commit();
const removalPublication = prepareConfigRuntimeEnv({
previousConfig: config,
nextConfig: {},
}).publish();
removalPublication.commit();
expect(process.env[concurrentKey]).toBe("owned");
} finally {
resetPublishedConfigRuntimeEnv();
delete process.env[originalKey];
delete process.env[concurrentKey];
platformSpy.mockRestore();
}
});
it("blocks dangerous startup env vars from config env", async () => {
await withEnvOverride(
{
@@ -339,6 +638,33 @@ describe("config env vars", () => {
});
});
it("tracks an equal lower-precedence replacement as owned across reload", () => {
const key = "OPENROUTER_API_KEY";
const previousConfig = { env: { vars: { [key]: "shared" } } };
const nextConfig = { env: { vars: { [key]: "next" } } };
const env: NodeJS.ProcessEnv = { [key]: "shared" };
const before = { ...env };
const replacedLowerPrecedenceKeys: string[] = [];
applyConfigEnvVars(previousConfig, env, {
lowerPrecedenceEnv: { [key]: "shared" },
onLowerPrecedenceKeysReplaced: (keys) => replacedLowerPrecedenceKeys.push(...keys),
});
const ownedEnv = collectConfigRuntimeEnvOwnership(previousConfig, before, env, {
replacedLowerPrecedenceKeys,
});
const prepared = prepareConfigRuntimeEnv({
previousConfig,
nextConfig,
env,
previousOwnedEnv: ownedEnv,
});
expect(replacedLowerPrecedenceKeys).toEqual([key]);
expect(ownedEnv).toEqual({ [key]: "shared" });
expect(prepared.env[key]).toBe("next");
});
it("lets config service env vars override state-dir .env vars", async () => {
await withTempHome(async (_home) => {
await writeStateDirDotEnv("MY_KEY=from-dotenv\n", {
+46
View File
@@ -0,0 +1,46 @@
import { collectConfigRuntimeEnvVars } from "./env-vars.js";
import type { OpenClawConfig } from "./types.js";
export const GATEWAY_CONFIG_SELECTION_ENV_KEYS: ReadonlySet<string> = new Set([
"ANDROID_DATA",
"HOME",
"HOMEDRIVE",
"HOMEPATH",
"OPENCLAW_AGENT_DIR",
"OPENCLAW_CONFIG_PATH",
"OPENCLAW_HOME",
"OPENCLAW_INCLUDE_ROOTS",
"OPENCLAW_NIX_MODE",
"OPENCLAW_OAUTH_DIR",
"OPENCLAW_PACKAGE_DIR",
"OPENCLAW_PROFILE",
"OPENCLAW_STATE_DIR",
"OPENCLAW_TEST_FAST",
"OPENCLAW_WORKSPACE_DIR",
"PI_CODING_AGENT_DIR",
"PREFIX",
"USERPROFILE",
]);
/** Rejects config.env changes that would retarget a running Gateway process. */
export function assertGatewayConfigEnvSelectionUnchanged(
previousConfig: OpenClawConfig,
nextConfig: OpenClawConfig,
): void {
const normalize = (config: OpenClawConfig) =>
new Map(
Object.entries(collectConfigRuntimeEnvVars(config)).map(([key, value]) => [
key.toUpperCase(),
value,
]),
);
const previous = normalize(previousConfig);
const next = normalize(nextConfig);
for (const key of GATEWAY_CONFIG_SELECTION_ENV_KEYS) {
if (previous.get(key) !== next.get(key)) {
throw new Error(
`Config env cannot change process-stable Gateway selector ${key} during reload. Restart with the target environment instead.`,
);
}
}
}
+195 -26
View File
@@ -37,13 +37,19 @@ import { isRecord } from "../utils.js";
import { VERSION } from "../version.js";
import { DuplicateAgentDirError, findDuplicateAgentDirs } from "./agent-dirs.js";
import { maintainConfigBackups } from "./backup-rotation.js";
import {
applyConfigEnvVars,
cloneEnvWithPlatformSemantics,
createConfigRuntimeEnvBase,
getPublishedConfigRuntimeEnvState,
} from "./config-env-vars.js";
import { EnvRefArrayMutationError, restoreEnvVarRefs } from "./env-preserve.js";
import {
type EnvSubstitutionWarning,
containsEnvVarReference,
resolveConfigEnvVars,
} from "./env-substitution.js";
import { applyConfigEnvVars, cloneEnvWithPlatformSemantics } from "./env-vars.js";
import { GATEWAY_CONFIG_SELECTION_ENV_KEYS } from "./gateway-env-selection.js";
import {
ConfigIncludeError,
hashConfigIncludeRaw,
@@ -108,13 +114,16 @@ import {
clearRuntimeConfigSnapshot as clearRuntimeConfigSnapshotState,
createRuntimeConfigWriteNotification,
finalizeRuntimeSnapshotWrite,
hasManagedRuntimeConfigWriteOwner,
getRuntimeConfigSnapshotMetadata as getRuntimeConfigSnapshotMetadataState,
getRuntimeConfigSnapshot as getRuntimeConfigSnapshotState,
getRuntimeConfigSourceSnapshot as getRuntimeConfigSourceSnapshotState,
loadPinnedRuntimeConfig,
notifyRuntimeConfigWriteListeners,
preflightRuntimeSnapshotWrite,
preflightManagedRuntimeConfigWrite,
registerRuntimeConfigWriteListener,
registerManagedRuntimeConfigWriteOwner,
resetConfigRuntimeState as resetConfigRuntimeStateState,
resolveRuntimeConfigCacheKey,
selectApplicableRuntimeConfig,
@@ -123,6 +132,7 @@ import {
setRuntimeConfigSnapshotRefreshHandler as setRuntimeConfigSnapshotRefreshHandlerState,
type ConfigWriteAfterWrite,
type RuntimeConfigSnapshotRefreshOptions,
type RuntimeConfigWritePreparedCandidate,
type RuntimeConfigWriteNotification,
} from "./runtime-snapshot.js";
export { projectConfigOntoRuntimeSourceSnapshot } from "./runtime-source-projection.js";
@@ -144,6 +154,7 @@ export {
selectApplicableRuntimeConfig,
setRuntimeConfigSnapshotState as setRuntimeConfigSnapshot,
setRuntimeConfigSnapshotRefreshHandlerState as setRuntimeConfigSnapshotRefreshHandler,
registerManagedRuntimeConfigWriteOwner,
};
// Re-export for backwards compatibility
@@ -1361,6 +1372,34 @@ function snapshotEnv(env: NodeJS.ProcessEnv): Record<string, string | undefined>
return { ...env };
}
function replaceEnvSnapshot(
env: NodeJS.ProcessEnv,
next: Record<string, string | undefined>,
): void {
for (const key of Object.keys(env)) {
delete env[key];
}
Object.assign(env, next);
}
function resolveManagedRuntimeEnvBaseline(): {
generation: number;
sourceConfig: OpenClawConfig;
} {
const published = getPublishedConfigRuntimeEnvState();
return {
generation: published.generation,
sourceConfig:
published.sourceConfig ?? getRuntimeConfigSourceSnapshotState() ?? ({} as OpenClawConfig),
};
}
function createManagedRuntimeEnvBase(): NodeJS.ProcessEnv {
return createConfigRuntimeEnvBase(resolveManagedRuntimeEnvBaseline().sourceConfig, process.env, {
preservedKeys: GATEWAY_CONFIG_SELECTION_ENV_KEYS,
});
}
export function restoreEnvChangesIfUnchanged(params: {
env: NodeJS.ProcessEnv;
before: Record<string, string | undefined>;
@@ -2782,8 +2821,38 @@ export function clearConfigCache(): void {
export function registerConfigWriteListener(
listener: (event: ConfigWriteNotification) => void,
options: {
ownsRuntimeActivationFor?: string;
preCommitRuntimePreflight?: (
sourceConfig: OpenClawConfig,
refreshOptions?: RuntimeConfigSnapshotRefreshOptions,
) => Promise<RuntimeConfigWritePreparedCandidate>;
} = {},
): () => void {
return registerRuntimeConfigWriteListener(listener);
const unregisterOwner = options.ownsRuntimeActivationFor
? registerManagedRuntimeConfigWriteOwner(
options.ownsRuntimeActivationFor,
options.preCommitRuntimePreflight,
)
: undefined;
const unregisterListener = registerRuntimeConfigWriteListener((event) => {
const {
preparedCandidate: _preparedCandidate,
preparedCandidatesByOwner: _preparedCandidatesByOwner,
...baseEvent
} = event;
const preparedCandidate = unregisterOwner
? event.preparedCandidatesByOwner?.get(unregisterOwner.ownerId)
: undefined;
listener({
...baseEvent,
...(preparedCandidate ? { preparedCandidate } : {}),
});
});
return () => {
unregisterListener();
unregisterOwner?.();
};
}
export function loadConfig(options?: {
@@ -2903,13 +2972,33 @@ export async function readSourceConfigSnapshot(): Promise<ConfigFileSnapshot> {
return await readConfigFileSnapshot();
}
/** Reads a reload candidate against the accepted runtime env layer in isolation. */
export async function readConfigFileSnapshotForRuntimeTransaction(
activeSourceConfig: OpenClawConfig,
): Promise<ConfigFileSnapshot> {
return await createConfigIO({
env: createConfigRuntimeEnvBase(activeSourceConfig, process.env, {
preservedKeys: GATEWAY_CONFIG_SELECTION_ENV_KEYS,
}),
}).readConfigFileSnapshot();
}
export async function readConfigFileSnapshotForWrite(options?: {
skipPluginValidation?: boolean;
}): Promise<ReadConfigFileSnapshotForWriteResult> {
const readOptions = options?.skipPluginValidation ? { pluginValidation: "skip" as const } : {};
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
const result = await createConfigIO(readOptions).readConfigFileSnapshotForWrite();
const processIo = createConfigIO(readOptions);
// The Gateway owns runtime activation for managed writes. Their source
// read must not leak config.env into the process before that transaction accepts.
const io = hasManagedRuntimeConfigWriteOwner(processIo.configPath)
? createConfigIO({
...readOptions,
env: createManagedRuntimeEnvBase(),
})
: processIo;
const result = await io.readConfigFileSnapshotForWrite();
result.writeOptions.assertConfigPathForWrite?.();
return result;
} catch (error) {
@@ -2930,13 +3019,23 @@ export async function writeConfigFile(
options: ConfigWriteOptions = {},
): Promise<ConfigWriteResult> {
options.assertConfigPathForWrite?.();
const io = createConfigIO({
const ioOptions = {
...(options.ownedConfigPathForWrite ? { configPath: options.ownedConfigPathForWrite } : {}),
...(options.skipPluginValidation ? { pluginValidation: "skip" as const } : {}),
...(options.preservedLegacyRootKeys
? { preservedLegacyRootKeys: options.preservedLegacyRootKeys }
: {}),
});
};
const processIo = createConfigIO(ioOptions);
const deferRuntimeActivation = hasManagedRuntimeConfigWriteOwner(processIo.configPath);
// Managed writes stage every read in an isolated environment. The reloader
// publishes config.env only after the candidate reaches its acceptance edge.
const io = deferRuntimeActivation
? createConfigIO({
...ioOptions,
env: createManagedRuntimeEnvBase(),
})
: processIo;
assertConfigWriteAllowedInCurrentMode({ configPath: io.configPath });
let nextCfg = cfg;
const runtimeConfigSnapshot = getRuntimeConfigSnapshotState();
@@ -2954,7 +3053,13 @@ export async function writeConfigFile(
}
: await io.readConfigFileSnapshotWithPluginMetadata();
const baseSnapshot = baseSnapshotRead.snapshot;
if (deferRuntimeActivation) {
// The base read applied the accepted config layer to its isolated env.
// Reset before resolving the candidate so old config values cannot win.
replaceEnvSnapshot(io.env, createManagedRuntimeEnvBase());
}
let runtimePreflightResult: unknown;
let managedPreparedCandidates = new Map<symbol, RuntimeConfigWritePreparedCandidate>();
const writeResult = await io.writeConfigFile(nextCfg, {
baseSnapshot,
basePluginMetadataSnapshot: baseSnapshotRead.pluginMetadataSnapshot,
@@ -2978,16 +3083,24 @@ export async function writeConfigFile(
preservedLegacyRootKeys: options.preservedLegacyRootKeys,
lastTouchedVersionOverride: options.lastTouchedVersionOverride,
preCommitRuntimePreflight: async (sourceConfig) => {
runtimePreflightResult = await preflightRuntimeSnapshotWrite({
nextSourceConfig: sourceConfig,
refreshOptions: options.runtimeRefresh,
formatRefreshError: (error) => formatErrorMessage(error),
createRefreshError: (detail, cause) =>
new ConfigRuntimeRefreshError(
`Config write blocked before committing ${io.configPath}: active SecretRef resolution failed: ${detail}`,
{ cause },
),
});
if (deferRuntimeActivation) {
managedPreparedCandidates = await preflightManagedRuntimeConfigWrite(
io.configPath,
sourceConfig,
options.runtimeRefresh,
);
} else {
runtimePreflightResult = await preflightRuntimeSnapshotWrite({
nextSourceConfig: sourceConfig,
refreshOptions: options.runtimeRefresh,
formatRefreshError: (error) => formatErrorMessage(error),
createRefreshError: (detail, cause) =>
new ConfigRuntimeRefreshError(
`Config write blocked before committing ${io.configPath}: active SecretRef resolution failed: ${detail}`,
{ cause },
),
});
}
// Callers may bind a privileged mutation to external authority that can
// change while validation runs. Keep that check after the runtime
// preflight so it is the final async gate before the atomic write.
@@ -3001,6 +3114,9 @@ export async function writeConfigFile(
) {
return writeResult;
}
if (deferRuntimeActivation) {
replaceEnvSnapshot(io.env, createManagedRuntimeEnvBase());
}
// Re-read the freshly persisted file so the sourceConfig we publish matches
// exactly what readConfigFileSnapshot() will produce when the file-watcher
// path next picks up an external edit. Without this, the in-process write
@@ -3014,37 +3130,89 @@ export async function writeConfigFile(
// triggering a `plugins`-scoped restart of the gateway for changes that
// never touched any plugin entry.
let canonicalSourceConfig: OpenClawConfig = nextCfg;
const envBeforeCanonicalRead = snapshotEnv(process.env);
let canonicalRuntimeConfig: OpenClawConfig = nextCfg;
let envBeforeCanonicalRead = snapshotEnv(io.env);
let envAfterCanonicalRead;
let canonicalReadFailure: ConfigRuntimeRefreshError | null = null;
try {
const freshSnapshot = await io.readConfigFileSnapshot();
if (freshSnapshot.exists && freshSnapshot.valid) {
canonicalSourceConfig = freshSnapshot.sourceConfig;
let stableEnvGeneration = !deferRuntimeActivation;
for (let attempt = 0; attempt < 3; attempt += 1) {
const baseline = resolveManagedRuntimeEnvBaseline();
if (deferRuntimeActivation) {
replaceEnvSnapshot(
io.env,
createConfigRuntimeEnvBase(baseline.sourceConfig, process.env, {
preservedKeys: GATEWAY_CONFIG_SELECTION_ENV_KEYS,
}),
);
envBeforeCanonicalRead = snapshotEnv(io.env);
}
const freshSnapshot = await io.readConfigFileSnapshot();
if (freshSnapshot.exists && freshSnapshot.valid) {
canonicalSourceConfig = freshSnapshot.sourceConfig;
canonicalRuntimeConfig = freshSnapshot.config;
}
if (
!deferRuntimeActivation ||
getPublishedConfigRuntimeEnvState().generation === baseline.generation
) {
stableEnvGeneration = true;
break;
}
}
} catch {
// Best-effort; fall back to nextCfg so a transient read failure does not
// block the write notification.
if (!stableEnvGeneration) {
canonicalReadFailure = new ConfigRuntimeRefreshError(
`Config was written to ${io.configPath}, but the active config environment changed during every canonical reread`,
);
}
} catch (error) {
canonicalReadFailure = new ConfigRuntimeRefreshError(
`Config was written to ${io.configPath}, but the canonical reread failed: ${formatErrorMessage(error)}`,
{ cause: error },
);
} finally {
envAfterCanonicalRead = snapshotEnv(process.env);
envAfterCanonicalRead = snapshotEnv(io.env);
}
const notifyCommittedWrite = () => {
const currentRuntimeConfig = getRuntimeConfigSnapshotState();
if (!currentRuntimeConfig) {
const notificationRuntimeConfig = deferRuntimeActivation
? canonicalRuntimeConfig
: currentRuntimeConfig;
if (!notificationRuntimeConfig) {
return;
}
const notificationPreparedCandidates = new Map(
[...managedPreparedCandidates].map(([ownerId, candidate]) => [
ownerId,
{
...candidate,
runtimeConfig:
candidate.reapplyRuntimeOverlays?.(canonicalRuntimeConfig) ?? candidate.runtimeConfig,
compareConfig:
candidate.reapplyCompareOverlays?.(canonicalSourceConfig) ?? candidate.compareConfig,
},
]),
);
notifyRuntimeConfigWriteListeners(
createRuntimeConfigWriteNotification({
configPath: io.configPath,
sourceConfig: canonicalSourceConfig,
runtimeConfig: currentRuntimeConfig,
runtimeConfig: notificationRuntimeConfig,
persistedHash: writeResult.persistedHash,
afterWrite: options.afterWrite,
runtimeRefresh: options.runtimeRefresh,
...(notificationPreparedCandidates.size > 0
? { preparedCandidatesByOwner: notificationPreparedCandidates }
: {}),
}),
);
};
// Keep the last-known-good runtime snapshot active until the specialized refresh path
// succeeds, so concurrent readers do not observe unresolved SecretRefs mid-refresh.
try {
if (canonicalReadFailure) {
throw canonicalReadFailure;
}
options.assertConfigPathForWrite?.();
await finalizeRuntimeSnapshotWrite({
nextSourceConfig: canonicalSourceConfig,
@@ -3055,6 +3223,7 @@ export async function writeConfigFile(
notifyCommittedWrite,
formatRefreshError: (error) => formatErrorMessage(error),
preflightResult: runtimePreflightResult,
deferRuntimeActivation,
createRefreshError: (detail, cause) =>
new ConfigRuntimeRefreshError(
`Config was written to ${io.configPath}, but runtime snapshot refresh failed: ${detail}`,
@@ -3071,7 +3240,7 @@ export async function writeConfigFile(
});
if (rolledBackConfig) {
restoreEnvChangesIfUnchanged({
env: process.env,
env: io.env,
before: envBeforeCanonicalRead,
after: envAfterCanonicalRead,
});
+242
View File
@@ -14,11 +14,13 @@ import {
} from "../state/openclaw-state-db.js";
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
import { withEnvAsync } from "../test-utils/env.js";
import { initializePublishedConfigRuntimeEnv, prepareConfigRuntimeEnv } from "./config-env-vars.js";
import { hashConfigIncludeRaw } from "./includes.js";
import {
createConfigIO as createObservedConfigIO,
getRuntimeConfigSourceSnapshot,
readConfigFileSnapshotForWrite,
readConfigFileSnapshotForRuntimeTransaction,
registerConfigWriteListener,
resetConfigRuntimeState,
setRuntimeConfigSnapshot,
@@ -2568,6 +2570,201 @@ describe("config io write", () => {
});
});
it("preserves auth-store refresh scope through managed preflight and notification", async () => {
await withSuiteHome(async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");
await fs.mkdir(path.dirname(configPath), { recursive: true });
const initialConfig = {
gateway: { mode: "local" as const },
logging: { level: "info" as const },
} satisfies OpenClawConfig;
await fs.writeFile(configPath, `${JSON.stringify(initialConfig, null, 2)}\n`, "utf-8");
const preflight = vi.fn(
async (
sourceConfig: OpenClawConfig,
refreshOptions?: { includeAuthStoreRefs?: boolean },
) => ({
runtimeConfig: sourceConfig,
compareConfig: sourceConfig,
refreshOptions,
}),
);
const notifications: Array<{ includeAuthStoreRefs?: boolean } | undefined> = [];
const unsubscribe = registerConfigWriteListener(
(event) => notifications.push(event.runtimeRefresh),
{
ownsRuntimeActivationFor: configPath,
preCommitRuntimePreflight: preflight,
},
);
try {
await withEnvAsync({ OPENCLAW_CONFIG_PATH: configPath }, async () => {
setRuntimeConfigSnapshot(initialConfig, initialConfig);
await writeConfigFile(
{ ...initialConfig, logging: { level: "debug" } },
{ runtimeRefresh: { includeAuthStoreRefs: false } },
);
});
} finally {
unsubscribe();
}
expect(preflight).toHaveBeenCalledWith(expect.any(Object), {
includeAuthStoreRefs: false,
});
expect(notifications).toEqual([{ includeAuthStoreRefs: false }]);
});
});
it("stages managed root-write config env until the owner accepts it", async () => {
await withSuiteHome(async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");
const envKey = "OPENCLAW_TEST_MANAGED_ROOT_ENV";
const initialAuthoredConfig = {
gateway: {
mode: "local" as const,
auth: { mode: "token" as const, token: "${OPENCLAW_TEST_MANAGED_ROOT_ENV}" },
},
env: { vars: { [envKey]: "old" } },
} satisfies OpenClawConfig;
const initialConfig = {
...initialAuthoredConfig,
gateway: {
...initialAuthoredConfig.gateway,
auth: { mode: "token" as const, token: "old" },
},
} satisfies OpenClawConfig;
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
`${JSON.stringify(initialAuthoredConfig, null, 2)}\n`,
"utf-8",
);
let preparedEnv: NodeJS.ProcessEnv | undefined;
let notifiedSource: OpenClawConfig | undefined;
const unsubscribe = registerConfigWriteListener(
(event) => {
notifiedSource = event.sourceConfig;
},
{
ownsRuntimeActivationFor: configPath,
preCommitRuntimePreflight: async (sourceConfig) => {
const runtimeEnv = prepareConfigRuntimeEnv({
previousConfig: initialConfig,
nextConfig: sourceConfig,
});
preparedEnv = runtimeEnv.env;
return { runtimeConfig: sourceConfig, compareConfig: sourceConfig, runtimeEnv };
},
},
);
try {
await withEnvAsync({ OPENCLAW_CONFIG_PATH: configPath, [envKey]: "old" }, async () => {
setRuntimeConfigSnapshot(initialConfig, initialConfig);
initializePublishedConfigRuntimeEnv(initialConfig, {
ownedEnv: { [envKey]: "old" },
});
await writeConfigFile({
...initialConfig,
env: { vars: { [envKey]: "candidate" } },
});
expect(preparedEnv?.[envKey]).toBe("candidate");
expect(notifiedSource?.gateway?.auth?.token).toBe("candidate");
expect(process.env[envKey]).toBe("old");
});
} finally {
unsubscribe();
}
});
});
it("resolves watcher candidates after removing the accepted config env layer", async () => {
await withSuiteHome(async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");
const envKey = "OPENCLAW_TEST_WATCHER_ENV";
const activeConfig = {
env: { vars: { [envKey]: "old" } },
gateway: { auth: { mode: "token" as const, token: "old" } },
} satisfies OpenClawConfig;
const candidate = {
env: { vars: { [envKey]: "new" } },
gateway: { auth: { mode: "token" as const, token: "${OPENCLAW_TEST_WATCHER_ENV}" } },
} satisfies OpenClawConfig;
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(configPath, `${JSON.stringify(candidate, null, 2)}\n`, "utf-8");
await withEnvAsync({ OPENCLAW_CONFIG_PATH: configPath, [envKey]: "old" }, async () => {
initializePublishedConfigRuntimeEnv(activeConfig, {
ownedEnv: { [envKey]: "old" },
});
const snapshot = await readConfigFileSnapshotForRuntimeTransaction(activeConfig);
expect(snapshot.sourceConfig.gateway?.auth?.token).toBe("new");
expect(process.env[envKey]).toBe("old");
});
});
});
it("rereads a managed write against an env transaction accepted during preflight", async () => {
await withSuiteHome(async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");
const envKey = "OPENCLAW_TEST_INTERLEAVED_WRITE_ENV";
const makeConfig = (value: string, token: string): OpenClawConfig => ({
env: { vars: { [envKey]: value } },
gateway: { mode: "local", auth: { mode: "token", token } },
});
const configA = makeConfig("a", "a");
const authoredA = makeConfig("a", `\${${envKey}}`);
const configB = makeConfig("b", "a");
const configC = makeConfig("c", "c");
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(configPath, `${JSON.stringify(authoredA, null, 2)}\n`, "utf-8");
let notifiedSource: OpenClawConfig | undefined;
const unsubscribe = registerConfigWriteListener(
(event) => {
notifiedSource = event.sourceConfig;
},
{
ownsRuntimeActivationFor: configPath,
preCommitRuntimePreflight: async (sourceConfig) => {
const staleRuntimeEnv = prepareConfigRuntimeEnv({
previousConfig: configA,
nextConfig: sourceConfig,
});
await Promise.resolve();
process.env[envKey] = "c";
initializePublishedConfigRuntimeEnv(configC, {
ownedEnv: { [envKey]: "c" },
});
return {
runtimeConfig: sourceConfig,
compareConfig: sourceConfig,
runtimeEnv: staleRuntimeEnv,
};
},
},
);
try {
await withEnvAsync({ OPENCLAW_CONFIG_PATH: configPath, [envKey]: "a" }, async () => {
setRuntimeConfigSnapshot(configA, configA);
initializePublishedConfigRuntimeEnv(configA, {
ownedEnv: { [envKey]: "a" },
});
await writeConfigFile(configB);
expect(notifiedSource?.gateway?.auth?.token).toBe("b");
expect(process.env[envKey]).toBe("c");
});
} finally {
unsubscribe();
}
});
});
it("rejects ambiguous removals from arrays containing environment references", async () => {
await withSuiteHome(async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");
@@ -3038,6 +3235,51 @@ describe("config io write", () => {
});
});
it("rolls back a managed root write when canonical rereads exhaust env generations", async () => {
await withSuiteHome(async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");
const initialConfig = { gateway: { mode: "local", port: 18789 } } satisfies OpenClawConfig;
const initialRaw = `${JSON.stringify(initialConfig, null, 2)}\n`;
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(configPath, initialRaw, "utf-8");
const readFileSync = fsNode.readFileSync.bind(fsNode);
let generationChanges = 0;
const readSpy = vi.spyOn(fsNode, "readFileSync").mockImplementation((target, options) => {
const result = readFileSync(target, options);
if (String(target) === configPath && String(result).includes("19001")) {
generationChanges += 1;
initializePublishedConfigRuntimeEnv(initialConfig);
}
return result;
});
const unsubscribe = registerConfigWriteListener(() => {}, {
ownsRuntimeActivationFor: configPath,
preCommitRuntimePreflight: async (sourceConfig) => ({
runtimeConfig: sourceConfig,
compareConfig: sourceConfig,
}),
});
try {
await withEnvAsync({ OPENCLAW_CONFIG_PATH: configPath }, async () => {
setRuntimeConfigSnapshot(initialConfig, initialConfig);
initializePublishedConfigRuntimeEnv(initialConfig);
await expect(
writeConfigFile({ gateway: { mode: "local", port: 19001 } }),
).rejects.toThrow("active config environment changed during every canonical reread");
});
} finally {
unsubscribe();
readSpy.mockRestore();
}
expect(generationChanges).toBeGreaterThanOrEqual(3);
await expect(fs.readFile(configPath, "utf-8")).resolves.toBe(initialRaw);
});
});
it("rolls back root writes when canonical reread changes config path ownership", async () => {
await withSuiteHome(async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");
+206 -2
View File
@@ -4,6 +4,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
import { initializePublishedConfigRuntimeEnv, prepareConfigRuntimeEnv } from "./config-env-vars.js";
import { hashConfigIncludeRaw } from "./includes.js";
import type { ConfigWriteOptions } from "./io.js";
import {
@@ -15,7 +16,9 @@ import {
import { resolveConfigPath } from "./paths.js";
import {
registerRuntimeConfigWriteListener,
registerManagedRuntimeConfigWriteOwner,
resetConfigRuntimeState,
setRuntimeConfigSnapshot,
setRuntimeConfigSnapshotRefreshHandler,
} from "./runtime-snapshot.js";
import type { ConfigFileSnapshot, OpenClawConfig } from "./types.js";
@@ -24,11 +27,20 @@ type MockValidationIssue = { path: string; message: string };
type MockValidationResult =
| { ok: true; config: OpenClawConfig; warnings: MockValidationIssue[] }
| { ok: false; issues: MockValidationIssue[]; warnings: MockValidationIssue[] };
type ConfigIOReadForWrite = ReturnType<
typeof import("./io.js").createConfigIO
>["readConfigFileSnapshotForWrite"];
const ioMocks = vi.hoisted(() => {
const readConfigFileSnapshotForWrite = vi.fn();
const readConfigFileSnapshotForWrite = vi.fn<ConfigIOReadForWrite>();
return {
createConfigIO: vi.fn(() => ({ readConfigFileSnapshotForWrite })),
createConfigIO: vi.fn(
(
_options?: Parameters<typeof import("./io.js").createConfigIO>[0],
): { readConfigFileSnapshotForWrite: ConfigIOReadForWrite } => ({
readConfigFileSnapshotForWrite,
}),
),
readConfigFileSnapshotForWrite,
resolveConfigSnapshotHash: vi.fn(),
writeConfigFile: vi.fn(),
@@ -1346,6 +1358,198 @@ describe("config mutate helpers", () => {
}
});
it("preserves auth-store refresh scope for managed top-level include writes", async () => {
const home = await suiteRootTracker.make("include-managed-refresh-scope");
const configPath = path.join(home, ".openclaw", "openclaw.json");
const pluginsPath = path.join(home, ".openclaw", "config", "plugins.json5");
await fs.mkdir(path.dirname(pluginsPath), { recursive: true });
await fs.writeFile(
configPath,
`${JSON.stringify({ plugins: { $include: "./config/plugins.json5" } }, null, 2)}\n`,
"utf-8",
);
await fs.writeFile(pluginsPath, `${JSON.stringify({ entries: {} }, null, 2)}\n`, "utf-8");
const snapshot = createSnapshot({
hash: "hash-include-managed-refresh-scope",
path: configPath,
parsed: { plugins: { $include: "./config/plugins.json5" } },
sourceConfig: { plugins: { entries: {} } },
});
const nextConfig = {
plugins: { entries: { demo: { enabled: true } } },
} satisfies OpenClawConfig;
ioMocks.readConfigFileSnapshotForWrite.mockResolvedValue({
snapshot: createSnapshot({
hash: "hash-include-managed-refresh-scope-written",
path: configPath,
parsed: { plugins: { $include: "./config/plugins.json5" } },
sourceConfig: nextConfig,
}),
writeOptions: { expectedConfigPath: configPath },
});
const preflight = vi.fn(
async (sourceConfig: OpenClawConfig, refreshOptions?: { includeAuthStoreRefs?: boolean }) => {
if (refreshOptions?.includeAuthStoreRefs !== false) {
throw new Error("unavailable auth-profile SecretRef");
}
return { runtimeConfig: sourceConfig, compareConfig: sourceConfig };
},
);
const releaseOwner = registerManagedRuntimeConfigWriteOwner(configPath, preflight);
const notifications: Array<{ includeAuthStoreRefs?: boolean } | undefined> = [];
const releaseListener = registerRuntimeConfigWriteListener((event) => {
if (event.configPath === configPath) {
notifications.push(event.runtimeRefresh);
}
});
try {
await replaceConfigFile({
baseHash: snapshot.hash,
snapshot,
writeOptions: {
expectedConfigPath: snapshot.path,
assertConfigPathForWrite: allowConfigPathWrite,
includeFileTargetsForWrite: {
[pluginsPath]: await resolveIncludeTarget(pluginsPath),
},
runtimeRefresh: { includeAuthStoreRefs: false },
},
nextConfig,
});
} finally {
releaseListener();
releaseOwner();
}
expect(preflight).toHaveBeenCalledWith(expect.any(Object), {
includeAuthStoreRefs: false,
});
expect(notifications).toEqual([{ includeAuthStoreRefs: false }]);
const persisted = JSON.parse(
await fs.readFile(pluginsPath, "utf-8"),
) as OpenClawConfig["plugins"];
expect(persisted?.entries?.demo?.enabled).toBe(true);
});
it("uses the published restart env source for isolated managed include writes", async () => {
const home = await suiteRootTracker.make("include-managed-deferred-restart-env");
const configPath = path.join(home, ".openclaw", "openclaw.json");
const envPath = path.join(home, ".openclaw", "config", "env.json5");
const envKey = "OC";
await fs.mkdir(path.dirname(envPath), { recursive: true });
await fs.writeFile(
configPath,
`${JSON.stringify(
{
env: { $include: "./config/env.json5" },
gateway: { auth: { mode: "token", token: "${OC}" } },
},
null,
2,
)}\n`,
"utf-8",
);
await fs.writeFile(
envPath,
`${JSON.stringify({ vars: { [envKey]: "live" } }, null, 2)}\n`,
"utf-8",
);
const initialConfig = {
env: { vars: { [envKey]: "old" } },
gateway: { auth: { mode: "token" as const, token: "old" } },
} satisfies OpenClawConfig;
const acceptedRestartConfig = {
env: { vars: { [envKey]: "live" } },
gateway: { auth: { mode: "token" as const, token: "live" } },
} satisfies OpenClawConfig;
const nextConfig = {
env: { vars: { [envKey]: "next" } },
gateway: { auth: { mode: "token" as const, token: "live" } },
} satisfies OpenClawConfig;
const snapshot = createSnapshot({
hash: "hash-include-managed-deferred-restart-env",
path: configPath,
parsed: {
env: { $include: "./config/env.json5" },
gateway: { auth: { mode: "token", token: "${OC}" } },
},
sourceConfig: acceptedRestartConfig,
runtimeConfig: initialConfig,
});
const refreshedSnapshot = createSnapshot({
hash: "hash-include-managed-deferred-restart-env-written",
path: configPath,
parsed: snapshot.parsed,
sourceConfig: {
...nextConfig,
gateway: { auth: { mode: "token", token: "next" } },
},
});
let preflightSource: OpenClawConfig | undefined;
const releaseOwner = registerManagedRuntimeConfigWriteOwner(
configPath,
async (sourceConfig) => {
preflightSource = sourceConfig;
return { runtimeConfig: sourceConfig, compareConfig: sourceConfig };
},
);
const previousEnv = process.env[envKey];
process.env[envKey] = "old";
setRuntimeConfigSnapshot(initialConfig, initialConfig);
initializePublishedConfigRuntimeEnv(initialConfig, {
ownedEnv: { [envKey]: "old" },
});
const rollbackRestartEnv = prepareConfigRuntimeEnv({
previousConfig: initialConfig,
nextConfig: acceptedRestartConfig,
}).publish();
let rereadEnv: NodeJS.ProcessEnv | undefined;
ioMocks.createConfigIO.mockImplementation((options?: { env?: NodeJS.ProcessEnv }) => ({
readConfigFileSnapshotForWrite: async () => {
rereadEnv = options?.env;
expect(rereadEnv?.[envKey]).toBeUndefined();
if (rereadEnv) {
rereadEnv[envKey] = "next";
}
return {
snapshot: refreshedSnapshot,
writeOptions: { expectedConfigPath: configPath },
};
},
}));
try {
await replaceConfigFile({
baseHash: snapshot.hash,
snapshot,
writeOptions: {
expectedConfigPath: snapshot.path,
assertConfigPathForWrite: allowConfigPathWrite,
includeFileTargetsForWrite: { [envPath]: await resolveIncludeTarget(envPath) },
},
nextConfig,
});
expect(rereadEnv).toBeDefined();
expect(rereadEnv).not.toBe(process.env);
expect(rereadEnv?.[envKey]).toBe("next");
expect(preflightSource?.gateway?.auth?.token).toBe("next");
expect(process.env[envKey]).toBe("live");
} finally {
rollbackRestartEnv();
releaseOwner();
ioMocks.createConfigIO.mockImplementation(() => ({
readConfigFileSnapshotForWrite: ioMocks.readConfigFileSnapshotForWrite,
}));
if (previousEnv === undefined) {
delete process.env[envKey];
} else {
process.env[envKey] = previousEnv;
}
}
});
it("does not overwrite concurrent include edits made during preflight", async () => {
const home = await suiteRootTracker.make("include-preflight-concurrent");
const configPath = path.join(home, ".openclaw", "openclaw.json");
+124 -21
View File
@@ -12,8 +12,15 @@ import { isPathInside } from "../security/scan-paths.js";
import { isRecord } from "../utils.js";
import { parseJsonWithJson5Fallback } from "../utils/parse-json-compat.js";
import { maintainConfigBackups } from "./backup-rotation.js";
import {
applyConfigEnvVars,
cloneEnvWithPlatformSemantics,
createConfigRuntimeEnvBase,
getPublishedConfigRuntimeEnvState,
} from "./config-env-vars.js";
import { restoreEnvVarRefs } from "./env-preserve.js";
import { resolveConfigEnvVars } from "./env-substitution.js";
import { GATEWAY_CONFIG_SELECTION_ENV_KEYS } from "./gateway-env-selection.js";
import {
ConfigIncludeError,
hashConfigIncludeRaw,
@@ -41,15 +48,18 @@ import { resolveConfigPath } from "./paths.js";
import {
createRuntimeConfigWriteNotification,
finalizeRuntimeSnapshotWrite,
hasManagedRuntimeConfigWriteOwner,
getRuntimeConfigSnapshot,
getRuntimeConfigSnapshotRefreshHandler,
getRuntimeConfigSourceSnapshot,
notifyRuntimeConfigWriteListeners,
preflightManagedRuntimeConfigWrite,
preflightRuntimeSnapshotWrite,
resolveConfigWriteAfterWrite,
resolveConfigWriteFollowUp,
type ConfigWriteAfterWrite,
type ConfigWriteFollowUp,
type RuntimeConfigWritePreparedCandidate,
} from "./runtime-snapshot.js";
import type { ConfigFileSnapshot, OpenClawConfig } from "./types.js";
import { validateConfigObjectWithPlugins } from "./validation.js";
@@ -152,6 +162,28 @@ type ConfigMutationOwnership = {
assertConfigPathForWrite?: () => void;
};
function resolveManagedRuntimeEnvBaseline(): {
generation: number;
sourceConfig: OpenClawConfig;
} {
// Accepted restart candidates publish env before the runtime snapshot advances.
// Managed writes must stay on that publication generation to avoid mixed env refs.
const published = getPublishedConfigRuntimeEnvState();
return {
generation: published.generation,
sourceConfig: published.sourceConfig ?? getRuntimeConfigSourceSnapshot() ?? {},
};
}
function assertManagedRuntimeEnvGeneration(generation: number): void {
if (getPublishedConfigRuntimeEnvState().generation !== generation) {
throw new ConfigMutationConflictError(
"active config environment changed while preparing write",
{ currentHash: null },
);
}
}
function assertBaseHashMatches(snapshot: ConfigFileSnapshot, expectedHash?: string): string | null {
const currentHash = resolveConfigSnapshotHash(snapshot) ?? null;
if (expectedHash !== undefined && expectedHash !== currentHash) {
@@ -216,10 +248,23 @@ async function readConfigSnapshotForMutation(params: {
return await params.io.readConfigFileSnapshotForWrite(options);
}
if (params.ownedConfigPathForWrite) {
return await createConfigIO({
const ioOptions = {
configPath: params.ownedConfigPathForWrite,
...(params.writeOptions?.skipPluginValidation ? { pluginValidation: "skip" as const } : {}),
}).readConfigFileSnapshotForWrite();
};
const io = hasManagedRuntimeConfigWriteOwner(params.ownedConfigPathForWrite)
? createConfigIO({
...ioOptions,
env: createConfigRuntimeEnvBase(
resolveManagedRuntimeEnvBaseline().sourceConfig,
process.env,
{
preservedKeys: GATEWAY_CONFIG_SELECTION_ENV_KEYS,
},
),
})
: createConfigIO(ioOptions);
return await io.readConfigFileSnapshotForWrite();
}
return await readConfigFileSnapshotForWrite(options);
}
@@ -670,10 +715,29 @@ async function tryWriteSingleTopLevelIncludeMutation(params: {
);
}
}
const runtimeConfigToWrite = {
...nextConfig,
[key]: resolveConfigEnvVars(includedValueToWrite, writeEnv, { onMissing: () => {} }),
} as OpenClawConfig;
const deferRuntimeActivation = hasManagedRuntimeConfigWriteOwner(params.snapshot.path);
const runtimeEnvBaseline = deferRuntimeActivation
? resolveManagedRuntimeEnvBaseline()
: undefined;
const runtimeCandidateEnv = runtimeEnvBaseline
? createConfigRuntimeEnvBase(runtimeEnvBaseline.sourceConfig, process.env, {
preservedKeys: GATEWAY_CONFIG_SELECTION_ENV_KEYS,
})
: cloneEnvWithPlatformSemantics(writeEnv);
const authoredRuntimeCandidate = restoreEnvVarRefs(
nextConfig,
params.snapshot.parsed,
envForRestore,
) as OpenClawConfig;
applyConfigEnvVars(authoredRuntimeCandidate, runtimeCandidateEnv);
const runtimeConfigToWrite = resolveConfigEnvVars(
{
...authoredRuntimeCandidate,
[key]: includedValueToWrite,
},
runtimeCandidateEnv,
{ onMissing: () => {} },
) as OpenClawConfig;
const validated = validateConfigObjectWithPlugins(
runtimeConfigToWrite,
params.writeOptions?.skipPluginValidation ? { pluginValidation: "skip" } : undefined,
@@ -689,16 +753,27 @@ async function tryWriteSingleTopLevelIncludeMutation(params: {
const runtimeConfigSourceSnapshot = getRuntimeConfigSourceSnapshot();
const hadRuntimeSnapshot = Boolean(runtimeConfigSnapshot);
const hadBothSnapshots = Boolean(runtimeConfigSnapshot && runtimeConfigSourceSnapshot);
const runtimePreflightResult = await preflightRuntimeSnapshotWrite({
nextSourceConfig: runtimeConfigToWrite,
refreshOptions: params.writeOptions?.runtimeRefresh,
formatRefreshError: (error) => formatErrorMessage(error),
createRefreshError: (detail, cause) =>
new Error(
`Config write blocked before committing ${includePath}: active SecretRef resolution failed: ${detail}`,
{ cause },
),
});
let managedPreparedCandidates = new Map<symbol, RuntimeConfigWritePreparedCandidate>();
let runtimePreflightResult: unknown;
if (runtimeEnvBaseline) {
managedPreparedCandidates = await preflightManagedRuntimeConfigWrite(
params.snapshot.path,
runtimeConfigToWrite,
params.writeOptions?.runtimeRefresh,
);
assertManagedRuntimeEnvGeneration(runtimeEnvBaseline.generation);
} else {
runtimePreflightResult = await preflightRuntimeSnapshotWrite({
nextSourceConfig: runtimeConfigToWrite,
refreshOptions: params.writeOptions?.runtimeRefresh,
formatRefreshError: (error) => formatErrorMessage(error),
createRefreshError: (detail, cause) =>
new Error(
`Config write blocked before committing ${includePath}: active SecretRef resolution failed: ${detail}`,
{ cause },
),
});
}
const committedIncludeRaw = formatJsonFileValue(includedValueToWrite);
const committedIncludeHash = hashConfigIncludeRaw(committedIncludeRaw);
const callerPreCommit = params.writeOptions?.preCommitRuntimePreflight;
@@ -719,9 +794,15 @@ async function tryWriteSingleTopLevelIncludeMutation(params: {
expectedRaw: includeRawAtCommit,
rootSnapshot: params.snapshot,
assertConfigPathForWrite,
preCommitRuntimePreflight: callerPreCommit
? () => callerPreCommit(runtimeConfigToWrite)
: undefined,
preCommitRuntimePreflight:
runtimeEnvBaseline || callerPreCommit
? async () => {
if (runtimeEnvBaseline) {
assertManagedRuntimeEnvGeneration(runtimeEnvBaseline.generation);
}
await callerPreCommit?.(runtimeConfigToWrite);
}
: undefined,
});
const envBeforePostWriteRead = { ...writeEnv };
let envAfterPostWriteRead = envBeforePostWriteRead;
@@ -762,16 +843,37 @@ async function tryWriteSingleTopLevelIncludeMutation(params: {
const notifyCommittedWrite = () => {
const currentRuntimeConfig = getRuntimeConfigSnapshot();
if (!currentRuntimeConfig) {
const notificationRuntimeConfig = deferRuntimeActivation
? refreshedSnapshot.runtimeConfig
: currentRuntimeConfig;
if (!notificationRuntimeConfig) {
return;
}
const notificationPreparedCandidates = new Map(
[...managedPreparedCandidates].map(([ownerId, candidate]) => [
ownerId,
{
...candidate,
runtimeConfig:
candidate.reapplyRuntimeOverlays?.(refreshedSnapshot.runtimeConfig) ??
candidate.runtimeConfig,
compareConfig:
candidate.reapplyCompareOverlays?.(refreshedSnapshot.sourceConfig) ??
candidate.compareConfig,
},
]),
);
notifyRuntimeConfigWriteListeners(
createRuntimeConfigWriteNotification({
configPath: params.snapshot.path,
sourceConfig: refreshedSnapshot.sourceConfig,
runtimeConfig: currentRuntimeConfig,
runtimeConfig: notificationRuntimeConfig,
persistedHash,
afterWrite: params.afterWrite ?? params.writeOptions?.afterWrite,
runtimeRefresh: params.writeOptions?.runtimeRefresh,
...(notificationPreparedCandidates.size > 0
? { preparedCandidatesByOwner: notificationPreparedCandidates }
: {}),
}),
);
};
@@ -783,6 +885,7 @@ async function tryWriteSingleTopLevelIncludeMutation(params: {
loadFreshConfig: () => refreshedSnapshot.runtimeConfig,
notifyCommittedWrite,
preflightResult: runtimePreflightResult,
deferRuntimeActivation,
formatRefreshError: (error) => formatErrorMessage(error),
createRefreshError: (detail, cause) =>
new Error(
+10
View File
@@ -2,6 +2,7 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
applyConfigOverrides,
captureConfigOverrideApplier,
getConfigOverrides,
resetConfigOverrides,
setConfigOverride,
@@ -23,6 +24,15 @@ describe("runtime overrides", () => {
expect(next.messages?.responsePrefix).toBe("[debug]");
});
it("captures an immutable override applier", () => {
setConfigOverride("gateway.auth.token", "startup-token");
const applyStartupOverrides = captureConfigOverrideApplier();
setConfigOverride("gateway.auth.token", "later-token");
expect(applyStartupOverrides({}).gateway?.auth?.token).toBe("startup-token");
expect(applyConfigOverrides({}).gateway?.auth?.token).toBe("later-token");
});
it("merges object overrides without clobbering siblings", () => {
const cfg = {
channels: { whatsapp: { dmPolicy: "pairing", allowFrom: ["+1"] } },
+9
View File
@@ -96,3 +96,12 @@ export function applyConfigOverrides(cfg: OpenClawConfig): OpenClawConfig {
}
return mergeOverrides(cfg, overrides) as OpenClawConfig;
}
/** Capture an immutable applier for the process-local overrides active at this instant. */
export function captureConfigOverrideApplier(): (cfg: OpenClawConfig) => OpenClawConfig {
const capturedOverrides = structuredClone(overrides);
if (Object.keys(capturedOverrides).length === 0) {
return (cfg) => cfg;
}
return (cfg) => mergeOverrides(cfg, capturedOverrides) as OpenClawConfig;
}
+68
View File
@@ -2,12 +2,15 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
finalizeRuntimeSnapshotWrite,
hasManagedRuntimeConfigWriteOwner,
getRuntimeConfigSnapshotMetadata,
getRuntimeConfigSourceSnapshot,
getRuntimeConfigSnapshot,
preflightManagedRuntimeConfigWrite,
loadPinnedRuntimeConfig,
notifyRuntimeConfigWriteListeners,
registerRuntimeConfigWriteListener,
registerManagedRuntimeConfigWriteOwner,
resetConfigRuntimeState,
resolveRuntimeConfigCacheKey,
selectApplicableRuntimeConfig,
@@ -328,4 +331,69 @@ describe("runtime snapshot state", () => {
},
]);
});
it("scopes managed write ownership by path and reference count", () => {
const releaseA = registerManagedRuntimeConfigWriteOwner("/tmp/a.json");
const releaseA2 = registerManagedRuntimeConfigWriteOwner("/tmp/a.json");
const releaseB = registerManagedRuntimeConfigWriteOwner("/tmp/b.json");
expect(hasManagedRuntimeConfigWriteOwner("/tmp/a.json")).toBe(true);
expect(hasManagedRuntimeConfigWriteOwner("/tmp/b.json")).toBe(true);
releaseA();
expect(hasManagedRuntimeConfigWriteOwner("/tmp/a.json")).toBe(true);
releaseA2();
releaseA2();
expect(hasManagedRuntimeConfigWriteOwner("/tmp/a.json")).toBe(false);
expect(hasManagedRuntimeConfigWriteOwner("/tmp/b.json")).toBe(true);
releaseB();
});
it("keeps prepared candidates scoped to each managed owner", async () => {
const runtimeConfigA: OpenClawConfig = { gateway: { port: 19001 } };
const runtimeConfigB: OpenClawConfig = { gateway: { port: 19002 } };
const candidateA = { runtimeConfig: runtimeConfigA, compareConfig: {} };
const candidateB = { runtimeConfig: runtimeConfigB, compareConfig: {} };
const releaseA = registerManagedRuntimeConfigWriteOwner(
"/tmp/scoped.json",
async () => candidateA,
);
const releaseB = registerManagedRuntimeConfigWriteOwner(
"/tmp/scoped.json",
async () => candidateB,
);
try {
const prepared = await preflightManagedRuntimeConfigWrite("/tmp/scoped.json", {});
expect(prepared.get(releaseA.ownerId)).toBe(candidateA);
expect(prepared.get(releaseB.ownerId)).toBe(candidateB);
} finally {
releaseA();
releaseB();
}
});
it("defers raw runtime activation to a managed write owner", async () => {
const activeConfig: OpenClawConfig = { gateway: { port: 18789 } };
setRuntimeConfigSnapshot(activeConfig);
const notifyCommittedWrite = vi.fn();
const refresh = vi.fn(async () => true);
const loadFreshConfig = vi.fn(() => ({ gateway: { port: 19001 } }));
setRuntimeConfigSnapshotRefreshHandler({ refresh });
await finalizeRuntimeSnapshotWrite({
nextSourceConfig: { gateway: { port: 19001 } },
hadRuntimeSnapshot: true,
hadBothSnapshots: false,
loadFreshConfig,
notifyCommittedWrite,
deferRuntimeActivation: true,
formatRefreshError: (error) => String(error),
createRefreshError: (detail, cause) => new Error(detail, { cause }),
});
expect(getRuntimeConfigSnapshot()).toBe(activeConfig);
expect(refresh).not.toHaveBeenCalled();
expect(loadFreshConfig).not.toHaveBeenCalled();
expect(notifyCommittedWrite).toHaveBeenCalledOnce();
});
});
+100
View File
@@ -1,5 +1,9 @@
// Produces redacted runtime config snapshots for diagnostics and UI surfaces.
import { sha256Base64Url } from "../infra/crypto-digest.js";
import {
resetPublishedConfigRuntimeEnv,
type PreparedConfigRuntimeEnv,
} from "./config-env-vars.js";
import type { OpenClawConfig } from "./types.js";
export type RuntimeConfigSnapshotRefreshOptions = {
@@ -79,6 +83,17 @@ export type RuntimeConfigWriteNotification = {
sourceFingerprint: string | null;
writtenAtMs: number;
afterWrite?: ConfigWriteAfterWrite;
runtimeRefresh?: RuntimeConfigSnapshotRefreshOptions;
preparedCandidate?: RuntimeConfigWritePreparedCandidate;
preparedCandidatesByOwner?: ReadonlyMap<symbol, RuntimeConfigWritePreparedCandidate>;
};
export type RuntimeConfigWritePreparedCandidate = {
runtimeConfig: OpenClawConfig;
compareConfig: OpenClawConfig;
runtimeEnv?: PreparedConfigRuntimeEnv;
reapplyRuntimeOverlays?: (config: OpenClawConfig) => OpenClawConfig;
reapplyCompareOverlays?: (config: OpenClawConfig) => OpenClawConfig;
};
export type RuntimeConfigSnapshotMetadata = {
@@ -93,6 +108,14 @@ let runtimeConfigSourceSnapshot: OpenClawConfig | null = null;
let runtimeConfigSnapshotMetadata: RuntimeConfigSnapshotMetadata | null = null;
let runtimeConfigSnapshotRevision = 0;
let runtimeConfigSnapshotRefreshHandler: RuntimeConfigSnapshotRefreshHandler | null = null;
type ManagedRuntimeConfigWritePreflight = (
sourceConfig: OpenClawConfig,
refreshOptions?: RuntimeConfigSnapshotRefreshOptions,
) => MaybePromise<RuntimeConfigWritePreparedCandidate>;
const managedRuntimeConfigWriteOwners = new Map<
string,
Set<{ id: symbol; preflight?: ManagedRuntimeConfigWritePreflight }>
>();
const runtimeConfigWriteListeners = new Set<(event: RuntimeConfigWriteNotification) => void>();
function stableConfigStringify(value: unknown): string {
@@ -146,11 +169,28 @@ export function setRuntimeConfigSnapshot(
runtimeConfigSnapshotMetadata = createRuntimeConfigSnapshotMetadata(config, sourceConfig);
}
/** Publish a newer canonical source without changing the active runtime object. */
export function setRuntimeConfigSourceSnapshotIfCurrent(params: {
expectedRevision: number;
sourceConfig: OpenClawConfig;
}): boolean {
if (
!runtimeConfigSnapshot ||
!runtimeConfigSnapshotMetadata ||
runtimeConfigSnapshotMetadata.revision !== params.expectedRevision
) {
return false;
}
setRuntimeConfigSnapshot(runtimeConfigSnapshot, params.sourceConfig);
return true;
}
export function resetConfigRuntimeState(): void {
runtimeConfigSnapshot = null;
runtimeConfigSourceSnapshot = null;
runtimeConfigSnapshotMetadata = null;
runtimeConfigSnapshotRevision = 0;
resetPublishedConfigRuntimeEnv();
}
export function clearRuntimeConfigSnapshot(): void {
@@ -184,6 +224,9 @@ export function createRuntimeConfigWriteNotification(params: {
persistedHash: string;
writtenAtMs?: number;
afterWrite?: ConfigWriteAfterWrite;
runtimeRefresh?: RuntimeConfigSnapshotRefreshOptions;
preparedCandidate?: RuntimeConfigWritePreparedCandidate;
preparedCandidatesByOwner?: ReadonlyMap<symbol, RuntimeConfigWritePreparedCandidate>;
}): RuntimeConfigWriteNotification {
const metadata =
params.runtimeConfig === runtimeConfigSnapshot && runtimeConfigSnapshotMetadata
@@ -204,6 +247,11 @@ export function createRuntimeConfigWriteNotification(params: {
sourceFingerprint: metadata.sourceFingerprint,
writtenAtMs: params.writtenAtMs ?? Date.now(),
afterWrite: params.afterWrite,
...(params.runtimeRefresh ? { runtimeRefresh: params.runtimeRefresh } : {}),
...(params.preparedCandidate ? { preparedCandidate: params.preparedCandidate } : {}),
...(params.preparedCandidatesByOwner
? { preparedCandidatesByOwner: params.preparedCandidatesByOwner }
: {}),
};
}
@@ -252,6 +300,53 @@ export function registerRuntimeConfigWriteListener(
};
}
export function registerManagedRuntimeConfigWriteOwner(
configPath: string,
preflight?: ManagedRuntimeConfigWritePreflight,
): (() => void) & { ownerId: symbol } {
const owner = preflight
? { id: Symbol("managed-runtime-config-write-owner"), preflight }
: { id: Symbol("managed-runtime-config-write-owner") };
const owners = managedRuntimeConfigWriteOwners.get(configPath) ?? new Set();
owners.add(owner);
managedRuntimeConfigWriteOwners.set(configPath, owners);
let released = false;
const unregister = () => {
if (released) {
return;
}
released = true;
const currentOwners = managedRuntimeConfigWriteOwners.get(configPath);
currentOwners?.delete(owner);
if (!currentOwners || currentOwners.size === 0) {
managedRuntimeConfigWriteOwners.delete(configPath);
}
};
return Object.assign(unregister, { ownerId: owner.id });
}
export async function preflightManagedRuntimeConfigWrite(
configPath: string,
sourceConfig: OpenClawConfig,
refreshOptions?: RuntimeConfigSnapshotRefreshOptions,
): Promise<Map<symbol, RuntimeConfigWritePreparedCandidate>> {
const owners = managedRuntimeConfigWriteOwners.get(configPath);
if (!owners) {
return new Map();
}
const preparedCandidates = new Map<symbol, RuntimeConfigWritePreparedCandidate>();
for (const owner of owners) {
if (owner.preflight) {
preparedCandidates.set(owner.id, await owner.preflight(sourceConfig, refreshOptions));
}
}
return preparedCandidates;
}
export function hasManagedRuntimeConfigWriteOwner(configPath: string): boolean {
return managedRuntimeConfigWriteOwners.has(configPath);
}
export function notifyRuntimeConfigWriteListeners(event: RuntimeConfigWriteNotification): void {
for (const listener of runtimeConfigWriteListeners) {
try {
@@ -301,7 +396,12 @@ export async function finalizeRuntimeSnapshotWrite(params: {
createRefreshError: (detail: string, cause: unknown) => Error;
formatRefreshError: (error: unknown) => string;
preflightResult?: unknown;
deferRuntimeActivation?: boolean;
}): Promise<void> {
if (params.deferRuntimeActivation) {
params.notifyCommittedWrite();
return;
}
const refreshHandler = getRuntimeConfigSnapshotRefreshHandler();
if (refreshHandler) {
try {
+7 -7
View File
@@ -35,12 +35,12 @@ export type {
} from "./store/types.js";
import type { CronStoreFile } from "./types.js";
function resolveDefaultCronDir(): string {
return path.join(resolveConfigDir(), "cron");
function resolveDefaultCronDir(env: NodeJS.ProcessEnv): string {
return path.join(resolveConfigDir(env), "cron");
}
function resolveDefaultCronStorePath(): string {
return path.join(resolveDefaultCronDir(), "jobs.json");
function resolveDefaultCronStorePath(env: NodeJS.ProcessEnv): string {
return path.join(resolveDefaultCronDir(env), "jobs.json");
}
/** Resolves the sidecar quarantine path used for invalid cron config rows. */
@@ -52,15 +52,15 @@ export function resolveCronQuarantinePath(storePath: string): string {
}
/** Resolves the cron jobs store path, expanding home-relative user input. */
export function resolveCronJobsStorePath(storePath?: string) {
export function resolveCronJobsStorePath(storePath?: string, env: NodeJS.ProcessEnv = process.env) {
if (storePath?.trim()) {
const raw = storePath.trim();
if (raw.startsWith("~")) {
return path.resolve(expandHomePrefix(raw));
return path.resolve(expandHomePrefix(raw, { env }));
}
return path.resolve(raw);
}
return resolveDefaultCronStorePath();
return resolveDefaultCronStorePath(env);
}
/** Loads cron jobs plus config/runtime sidecars from the SQLite-backed store. */
File diff suppressed because it is too large Load Diff
+528 -59
View File
@@ -1,9 +1,11 @@
// Gateway config hot-reload watcher.
// Diffs config/plugin install snapshots and dispatches hot reload or restart plans.
import chokidar from "chokidar";
import type { ConfigRuntimeEnvPublication } from "../config/config-env-vars.js";
import type { ConfigWriteNotification } from "../config/io.js";
import { formatConfigIssueLines } from "../config/issue-format.js";
import { resolveConfigWriteFollowUp } from "../config/runtime-snapshot.js";
import type { RuntimeConfigSnapshotRefreshOptions } from "../config/runtime-snapshot.js";
import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.openclaw.js";
import type { PluginInstallRecord } from "../config/types.plugins.js";
import {
@@ -118,6 +120,42 @@ type GatewayConfigReloader = {
type PluginInstallRecords = Record<string, PluginInstallRecord>;
type InProcessConfigCandidate = {
config: OpenClawConfig;
compareConfig: OpenClawConfig;
persistedHash: string;
afterWrite?: ConfigWriteNotification["afterWrite"];
preparedCandidate?: ConfigWriteNotification["preparedCandidate"];
runtimeRefresh?: RuntimeConfigSnapshotRefreshOptions;
epoch: number;
};
export type GatewayConfigReloadTransactionOwnership = {
isCurrent: () => boolean;
markRuntimeCommitted: (runtimeConfig: OpenClawConfig, plan: GatewayReloadPlan) => void;
commitRuntimeEnv: () => void;
publishRuntimeEnv: () => void;
rollbackRuntimeEnv: () => void;
reapplyRuntimeOverlays: (config: OpenClawConfig) => OpenClawConfig;
runtimeEnv?: NonNullable<ConfigWriteNotification["preparedCandidate"]>["runtimeEnv"];
runtimeRefresh?: RuntimeConfigSnapshotRefreshOptions;
};
type PreparedGatewayConfigCandidate = {
runtimeConfig: OpenClawConfig;
compareConfig: OpenClawConfig;
runtimeEnv?: NonNullable<ConfigWriteNotification["preparedCandidate"]>["runtimeEnv"];
reapplyRuntimeOverlays?: (config: OpenClawConfig) => OpenClawConfig;
reapplyCompareOverlays?: (config: OpenClawConfig) => OpenClawConfig;
};
class GatewayConfigReloadSupersededError extends Error {
constructor() {
super("config reload superseded by a newer config write");
this.name = "GatewayConfigReloadSupersededError";
}
}
function asPluginInstallConfig(records: PluginInstallRecords): OpenClawConfig {
return {
plugins: {
@@ -129,13 +167,52 @@ function asPluginInstallConfig(records: PluginInstallRecords): OpenClawConfig {
export function startGatewayConfigReloader(opts: {
initialConfig: OpenClawConfig;
initialCompareConfig?: OpenClawConfig;
prepareConfigCandidate?: (params: {
runtimeConfig: OpenClawConfig;
sourceConfig: OpenClawConfig;
previousSourceConfig: OpenClawConfig;
}) => PreparedGatewayConfigCandidate;
initialInternalWriteHash?: string | null;
readSnapshot: () => Promise<ConfigFileSnapshot>;
readSnapshot: (activeSourceConfig: OpenClawConfig) => Promise<ConfigFileSnapshot>;
/** Pauses restart emission synchronously when a matching disk candidate is observed. */
onConfigCandidateObserved?: () => void;
onConfigChange?: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => void | Promise<void>;
/** Publishes runtime state after a hot or no-op config transaction. */
onConfigApplied?: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => void | Promise<void>;
onNoopConfigCommit: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => Promise<void>;
onHotReload: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => Promise<void>;
onRestart: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => void | Promise<void>;
/** Retires rejected lifecycle work after any newer config transaction is accepted. */
onConfigAccepted?: (
nextConfig: OpenClawConfig,
ownership: GatewayConfigReloadTransactionOwnership,
sourceConfig: OpenClawConfig,
acceptance: {
runtimeApplied: boolean;
publishSource?: () => Promise<() => Promise<void>>;
},
) => void | (() => Promise<void>) | Promise<void | (() => Promise<void>)>;
/** Publishes a newer source snapshot when effective runtime bytes are unchanged. */
onEffectiveConfigUnchanged?: (
nextConfig: OpenClawConfig,
ownership: GatewayConfigReloadTransactionOwnership,
sourceConfig: OpenClawConfig,
) => Promise<() => Promise<void>>;
onNoopConfigCommit: (
plan: GatewayReloadPlan,
nextConfig: OpenClawConfig,
ownership: GatewayConfigReloadTransactionOwnership,
sourceConfig: OpenClawConfig,
) => Promise<void>;
onHotReload: (
plan: GatewayReloadPlan,
nextConfig: OpenClawConfig,
ownership: GatewayConfigReloadTransactionOwnership,
sourceConfig: OpenClawConfig,
) => Promise<void>;
onRestart: (
plan: GatewayReloadPlan,
nextConfig: OpenClawConfig,
ownership: GatewayConfigReloadTransactionOwnership,
sourceConfig: OpenClawConfig,
) => void | Promise<void>;
/** Keeps one accepted config transaction inside the Gateway work fence. */
runTransaction?: <T>(run: () => Promise<T>) => Promise<T>;
promoteSnapshot?: (snapshot: ConfigFileSnapshot, reason: string) => Promise<boolean>;
@@ -149,26 +226,63 @@ export function startGatewayConfigReloader(opts: {
};
watchPath: string;
}): GatewayConfigReloader {
let currentConfig = opts.initialConfig;
let currentCompareConfig = opts.initialCompareConfig ?? opts.initialConfig;
const initialSourceConfig = opts.initialCompareConfig ?? opts.initialConfig;
const initialCandidate = opts.prepareConfigCandidate?.({
runtimeConfig: opts.initialConfig,
sourceConfig: initialSourceConfig,
previousSourceConfig: initialSourceConfig,
});
let currentConfig = initialCandidate?.runtimeConfig ?? opts.initialConfig;
let currentCompareConfig = initialCandidate?.compareConfig ?? initialSourceConfig;
let currentSourceConfig = initialSourceConfig;
let currentRuntimeEnvSourceConfig = initialSourceConfig;
let currentReapplyRuntimeOverlays =
initialCandidate?.reapplyRuntimeOverlays ?? ((config: OpenClawConfig) => config);
let currentRuntimeRefresh: RuntimeConfigSnapshotRefreshOptions | undefined;
let settings = resolveGatewayReloadSettings(currentConfig);
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let pending = false;
let running = false;
let stopped = false;
let restartQueued = false;
const activeReloads = new Set<Promise<void>>();
let missingConfigRetries = 0;
let pendingInProcessConfig: {
config: OpenClawConfig;
compareConfig: OpenClawConfig;
persistedHash: string;
afterWrite?: ConfigWriteNotification["afterWrite"];
} | null = null;
let lastAppliedWriteHash = opts.initialInternalWriteHash ?? null;
let configWriteEpoch = 0;
let pendingInProcessConfig: InProcessConfigCandidate | null = null;
let activeInProcessConfig: InProcessConfigCandidate | null = null;
let watcherIntentCandidate: InProcessConfigCandidate | null = null;
let startupInternalWriteHash = opts.initialInternalWriteHash ?? null;
let lastAppliedWriteHash: string | null = null;
let lastSourceOnlyWriteHash: string | null = null;
let lastSourceOnlyReapplyRuntimeOverlays: ((config: OpenClawConfig) => OpenClawConfig) | null =
null;
let lastSourceOnlyRuntimeRefresh: RuntimeConfigSnapshotRefreshOptions | undefined;
let lastSourceOnlyRuntimeConfig: OpenClawConfig | null = null;
let lastSourceOnlySourceConfig: OpenClawConfig | null = null;
let pendingRuntimeApplicationPlan: GatewayReloadPlan | null = null;
let currentPluginInstallRecords =
opts.initialPluginInstallRecords ?? loadInstalledPluginIndexInstallRecordsSync();
const readPluginInstallRecords =
opts.readPluginInstallRecords ?? loadInstalledPluginIndexInstallRecords;
const flushPendingRuntimeApplication = async () => {
const pendingPlan = pendingRuntimeApplicationPlan;
if (!pendingPlan) {
return;
}
await opts.onConfigApplied?.(pendingPlan, currentConfig);
if (pendingRuntimeApplicationPlan === pendingPlan) {
pendingRuntimeApplicationPlan = null;
}
};
const applyCurrentRuntimePlan = async (
plan: GatewayReloadPlan,
nextRuntimeConfig: OpenClawConfig,
) => {
if (pendingRuntimeApplicationPlan === plan) {
await flushPendingRuntimeApplication();
return;
}
await opts.onConfigApplied?.(plan, nextRuntimeConfig);
};
const scheduleAfter = (wait: number) => {
if (stopped) {
@@ -180,26 +294,27 @@ export function startGatewayConfigReloader(opts: {
clearTimeout(debounceTimer);
}
debounceTimer = setTimeout(() => {
void runReload();
startTrackedReload();
}, wait);
};
const schedule = () => {
scheduleAfter(settings.debounceMs);
};
const queueRestart = async (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => {
if (restartQueued) {
return;
}
restartQueued = true;
const prepareRestart = async (
plan: GatewayReloadPlan,
nextConfig: OpenClawConfig,
ownership: GatewayConfigReloadTransactionOwnership,
sourceConfig: OpenClawConfig,
) => {
try {
// Restart preparation reads secrets and can mutate auth/runtime state.
// Keep it inside the accepted config transaction instead of detaching it.
await opts.onRestart(plan, nextConfig);
// Every accepted restart candidate validates inside its config
// transaction. Only downstream signal delivery may coalesce.
await opts.onRestart(plan, nextConfig, ownership, sourceConfig);
} catch (err) {
// Restart checks can fail (for example unresolved SecretRefs). Keep the
// reloader alive and allow a future change to retry restart scheduling.
restartQueued = false;
opts.log.error(`config restart failed: ${String(err)}`);
// Failed restart admission must reject the transaction. Otherwise the
// persisted snapshot becomes the baseline and the same config cannot retry.
throw err;
}
};
@@ -230,10 +345,78 @@ export function startGatewayConfigReloader(opts: {
};
const applySnapshot = async (
nextConfig: OpenClawConfig,
nextCompareConfig: OpenClawConfig,
candidateRuntimeConfig: OpenClawConfig,
nextSourceConfig: OpenClawConfig,
afterWrite?: ConfigWriteNotification["afterWrite"],
transactionEpoch = configWriteEpoch,
persistedHash?: string,
preflightCandidate?: ConfigWriteNotification["preparedCandidate"],
runtimeRefresh?: RuntimeConfigSnapshotRefreshOptions,
) => {
// Reprepare against the current accepted env owner. A managed write can
// finish preflight while another watcher transaction accepts first.
const preparedCandidate =
opts.prepareConfigCandidate?.({
runtimeConfig: candidateRuntimeConfig,
sourceConfig: nextSourceConfig,
previousSourceConfig: currentRuntimeEnvSourceConfig,
}) ?? preflightCandidate;
const nextConfig = preparedCandidate?.runtimeConfig ?? candidateRuntimeConfig;
const nextCompareConfig = preparedCandidate?.compareConfig ?? nextSourceConfig;
let nextPluginInstallRecords = currentPluginInstallRecords;
let committedRuntimeConfig: OpenClawConfig | null = null;
let publishedRuntimeEnv: ConfigRuntimeEnvPublication | undefined;
let runtimeEnvCommitted = false;
const nextSettings = resolveGatewayReloadSettings(nextConfig);
const isCurrent = () => configWriteEpoch === transactionEpoch;
const assertCurrent = () => {
if (!isCurrent()) {
throw new GatewayConfigReloadSupersededError();
}
};
const commitPublishedRuntimeEnv = () => {
runtimeEnvCommitted = true;
publishedRuntimeEnv?.commit();
publishedRuntimeEnv = undefined;
};
const ownership: GatewayConfigReloadTransactionOwnership = {
isCurrent,
reapplyRuntimeOverlays: preparedCandidate?.reapplyRuntimeOverlays ?? ((config) => config),
...(preparedCandidate?.runtimeEnv ? { runtimeEnv: preparedCandidate.runtimeEnv } : {}),
...(runtimeRefresh ? { runtimeRefresh } : {}),
publishRuntimeEnv: () => {
assertCurrent();
if (runtimeEnvCommitted) {
return;
}
publishedRuntimeEnv ??= preparedCandidate?.runtimeEnv?.publish();
assertCurrent();
},
rollbackRuntimeEnv: () => {
if (runtimeEnvCommitted) {
return;
}
publishedRuntimeEnv?.();
publishedRuntimeEnv = undefined;
},
commitRuntimeEnv: commitPublishedRuntimeEnv,
markRuntimeCommitted: (runtimeConfig, plan) => {
// Publication can win immediately before a watcher supersedes this
// transaction. Advance the runtime diff baseline at that exact edge so
// the newer disk config plans the reverse work instead of diffing stale state.
commitPublishedRuntimeEnv();
committedRuntimeConfig = runtimeConfig;
currentConfig = runtimeConfig;
currentCompareConfig = nextCompareConfig;
currentSourceConfig = nextSourceConfig;
currentRuntimeEnvSourceConfig = nextSourceConfig;
currentReapplyRuntimeOverlays = ownership.reapplyRuntimeOverlays;
currentRuntimeRefresh = ownership.runtimeRefresh;
currentPluginInstallRecords = nextPluginInstallRecords;
settings = resolveGatewayReloadSettings(runtimeConfig);
pendingRuntimeApplicationPlan = plan;
},
};
const configChangedPaths = diffGatewayReloadPaths(currentCompareConfig, nextCompareConfig);
const configPluginInstallTimestampNoopPaths = listPluginInstallTimestampMetadataPaths(
currentCompareConfig,
@@ -243,12 +426,12 @@ export function startGatewayConfigReloader(opts: {
currentCompareConfig,
nextCompareConfig,
);
let nextPluginInstallRecords = currentPluginInstallRecords;
try {
nextPluginInstallRecords = await readPluginInstallRecords();
} catch (err) {
opts.log.warn(`config reload plugin install record check failed: ${String(err)}`);
}
assertCurrent();
const previousPluginInstallConfig = asPluginInstallConfig(currentPluginInstallRecords);
const nextPluginInstallConfig = asPluginInstallConfig(nextPluginInstallRecords);
const pluginInstallRecordChangedPaths = diffConfigPaths(
@@ -272,11 +455,89 @@ export function startGatewayConfigReloader(opts: {
...configPluginInstallWholeRecordPaths,
...pluginInstallRecordWholeRecordPaths,
];
currentConfig = nextConfig;
currentCompareConfig = nextCompareConfig;
currentPluginInstallRecords = nextPluginInstallRecords;
settings = resolveGatewayReloadSettings(nextConfig);
// Publication can be superseded after its runtime commit but before its
// lifecycle owner is applied. Finish that owner before the next candidate
// prepares state that acceptance or restart policy may discard.
await flushPendingRuntimeApplication();
assertCurrent();
const commitReloadBaseline = async (
options: {
runtimeApplied?: boolean;
publishSource?: () => Promise<() => Promise<void>>;
} = {},
) => {
assertCurrent();
// A prior transaction may publish runtime state immediately before a
// newer write supersedes it. Commit that runtime owner before accepting
// a baseline-only candidate, which can discard prepared lifecycle state.
await flushPendingRuntimeApplication();
assertCurrent();
let rollbackAcceptedSource: (() => Promise<void>) | undefined;
try {
const acceptedSourceRollback = await opts.onConfigAccepted?.(
committedRuntimeConfig ?? nextConfig,
ownership,
nextSourceConfig,
{
runtimeApplied: options.runtimeApplied !== false,
...(options.publishSource ? { publishSource: options.publishSource } : {}),
},
);
if (typeof acceptedSourceRollback === "function") {
rollbackAcceptedSource = acceptedSourceRollback;
}
assertCurrent();
rollbackAcceptedSource ??= await options.publishSource?.();
assertCurrent();
currentSourceConfig = nextSourceConfig;
if (options.runtimeApplied === false) {
// Persisted-but-skipped candidates are not runtime truth. Keep the
// effective baseline so a later safe edit cannot publish them indirectly.
lastSourceOnlyWriteHash = persistedHash ?? null;
lastSourceOnlyReapplyRuntimeOverlays = ownership.reapplyRuntimeOverlays;
lastSourceOnlyRuntimeRefresh = ownership.runtimeRefresh;
lastSourceOnlyRuntimeConfig = nextConfig;
lastSourceOnlySourceConfig = nextSourceConfig;
return;
}
// Runtime owners publish env at their commit edge. Keep this idempotent
// fallback for effective-config-unchanged transactions without a
// dedicated runtime publication callback.
ownership.publishRuntimeEnv();
currentRuntimeEnvSourceConfig = nextSourceConfig;
if (persistedHash === lastSourceOnlyWriteHash) {
lastSourceOnlyWriteHash = null;
lastSourceOnlyReapplyRuntimeOverlays = null;
lastSourceOnlyRuntimeRefresh = undefined;
lastSourceOnlyRuntimeConfig = null;
lastSourceOnlySourceConfig = null;
}
currentConfig = committedRuntimeConfig ?? nextConfig;
currentCompareConfig = nextCompareConfig;
currentReapplyRuntimeOverlays = ownership.reapplyRuntimeOverlays;
currentRuntimeRefresh = ownership.runtimeRefresh;
currentPluginInstallRecords = nextPluginInstallRecords;
settings = committedRuntimeConfig
? resolveGatewayReloadSettings(committedRuntimeConfig)
: nextSettings;
commitPublishedRuntimeEnv();
} catch (error) {
ownership.rollbackRuntimeEnv();
await rollbackAcceptedSource?.();
throw error;
}
};
if (changedPaths.length === 0) {
let publishedSourceRollback: (() => Promise<void>) | undefined;
const publishSource = opts.onEffectiveConfigUnchanged
? async () =>
(publishedSourceRollback ??= await opts.onEffectiveConfigUnchanged!(
nextConfig,
ownership,
nextSourceConfig,
))
: undefined;
await commitReloadBaseline(publishSource ? { publishSource } : {});
return;
}
@@ -294,22 +555,26 @@ export function startGatewayConfigReloader(opts: {
opts.log.info(`config change detected; evaluating reload (${changedPaths.join(", ")})`);
if (followUp.mode === "none") {
opts.log.info(`config reload skipped by writer intent (${followUp.reason})`);
await commitReloadBaseline({ runtimeApplied: false });
return;
}
const plan = buildGatewayReloadPlan(changedPaths, {
noopPaths: pluginInstallTimestampNoopPaths,
forceChangedPaths: pluginInstallWholeRecordPaths,
});
if (settings.mode === "off") {
if (nextSettings.mode === "off") {
opts.log.info("config reload disabled (gateway.reload.mode=off)");
await commitReloadBaseline({ runtimeApplied: false });
return;
}
if (isNoopReloadPlan(plan) && !followUp.requiresRestart) {
await opts.onConfigChange?.(plan, nextConfig);
// No-op plans still change the runtime config snapshot. Commit before
// marking applied so getRuntimeConfig() readers do not stay stale until restart.
await opts.onNoopConfigCommit(plan, nextConfig);
await opts.onConfigApplied?.(plan, nextConfig);
await opts.onNoopConfigCommit(plan, nextConfig, ownership, nextSourceConfig);
assertCurrent();
await applyCurrentRuntimePlan(plan, nextConfig);
await commitReloadBaseline();
return;
}
if (followUp.requiresRestart) {
@@ -319,31 +584,43 @@ export function startGatewayConfigReloader(opts: {
restartReasons: [...plan.restartReasons, followUp.reason],
};
await opts.onConfigChange?.(restartPlan, nextConfig);
await queueRestart(restartPlan, nextConfig);
await prepareRestart(restartPlan, nextConfig, ownership, nextSourceConfig);
await commitReloadBaseline();
return;
}
if (settings.mode === "restart") {
await opts.onConfigChange?.({ ...plan, restartGateway: true }, nextConfig);
await queueRestart(plan, nextConfig);
if (nextSettings.mode === "restart") {
const restartPlan = { ...plan, restartGateway: true };
await opts.onConfigChange?.(restartPlan, nextConfig);
await prepareRestart(restartPlan, nextConfig, ownership, nextSourceConfig);
await commitReloadBaseline();
return;
}
if (plan.restartGateway) {
if (settings.mode === "hot") {
if (nextSettings.mode === "hot") {
opts.log.warn(
`config reload requires gateway restart; hot mode ignoring (${plan.restartReasons.join(
", ",
)})`,
);
await commitReloadBaseline({ runtimeApplied: false });
return;
}
await opts.onConfigChange?.(plan, nextConfig);
await queueRestart(plan, nextConfig);
await prepareRestart(plan, nextConfig, ownership, nextSourceConfig);
await commitReloadBaseline();
return;
}
await opts.onConfigChange?.(plan, nextConfig);
await opts.onHotReload(plan, nextConfig);
await opts.onConfigApplied?.(plan, nextConfig);
try {
await opts.onHotReload(plan, nextConfig, ownership, nextSourceConfig);
} catch (error) {
ownership.rollbackRuntimeEnv();
throw error;
}
assertCurrent();
await applyCurrentRuntimePlan(plan, nextConfig);
await commitReloadBaseline();
};
const promoteAcceptedSnapshot = async (snapshot: ConfigFileSnapshot, reason: string) => {
@@ -365,12 +642,36 @@ export function startGatewayConfigReloader(opts: {
await run();
};
const acceptCurrentRuntimeEcho = async (transactionEpoch: number) => {
const ownership: GatewayConfigReloadTransactionOwnership = {
isCurrent: () => configWriteEpoch === transactionEpoch,
reapplyRuntimeOverlays: currentReapplyRuntimeOverlays,
publishRuntimeEnv: () => {},
rollbackRuntimeEnv: () => {},
commitRuntimeEnv: () => {},
...(currentRuntimeRefresh ? { runtimeRefresh: currentRuntimeRefresh } : {}),
markRuntimeCommitted: () => {},
};
await runAcceptedTransaction(async () => {
await flushPendingRuntimeApplication();
if (!ownership.isCurrent()) {
throw new GatewayConfigReloadSupersededError();
}
await opts.onConfigAccepted?.(currentConfig, ownership, currentSourceConfig, {
runtimeApplied: true,
});
if (!ownership.isCurrent()) {
throw new GatewayConfigReloadSupersededError();
}
});
};
const promoteAcceptedInProcessWrite = async (persistedHash: string) => {
if (!opts.promoteSnapshot) {
return;
}
try {
const snapshot = await opts.readSnapshot();
const snapshot = await opts.readSnapshot(currentRuntimeEnvSourceConfig);
if (snapshot.hash !== persistedHash || !snapshot.valid) {
return;
}
@@ -397,33 +698,160 @@ export function startGatewayConfigReloader(opts: {
if (pendingInProcessConfig) {
const pendingWrite = pendingInProcessConfig;
pendingInProcessConfig = null;
activeInProcessConfig = pendingWrite;
missingConfigRetries = 0;
await runAcceptedTransaction(async () => {
await applySnapshot(
pendingWrite.config,
pendingWrite.compareConfig,
pendingWrite.afterWrite,
);
await promoteAcceptedInProcessWrite(pendingWrite.persistedHash);
});
try {
await runAcceptedTransaction(async () => {
await applySnapshot(
pendingWrite.config,
pendingWrite.compareConfig,
pendingWrite.afterWrite,
pendingWrite.epoch,
pendingWrite.persistedHash,
pendingWrite.preparedCandidate,
pendingWrite.runtimeRefresh,
);
if (activeInProcessConfig === pendingWrite) {
activeInProcessConfig = null;
}
await promoteAcceptedInProcessWrite(pendingWrite.persistedHash);
});
} catch (err) {
if (lastAppliedWriteHash === pendingWrite.persistedHash) {
lastAppliedWriteHash = null;
}
if (
configWriteEpoch === pendingWrite.epoch &&
!pendingInProcessConfig &&
!watcherIntentCandidate
) {
watcherIntentCandidate = pendingWrite;
}
throw err;
} finally {
if (activeInProcessConfig === pendingWrite) {
activeInProcessConfig = null;
}
}
return;
}
const snapshot = await opts.readSnapshot();
const transactionEpoch = configWriteEpoch;
const intentCandidate = watcherIntentCandidate;
const snapshot = await opts.readSnapshot(currentRuntimeEnvSourceConfig);
if (configWriteEpoch !== transactionEpoch) {
throw new GatewayConfigReloadSupersededError();
}
if (handleMissingSnapshot(snapshot)) {
await flushPendingRuntimeApplication();
return;
}
if (startupInternalWriteHash && typeof snapshot.hash === "string") {
const matchesStartupWrite =
snapshot.valid &&
snapshot.hash === startupInternalWriteHash &&
diffConfigPaths(currentSourceConfig, snapshot.sourceConfig).length === 0;
// This hash comes from the startup write itself. Consume only its
// first source-identical watcher echo; includes can change under it.
startupInternalWriteHash = null;
if (matchesStartupWrite) {
await acceptCurrentRuntimeEcho(transactionEpoch);
return;
}
}
if (
intentCandidate &&
snapshot.valid &&
snapshot.hash === intentCandidate.persistedHash &&
diffConfigPaths(intentCandidate.compareConfig, snapshot.sourceConfig).length === 0
) {
lastAppliedWriteHash = intentCandidate.persistedHash;
try {
await runAcceptedTransaction(async () => {
await applySnapshot(
intentCandidate.config,
intentCandidate.compareConfig,
intentCandidate.afterWrite,
transactionEpoch,
intentCandidate.persistedHash,
intentCandidate.preparedCandidate,
intentCandidate.runtimeRefresh,
);
if (watcherIntentCandidate === intentCandidate) {
watcherIntentCandidate = null;
}
await promoteAcceptedSnapshot(snapshot, "in-process-write");
});
} catch (err) {
if (lastAppliedWriteHash === intentCandidate.persistedHash) {
lastAppliedWriteHash = null;
}
if (configWriteEpoch === transactionEpoch && !watcherIntentCandidate) {
watcherIntentCandidate = intentCandidate;
}
throw err;
}
return;
}
if (watcherIntentCandidate === intentCandidate) {
watcherIntentCandidate = null;
}
if (intentCandidate && lastAppliedWriteHash === intentCandidate.persistedHash) {
lastAppliedWriteHash = null;
}
if (lastAppliedWriteHash && typeof snapshot.hash === "string") {
if (snapshot.hash === lastAppliedWriteHash) {
const matchesAcceptedEffectiveConfig =
snapshot.valid &&
snapshot.hash === lastAppliedWriteHash &&
diffConfigPaths(currentSourceConfig, snapshot.sourceConfig).length === 0;
if (matchesAcceptedEffectiveConfig) {
if (snapshot.hash === lastSourceOnlyWriteHash) {
const ownership: GatewayConfigReloadTransactionOwnership = {
isCurrent: () => configWriteEpoch === transactionEpoch,
reapplyRuntimeOverlays:
lastSourceOnlyReapplyRuntimeOverlays ?? currentReapplyRuntimeOverlays,
publishRuntimeEnv: () => {},
rollbackRuntimeEnv: () => {},
commitRuntimeEnv: () => {},
...(lastSourceOnlyRuntimeRefresh
? { runtimeRefresh: lastSourceOnlyRuntimeRefresh }
: {}),
markRuntimeCommitted: () => {},
};
await runAcceptedTransaction(async () => {
await flushPendingRuntimeApplication();
if (!ownership.isCurrent()) {
throw new GatewayConfigReloadSupersededError();
}
await opts.onConfigAccepted?.(
lastSourceOnlyRuntimeConfig ?? currentConfig,
ownership,
lastSourceOnlySourceConfig ?? currentSourceConfig,
{ runtimeApplied: false },
);
if (!ownership.isCurrent()) {
throw new GatewayConfigReloadSupersededError();
}
});
return;
}
await acceptCurrentRuntimeEcho(transactionEpoch);
return;
}
lastAppliedWriteHash = null;
}
if (handleMissingSnapshot(snapshot)) {
return;
}
if (!snapshot.valid) {
handleInvalidSnapshot(snapshot);
await flushPendingRuntimeApplication();
return;
}
await runAcceptedTransaction(async () => {
await applySnapshot(snapshot.config, snapshot.sourceConfig);
await applySnapshot(
snapshot.config,
snapshot.sourceConfig,
undefined,
transactionEpoch,
snapshot.hash,
);
await promoteAcceptedSnapshot(snapshot, "valid-config");
});
} catch (err) {
@@ -437,7 +865,37 @@ export function startGatewayConfigReloader(opts: {
}
};
function startTrackedReload(): void {
const reload = runReload();
activeReloads.add(reload);
// A quick invocation can only set `pending` and finish while the owner run
// remains active. Track every promise so it cannot replace that owner.
void reload.then(
() => activeReloads.delete(reload),
() => activeReloads.delete(reload),
);
}
const scheduleFromWatcher = () => {
opts.onConfigCandidateObserved?.();
// Revoke the transaction synchronously. The debounced reread owns this new
// epoch; a slow prior reload must not publish after a newer disk write.
configWriteEpoch += 1;
const pendingCandidate = pendingInProcessConfig;
const activeCandidate = activeInProcessConfig;
const newestLiveCandidate =
pendingCandidate && (!activeCandidate || pendingCandidate.epoch > activeCandidate.epoch)
? pendingCandidate
: activeCandidate;
if (
newestLiveCandidate &&
(!watcherIntentCandidate || newestLiveCandidate.epoch > watcherIntentCandidate.epoch)
) {
watcherIntentCandidate = newestLiveCandidate;
}
if (pendingInProcessConfig) {
pendingInProcessConfig = null;
}
schedule();
};
@@ -446,11 +904,20 @@ export function startGatewayConfigReloader(opts: {
if (event.configPath !== opts.watchPath) {
return;
}
// A live writer notification owns any following watcher echo. Do not
// let the startup token discard its intent or prepared runtime metadata.
startupInternalWriteHash = null;
opts.onConfigCandidateObserved?.();
configWriteEpoch += 1;
watcherIntentCandidate = null;
pendingInProcessConfig = {
config: event.runtimeConfig,
compareConfig: event.sourceConfig,
persistedHash: event.persistedHash,
afterWrite: event.afterWrite,
...(event.preparedCandidate ? { preparedCandidate: event.preparedCandidate } : {}),
...(event.runtimeRefresh ? { runtimeRefresh: event.runtimeRefresh } : {}),
epoch: configWriteEpoch,
};
lastAppliedWriteHash = event.persistedHash;
scheduleAfter(0);
@@ -547,6 +1014,8 @@ export function startGatewayConfigReloader(opts: {
const active = watcher;
watcher = null;
await active?.close().catch(() => {});
// Timer callbacks detach runReload; shutdown owns their full transaction unwind.
await Promise.all(activeReloads);
},
hotReloadStatus: () => hotReloadStatus,
};
+330 -1
View File
@@ -5,8 +5,17 @@ import os from "node:os";
import path from "node:path";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { clearAllBootstrapSnapshots } from "../agents/bootstrap-cache.js";
import { clearConfigCache, clearRuntimeConfigSnapshot } from "../config/config.js";
import {
clearConfigCache,
clearRuntimeConfigSnapshot,
getRuntimeConfig,
getRuntimeConfigSnapshotMetadata,
writeConfigFile,
} from "../config/config.js";
import { resetConfigOverrides, setConfigOverride } from "../config/runtime-overrides.js";
import { clearSessionStoreCacheForTest } from "../config/sessions/store.js";
import type { GatewayAuthConfig, GatewayTailscaleConfig } from "../config/types.gateway.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resetAgentRunContextForTest } from "../infra/agent-events.js";
import { clearGatewaySubagentRuntime } from "../plugins/runtime/index.js";
import { captureEnv, deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js";
@@ -29,6 +38,8 @@ const GATEWAY_TEST_ENV_KEYS = [
"OPENCLAW_STATE_DIR",
"OPENCLAW_CONFIG_PATH",
"OPENCLAW_GATEWAY_TOKEN",
"OPENCLAW_TEST_GATEWAY_OVERRIDE_TOKEN",
"OPENCLAW_TEST_RUNTIME_OVERRIDE_TOKEN",
"OPENCLAW_SKIP_CHANNELS",
"OPENCLAW_SKIP_GMAIL_WATCHER",
"OPENCLAW_SKIP_CRON",
@@ -159,6 +170,7 @@ async function setupGatewayTempHome(params: { prefix: string; minimalGateway?: b
}
function resetGatewayTestState(): void {
resetConfigOverrides();
clearRuntimeConfigSnapshot();
clearConfigCache();
clearSessionStoreCacheForTest();
@@ -176,6 +188,323 @@ describe("gateway e2e", () => {
({ createConfigIO } = await import("../config/config.js"));
});
it.each(["generated", "explicit-override", "secret-ref-override", "runtime-overrides"] as const)(
"preserves %s auth across a safe direct gateway reload",
async (authSource) => {
const { envSnapshot, tempHome } = await setupGatewayTempHome({
prefix: "openclaw-gw-direct-reload-",
});
let server: Awaited<ReturnType<typeof startGatewayServer>> | undefined;
let client: Awaited<ReturnType<typeof connectGatewayClient>> | undefined;
try {
deleteTestEnvValue("OPENCLAW_GATEWAY_TOKEN");
const fileToken = nextGatewayId("direct-file-token");
const overrideToken = nextGatewayId("direct-override-token");
const initialConfig: OpenClawConfig = {
...(authSource !== "generated"
? {
gateway: {
auth: {
mode: "token",
token:
authSource === "secret-ref-override"
? {
source: "env" as const,
provider: "default",
id: "OPENCLAW_TEST_MISSING_DISK_TOKEN",
}
: fileToken,
},
},
}
: {}),
...(authSource === "runtime-overrides"
? { channels: { whatsapp: { dmPolicy: "pairing" as const } } }
: {}),
logging: { level: "info" },
};
const configPath = await createGatewayConfigPath(tempHome);
setTestEnvValue("OPENCLAW_CONFIG_PATH", configPath);
const configIO = createConfigIO({ configPath });
await configIO.writeConfigFile(initialConfig);
if (authSource === "secret-ref-override") {
setTestEnvValue("OPENCLAW_TEST_GATEWAY_OVERRIDE_TOKEN", overrideToken);
}
if (authSource === "runtime-overrides") {
deleteTestEnvValue("OPENCLAW_SKIP_CHANNELS");
deleteTestEnvValue("OPENCLAW_SKIP_PROVIDERS");
setTestEnvValue("OPENCLAW_TEST_RUNTIME_OVERRIDE_TOKEN", overrideToken);
expect(
setConfigOverride("gateway.auth.token", {
source: "env",
provider: "default",
id: "OPENCLAW_TEST_RUNTIME_OVERRIDE_TOKEN",
}).ok,
).toBe(true);
expect(
setConfigOverride("channels.whatsapp", { dmPolicy: "open", allowFrom: ["*"] }).ok,
).toBe(true);
}
const callerAuthOverride: GatewayAuthConfig | undefined =
authSource === "explicit-override"
? {
mode: "token" as const,
token: overrideToken,
rateLimit: { maxAttempts: 7 },
}
: authSource === "secret-ref-override"
? {
mode: "token",
token: {
source: "env",
provider: "default",
id: "OPENCLAW_TEST_GATEWAY_OVERRIDE_TOKEN",
},
}
: undefined;
const callerTailscaleOverride: GatewayTailscaleConfig | undefined =
authSource === "explicit-override"
? { mode: "off" as const, serviceName: "svc:startup" }
: undefined;
const port = await getFreeGatewayPort();
server = await startGatewayServer(port, {
bind: "loopback",
...(callerAuthOverride ? { auth: callerAuthOverride } : {}),
...(callerTailscaleOverride ? { tailscale: callerTailscaleOverride } : {}),
controlUiEnabled: false,
});
const expectedToken =
authSource === "generated" ? getRuntimeConfig().gateway?.auth?.token : overrideToken;
expect(typeof expectedToken).toBe("string");
client = await connectGatewayClient({
url: `ws://127.0.0.1:${port}`,
token: expectedToken as string,
clientDisplayName: "vitest-direct-reload",
});
const health = await client.request<{
configReload?: { hotReloadStatus?: string };
}>("health", { probe: true });
expect(health?.configReload?.hotReloadStatus).toBe("active");
if (authSource === "runtime-overrides") {
expect(getRuntimeConfig().channels?.whatsapp?.dmPolicy).toBe("open");
} else if (callerAuthOverride && callerTailscaleOverride) {
callerAuthOverride.token = `${overrideToken}-mutated`;
callerAuthOverride.rateLimit!.maxAttempts = 99;
callerTailscaleOverride.serviceName = "svc:mutated";
}
await writeConfigFile({
...initialConfig,
logging: { level: "debug" },
});
await expect
.poll(() => getRuntimeConfig().logging?.level, { timeout: 5_000, interval: 50 })
.toBe("debug");
expect(getRuntimeConfig().gateway?.auth?.token).toBe(expectedToken);
if (authSource === "explicit-override") {
expect(getRuntimeConfig().gateway?.auth?.rateLimit?.maxAttempts).toBe(7);
expect(getRuntimeConfig().gateway?.tailscale?.serviceName).toBe("svc:startup");
}
if (authSource === "runtime-overrides") {
expect(getRuntimeConfig().channels?.whatsapp?.dmPolicy).toBe("open");
expect(getRuntimeConfig().channels?.whatsapp?.allowFrom).toEqual(["*"]);
const sourceBeforePolicyEdit = (await configIO.readConfigFileSnapshot()).sourceConfig;
const revisionBeforePolicyEdit = getRuntimeConfigSnapshotMetadata()?.revision ?? -1;
await writeConfigFile({
...sourceBeforePolicyEdit,
channels: {
...sourceBeforePolicyEdit.channels,
whatsapp: {
...sourceBeforePolicyEdit.channels?.whatsapp,
dmPolicy: "disabled",
},
},
});
await expect
.poll(() => getRuntimeConfigSnapshotMetadata()?.revision ?? -1, {
timeout: 5_000,
interval: 50,
})
.toBeGreaterThan(revisionBeforePolicyEdit);
const persistedPolicyEdit = JSON.parse(
await fs.readFile(configPath, "utf-8"),
) as OpenClawConfig;
expect(persistedPolicyEdit.channels?.whatsapp?.dmPolicy).toBe("disabled");
expect(getRuntimeConfig().channels?.whatsapp?.dmPolicy).toBe("open");
const sourceBeforeUnrelatedWrite = (await configIO.readConfigFileSnapshot()).sourceConfig;
const revisionBeforeUnrelatedWrite = getRuntimeConfigSnapshotMetadata()?.revision ?? -1;
await writeConfigFile({
...sourceBeforeUnrelatedWrite,
ui: { assistant: { name: "unrelated-managed-write" } },
});
await expect
.poll(() => getRuntimeConfigSnapshotMetadata()?.revision ?? -1, {
timeout: 5_000,
interval: 50,
})
.toBeGreaterThan(revisionBeforeUnrelatedWrite);
const persistedAfterUnrelatedWrite = JSON.parse(
await fs.readFile(configPath, "utf-8"),
) as OpenClawConfig;
expect(persistedAfterUnrelatedWrite.channels?.whatsapp?.dmPolicy).toBe("disabled");
}
const reconnected = await connectGatewayClient({
url: `ws://127.0.0.1:${port}`,
token: expectedToken as string,
clientDisplayName: "vitest-direct-reload-reconnect",
});
await disconnectGatewayClient(reconnected);
} finally {
if (client) {
await disconnectGatewayClient(client);
}
if (server) {
await server.close({ reason: "direct reload test complete" });
}
await removeGatewayTempHome(tempHome);
envSnapshot.restore();
}
},
);
it(
"re-resolves a startup auth SecretRef override when secrets reload",
{ timeout: GATEWAY_E2E_TIMEOUT_MS },
async () => {
const { envSnapshot, tempHome } = await setupGatewayTempHome({
prefix: "openclaw-gw-startup-auth-ref-",
});
let server: Awaited<ReturnType<typeof startGatewayServer>> | undefined;
let oldClient: Awaited<ReturnType<typeof connectGatewayClient>> | undefined;
try {
const configPath = await createGatewayConfigPath(tempHome);
setTestEnvValue("OPENCLAW_CONFIG_PATH", configPath);
const configIO = createConfigIO({ configPath });
const fileToken = nextGatewayId("startup-auth-file-token");
const oldToken = nextGatewayId("startup-auth-ref-old");
const newToken = nextGatewayId("startup-auth-ref-new");
await configIO.writeConfigFile({
gateway: { auth: { mode: "token", token: fileToken } },
logging: { level: "info" },
});
setTestEnvValue("OPENCLAW_TEST_GATEWAY_OVERRIDE_TOKEN", oldToken);
const port = await getFreeGatewayPort();
server = await startGatewayServer(port, {
bind: "loopback",
auth: {
mode: "token",
token: {
source: "env",
provider: "default",
id: "OPENCLAW_TEST_GATEWAY_OVERRIDE_TOKEN",
},
},
controlUiEnabled: false,
});
oldClient = await connectGatewayClient({
url: `ws://127.0.0.1:${port}`,
token: oldToken,
clientDisplayName: "vitest-startup-auth-ref-old",
});
setTestEnvValue("OPENCLAW_TEST_GATEWAY_OVERRIDE_TOKEN", newToken);
const reload = await oldClient
.request<{ ok?: boolean }>("secrets.reload", {})
.catch((error: unknown) => (error instanceof Error ? error : new Error(String(error))));
if (!(reload instanceof Error)) {
expect(reload.ok).toBe(true);
}
const newClient = await connectGatewayClient({
url: `ws://127.0.0.1:${port}`,
token: newToken,
clientDisplayName: "vitest-startup-auth-ref-new",
});
await disconnectGatewayClient(newClient);
await writeConfigFile({
gateway: { auth: { mode: "token", token: fileToken } },
logging: { level: "debug" },
});
const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as {
gateway?: { auth?: { token?: unknown } };
};
expect(persisted.gateway?.auth?.token).toBe(fileToken);
} finally {
if (oldClient) {
await disconnectGatewayClient(oldClient);
}
if (server) {
await server.close({ reason: "startup auth SecretRef rotation test complete" });
}
await removeGatewayTempHome(tempHome);
envSnapshot.restore();
}
},
);
it("preserves runtime-seeded Control UI origins across a safe direct reload", async () => {
const { envSnapshot, tempHome } = await setupGatewayTempHome({
prefix: "openclaw-gw-direct-origins-",
});
const token = nextGatewayId("direct-origins-token");
const configPath = await createGatewayConfigPath(tempHome);
setTestEnvValue("OPENCLAW_CONFIG_PATH", configPath);
const configIO = createConfigIO({ configPath });
const initialConfig: OpenClawConfig = {
gateway: { auth: { mode: "token", token } },
logging: { level: "info" },
};
await configIO.writeConfigFile(initialConfig);
const port = await getFreeGatewayPort();
const server = await startGatewayServer(port, {
bind: "lan",
controlUiEnabled: false,
});
try {
const seededOrigins = getRuntimeConfig().gateway?.controlUi?.allowedOrigins;
expect(seededOrigins?.length).toBeGreaterThan(0);
await writeConfigFile({
...initialConfig,
logging: { level: "debug" },
});
await expect
.poll(() => getRuntimeConfig().logging?.level, { timeout: 5_000, interval: 50 })
.toBe("debug");
expect(getRuntimeConfig().gateway?.controlUi?.allowedOrigins).toEqual(seededOrigins);
expect(setConfigOverride("logging.level", "warn").ok).toBe(true);
await writeConfigFile({
...initialConfig,
ui: { assistant: { name: "override-active" } },
logging: { level: "debug" },
});
await expect
.poll(() => getRuntimeConfig().logging?.level, { timeout: 5_000, interval: 50 })
.toBe("warn");
resetConfigOverrides();
await writeConfigFile({
...initialConfig,
ui: { assistant: { name: "override-reset" } },
logging: { level: "debug" },
});
await expect
.poll(() => getRuntimeConfig().logging?.level, { timeout: 5_000, interval: 50 })
.toBe("debug");
expect(getRuntimeConfig().gateway?.controlUi?.allowedOrigins).toEqual(seededOrigins);
} finally {
await server.close({ reason: "direct origin reload test complete" });
await removeGatewayTempHome(tempHome);
envSnapshot.restore();
}
});
it(
"accepts a gateway agent request over ws and returns a run id",
{ timeout: GATEWAY_E2E_TIMEOUT_MS },
@@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vitest";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveGatewayStartupPluginActivationConfig } from "./plugin-activation-runtime-config.js";
import { resolveGatewayReloadPluginActivationCandidate } from "./plugin-activation-runtime-config.js";
vi.mock("../config/plugin-auto-enable.js", () => ({
applyPluginAutoEnable: vi.fn(),
@@ -60,3 +61,35 @@ describe("resolveGatewayStartupPluginActivationConfig", () => {
);
});
});
describe("resolveGatewayReloadPluginActivationCandidate", () => {
it("retains implicit provider and channel activation on a logging-only reload", () => {
const sourceConfig = { logging: { level: "debug" as const } };
const autoEnabledConfig = {
...sourceConfig,
channels: { telegram: { enabled: true } },
plugins: {
allow: ["openai-codex", "telegram"],
entries: {
"openai-codex": { enabled: true },
telegram: { enabled: true },
},
},
} as OpenClawConfig;
applyPluginAutoEnableMock.mockReturnValue({
config: autoEnabledConfig,
changes: [],
autoEnabledReasons: {},
});
const result = resolveGatewayReloadPluginActivationCandidate({
runtimeConfig: sourceConfig,
sourceConfig,
env: {},
});
expect(result.compareConfig).toBe(autoEnabledConfig);
expect(result.runtimeConfig.plugins).toEqual(autoEnabledConfig.plugins);
expect(result.runtimeConfig.channels?.telegram?.enabled).toBe(true);
});
});
@@ -136,3 +136,26 @@ export function resolveGatewayStartupPluginActivationConfig(params: {
}).config,
});
}
/** Re-derives source-owned plugin activation and carries it into one reload candidate. */
export function resolveGatewayReloadPluginActivationCandidate(params: {
runtimeConfig: OpenClawConfig;
sourceConfig: OpenClawConfig;
env: NodeJS.ProcessEnv;
manifestRegistry?: PluginManifestRegistry;
discovery?: PluginDiscoveryResult;
}): { runtimeConfig: OpenClawConfig; compareConfig: OpenClawConfig } {
const activationConfig = applyPluginAutoEnable({
config: params.sourceConfig,
env: params.env,
...(params.manifestRegistry ? { manifestRegistry: params.manifestRegistry } : {}),
discovery: params.discovery,
}).config;
return {
runtimeConfig: mergeActivationSectionsIntoRuntimeConfig({
runtimeConfig: params.runtimeConfig,
activationConfig,
}),
compareConfig: activationConfig,
};
}
+183 -5
View File
@@ -1,15 +1,22 @@
// Gateway auxiliary handler tests cover hot config reload behavior, prepared
// secret snapshot updates, and restart-plan side effects.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
getRuntimeAuthProfileStoreCredentialsRevision,
getRuntimeAuthProfileStoreSnapshot,
setRuntimeAuthProfileStoreSnapshot,
} from "../agents/auth-profiles/runtime-snapshots.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
activateSecretsRuntimeSnapshot,
clearSecretsRuntimeSnapshot,
getActiveSecretsRuntimeSnapshot,
getActiveSecretsRuntimeSnapshotRevision,
type PreparedSecretsRuntimeSnapshot,
} from "../secrets/runtime.js";
import type { GatewayReloadPlan } from "./config-reload.js";
import { createGatewayAuxHandlers } from "./server-aux-handlers.js";
import { replaceSharedGatewaySessionGenerationState } from "./server-shared-auth-generation.js";
function asConfig(value: unknown): OpenClawConfig {
return value as OpenClawConfig;
@@ -38,6 +45,7 @@ function createSnapshot(config: OpenClawConfig): PreparedSecretsRuntimeSnapshot
sourceConfig: asConfig({}),
config,
authStores: [],
authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(),
warnings: [],
webTools: {
search: { providerSource: "none", diagnostics: [] },
@@ -47,6 +55,10 @@ function createSnapshot(config: OpenClawConfig): PreparedSecretsRuntimeSnapshot
};
}
function createSourceSnapshot(config: OpenClawConfig): PreparedSecretsRuntimeSnapshot {
return { ...createSnapshot(config), sourceConfig: config };
}
function slackConfig(signingSecret: string) {
return asConfig({
channels: { slack: { signingSecret } },
@@ -258,10 +270,7 @@ describe("gateway aux handlers", () => {
const prepared = createSnapshot(
slackZaloDiscordConfig("new-slack-secret", "new-zalo-secret", "unchanged-discord-token"),
);
const activateRuntimeSecrets = vi.fn().mockImplementation(async () => {
activateSecretsRuntimeSnapshot(prepared);
return prepared;
});
const activateRuntimeSecrets = vi.fn().mockResolvedValue(prepared);
const { reload, respond, startChannel, stopChannel } =
createSecretsReloadHarnessWithChannelMocks({
activateRuntimeSecrets,
@@ -295,7 +304,6 @@ describe("gateway aux handlers", () => {
// handler were not serialized.
await Promise.resolve();
await Promise.resolve();
activateSecretsRuntimeSnapshot(preparedFirst);
activationOrder.push("first-end");
return preparedFirst;
});
@@ -321,7 +329,62 @@ describe("gateway aux handlers", () => {
expect(respond).toHaveBeenNthCalledWith(2, true, { ok: true, warningCount: 0 });
});
it("retries from the canonical source when it changes during secrets.reload preparation", async () => {
const initialConfig = slackConfig("initial-secret");
const canonicalConfig = slackConfig("canonical-secret");
activateSecretsRuntimeSnapshot(createSourceSnapshot(initialConfig));
const activatePreparedSnapshotIfCurrent = vi.fn(
async (
snapshot: PreparedSecretsRuntimeSnapshot,
expectedRevision: number,
_params: unknown,
onActivated?: () => void | Promise<void>,
canActivate?: () => boolean,
) => {
if (
getActiveSecretsRuntimeSnapshotRevision() !== expectedRevision ||
(canActivate && !canActivate())
) {
return null;
}
activateSecretsRuntimeSnapshot(snapshot);
await onActivated?.();
return snapshot;
},
);
const activateRuntimeSecrets = Object.assign(
vi.fn(
async (
config: OpenClawConfig,
_activationParams: Parameters<GatewayAuxHandlerParams["activateRuntimeSecrets"]>[1],
) => {
if (activateRuntimeSecrets.mock.calls.length === 1) {
activateSecretsRuntimeSnapshot(createSourceSnapshot(canonicalConfig));
}
return createSourceSnapshot(config);
},
),
{ activatePreparedSnapshotIfCurrent },
);
const { reload, respond } = createSecretsReloadHarness({ activateRuntimeSecrets });
await reload();
expect(activateRuntimeSecrets.mock.calls.map(([config]) => config)).toEqual([
initialConfig,
canonicalConfig,
]);
expect(activateRuntimeSecrets.mock.calls.map(([, activation]) => activation)).toEqual([
{ reason: "reload", activate: false },
{ reason: "reload", activate: false },
]);
expect(activatePreparedSnapshotIfCurrent).toHaveBeenCalledTimes(2);
expect(getActiveSecretsRuntimeSnapshot()?.sourceConfig).toEqual(canonicalConfig);
expect(firstRespondCall(respond)[0]).toBe(true);
});
it("rolls back stopped channels when a later restart fails", async () => {
const authAgentDir = "/tmp/openclaw-secrets-reload-concurrent-oauth";
const buildReloadPlan = buildRestartChannelsPlan("slack", "zalo");
activateSnapshot(slackZaloConfig("old-slack-secret", "old-zalo-secret"));
const activateRuntimeSecrets = mockResolvedSecrets(
@@ -332,14 +395,35 @@ describe("gateway aux handlers", () => {
.fn()
.mockResolvedValueOnce(undefined)
.mockImplementationOnce(async () => {
setRuntimeAuthProfileStoreSnapshot(
{
version: 1,
profiles: {
"openai:default": {
type: "oauth",
provider: "openai",
access: "access-new",
refresh: "refresh-new",
expires: Date.now() + 60_000,
},
},
},
authAgentDir,
);
throw new Error("zalo refused to start");
})
.mockResolvedValue(undefined);
const logChannelsInfo = vi.fn();
const sharedGatewaySessionGenerationState = {
current: "gen-old" as string | undefined,
required: "gen-old" as string | undefined | null,
};
const { reload, respond } = createSecretsReloadHarness({
activateRuntimeSecrets,
buildReloadPlan,
sharedGatewaySessionGenerationState,
resolveSharedGatewaySessionGenerationForConfig: () => "gen-new",
startChannel,
stopChannel,
logChannelsInfo,
@@ -374,6 +458,100 @@ describe("gateway aux handlers", () => {
expect(getActiveSecretsRuntimeSnapshot()?.config).toEqual(
slackZaloConfig("old-slack-secret", "old-zalo-secret"),
);
expect(sharedGatewaySessionGenerationState).toEqual({
current: "gen-old",
required: "gen-old",
});
expect(
getRuntimeAuthProfileStoreSnapshot(authAgentDir)?.profiles["openai:default"],
).toMatchObject({ access: "access-new", refresh: "refresh-new" });
});
it("does not roll back over a snapshot published after secrets.reload activation", async () => {
const buildReloadPlan = buildRestartChannelsPlan("slack");
activateSnapshot(slackConfig("old-slack-secret"));
const prepared = createSnapshot(slackConfig("reload-secret"));
const concurrent = createSnapshot(slackConfig("concurrent-secret"));
const activateRuntimeSecrets = vi.fn(
async (
_config: OpenClawConfig,
_activationParams: Parameters<GatewayAuxHandlerParams["activateRuntimeSecrets"]>[1],
) => {
return prepared;
},
);
const sharedGatewaySessionGenerationState = {
current: "gen-old" as string | undefined,
required: "gen-old" as string | undefined | null,
};
const startChannel = vi
.fn()
.mockImplementationOnce(async () => {
activateSecretsRuntimeSnapshot(concurrent);
replaceSharedGatewaySessionGenerationState(sharedGatewaySessionGenerationState, {
current: "gen-concurrent",
required: "gen-concurrent",
});
throw new Error("slack refused to start");
})
.mockResolvedValue(undefined);
const { reload, respond } = createSecretsReloadHarness({
activateRuntimeSecrets,
buildReloadPlan,
sharedGatewaySessionGenerationState,
resolveSharedGatewaySessionGenerationForConfig: () => "gen-reload",
startChannel,
stopChannel: vi.fn().mockResolvedValue(undefined),
});
await reload();
expect(firstRespondCall(respond)[0]).toBe(false);
expect(startChannel).toHaveBeenCalledTimes(2);
expect(getActiveSecretsRuntimeSnapshot()?.config).toEqual(slackConfig("concurrent-secret"));
expect(sharedGatewaySessionGenerationState).toEqual({
current: "gen-concurrent",
required: "gen-concurrent",
});
});
it("rolls back a failed snapshot without overwriting newer generation-only state", async () => {
const initialConfig = slackConfig("old-slack-secret");
const prepared = createSourceSnapshot(slackConfig("reload-secret"));
activateSecretsRuntimeSnapshot(createSourceSnapshot(initialConfig));
const sharedGatewaySessionGenerationState = {
current: "gen-old" as string | undefined,
required: "gen-old" as string | undefined | null,
};
const startChannel = vi
.fn()
.mockImplementationOnce(async () => {
replaceSharedGatewaySessionGenerationState(sharedGatewaySessionGenerationState, {
current: "gen-concurrent",
required: "gen-concurrent",
});
throw new Error("slack refused to start");
})
.mockResolvedValue(undefined);
const { reload, respond } = createSecretsReloadHarness({
activateRuntimeSecrets: vi.fn(async () => prepared),
buildReloadPlan: buildRestartChannelsPlan("slack"),
sharedGatewaySessionGenerationState,
resolveSharedGatewaySessionGenerationForConfig: () => "gen-reload",
startChannel,
stopChannel: vi.fn().mockResolvedValue(undefined),
});
await reload();
expect(firstRespondCall(respond)[0]).toBe(false);
expect(startChannel).toHaveBeenCalledTimes(2);
expect(getActiveSecretsRuntimeSnapshot()?.config).toEqual(initialConfig);
expect(sharedGatewaySessionGenerationState).toEqual({
current: "gen-concurrent",
required: "gen-concurrent",
});
});
it("attempts restart on rollback even when stopChannel itself throws mid-reload", async () => {
+224 -50
View File
@@ -16,6 +16,7 @@ import {
} from "../secrets/runtime-command-secrets.js";
import {
getActiveSecretsRuntimeSnapshot,
getActiveSecretsRuntimeSnapshotRevision,
type PreparedSecretsRuntimeSnapshot,
} from "../secrets/runtime-state.js";
import { createLazyPromise } from "../shared/lazy-runtime.js";
@@ -38,9 +39,14 @@ import {
import type { ChannelAutostartSuppression } from "./server-channels.js";
import type { GatewayRequestHandler, GatewayRequestHandlers } from "./server-methods/types.js";
import {
captureSharedGatewaySessionGenerationOwnership,
claimSharedGatewaySessionGenerationIfOwned,
disconnectStaleSharedGatewayAuthClients,
setCurrentSharedGatewaySessionGeneration,
finalizeOwnedSharedGatewaySessionGeneration,
isSharedGatewaySessionGenerationOwnershipCurrent,
replaceOwnedSharedGatewaySessionGenerationState,
type SharedGatewayAuthClient,
type SharedGatewaySessionGenerationOwnership,
type SharedGatewaySessionGenerationState,
} from "./server-shared-auth-generation.js";
import type { ActivateRuntimeSecrets } from "./server-startup-config.js";
@@ -56,11 +62,37 @@ type ReloadSecretsResult = {
warningCount: number;
};
async function activateSecretsRuntimeSnapshot(
async function activateSecretsRuntimeSnapshotIfCurrent(
snapshot: PreparedSecretsRuntimeSnapshot,
): Promise<void> {
expectedRevision: number,
options?: {
canActivate?: () => boolean;
onActivated?: () => void;
},
): Promise<number | null> {
const runtime = await import("../secrets/runtime.js");
runtime.activateSecretsRuntimeSnapshot(snapshot);
if (options?.canActivate && !options.canActivate()) {
return null;
}
if (!runtime.activateSecretsRuntimeSnapshotIfCurrent(snapshot, expectedRevision)) {
return null;
}
options?.onActivated?.();
return runtime.getActiveSecretsRuntimeSnapshotRevision();
}
async function restoreSecretsRuntimeSnapshotIfCurrent(
snapshot: PreparedSecretsRuntimeSnapshot,
expectedRevision: number,
ownedSnapshot: PreparedSecretsRuntimeSnapshot,
options?: { onActivated?: () => void },
): Promise<number | null> {
const runtime = await import("../secrets/runtime.js");
if (!runtime.restoreSecretsRuntimeSnapshotIfCurrent(snapshot, expectedRevision, ownedSnapshot)) {
return null;
}
options?.onActivated?.();
return runtime.getActiveSecretsRuntimeSnapshotRevision();
}
function createLazyHandler(
@@ -187,42 +219,134 @@ export function createGatewayAuxHandlers(params: {
createSecretsHandlers({
reloadSecrets: () =>
runExclusiveReload(async () => {
const previousSnapshot = getActiveSecretsRuntimeSnapshot();
if (!previousSnapshot) {
throw new Error("Secrets runtime snapshot is not active.");
}
// Snapshot both `current` and `required` because
// `setCurrentSharedGatewaySessionGeneration` can clear `required` as
// a side effect of activating a new generation. Restoring only
// `current` on rollback would leave `required` cleared and weaken
// shared-gateway auth-generation enforcement after a failed reload.
const previousSharedGatewaySessionGeneration =
params.sharedGatewaySessionGenerationState.current;
const previousSharedGatewaySessionGenerationRequired =
params.sharedGatewaySessionGenerationState.required;
let nextSharedGatewaySessionGeneration;
let sharedGatewaySessionGenerationChanged = false;
let transaction:
| {
previousSnapshot: PreparedSecretsRuntimeSnapshot;
previousSharedGatewaySessionGeneration: string | undefined;
previousSharedGatewaySessionGenerationRequired: string | undefined | null;
prepared: PreparedSecretsRuntimeSnapshot;
plan: GatewayReloadPlan;
nextSharedGatewaySessionGeneration: string | undefined;
sharedGatewaySessionGenerationChanged: boolean;
generationOwnership: SharedGatewaySessionGenerationOwnership;
publishedSnapshotRevision: number;
}
| undefined;
const stoppedChannels: ChannelKind[] = [];
const restartedChannels = new Set<ChannelKind>();
try {
const prepared = await params.activateRuntimeSecrets(
previousSnapshot.sourceConfig,
{
reason: "reload",
activate: true,
},
);
nextSharedGatewaySessionGeneration =
params.resolveSharedGatewaySessionGenerationForConfig(prepared.config);
const plan = buildReloadPlan(
diffConfigPaths(previousSnapshot.config, prepared.config),
);
setCurrentSharedGatewaySessionGeneration(
params.sharedGatewaySessionGenerationState,
for (;;) {
const previousSnapshot = getActiveSecretsRuntimeSnapshot();
if (!previousSnapshot) {
throw new Error("Secrets runtime snapshot is not active.");
}
const previousSnapshotRevision = getActiveSecretsRuntimeSnapshotRevision();
const previousGenerationOwnership =
captureSharedGatewaySessionGenerationOwnership(
params.sharedGatewaySessionGenerationState,
);
// Snapshot both generation fields with the candidate revision.
// A stale preparation retries all three owners together.
const previousSharedGatewaySessionGeneration =
previousGenerationOwnership.generation;
const previousSharedGatewaySessionGenerationRequired =
params.sharedGatewaySessionGenerationState.required;
const prepared = await params.activateRuntimeSecrets(
previousSnapshot.sourceConfig,
{
reason: "reload",
activate: false,
},
);
const plan = buildReloadPlan(
diffConfigPaths(previousSnapshot.config, prepared.config),
);
const nextSharedGatewaySessionGeneration =
params.resolveSharedGatewaySessionGenerationForConfig(prepared.config);
let publishedSnapshotRevision: number | null = null;
let generationOwnership: SharedGatewaySessionGenerationOwnership | null = null;
const activateIfCurrent =
params.activateRuntimeSecrets.activatePreparedSnapshotIfCurrent;
if (activateIfCurrent) {
const activated = await activateIfCurrent(
prepared,
previousSnapshotRevision,
{
reason: "reload",
activate: true,
},
async () => {
publishedSnapshotRevision = getActiveSecretsRuntimeSnapshotRevision();
generationOwnership = claimSharedGatewaySessionGenerationIfOwned(
params.sharedGatewaySessionGenerationState,
previousGenerationOwnership,
nextSharedGatewaySessionGeneration,
);
},
() =>
isSharedGatewaySessionGenerationOwnershipCurrent(
params.sharedGatewaySessionGenerationState,
previousGenerationOwnership,
),
);
if (!activated) {
continue;
}
} else {
publishedSnapshotRevision = await activateSecretsRuntimeSnapshotIfCurrent(
prepared,
previousSnapshotRevision,
{
canActivate: () =>
isSharedGatewaySessionGenerationOwnershipCurrent(
params.sharedGatewaySessionGenerationState,
previousGenerationOwnership,
),
onActivated: () => {
generationOwnership = claimSharedGatewaySessionGenerationIfOwned(
params.sharedGatewaySessionGenerationState,
previousGenerationOwnership,
nextSharedGatewaySessionGeneration,
);
},
},
);
if (publishedSnapshotRevision === null) {
continue;
}
}
if (publishedSnapshotRevision === null || generationOwnership === null) {
throw new Error("Secrets runtime activation did not publish ownership.");
}
transaction = {
previousSnapshot,
previousSharedGatewaySessionGeneration,
previousSharedGatewaySessionGenerationRequired,
prepared,
plan,
nextSharedGatewaySessionGeneration,
sharedGatewaySessionGenerationChanged:
previousSharedGatewaySessionGeneration !== nextSharedGatewaySessionGeneration,
generationOwnership,
publishedSnapshotRevision,
};
if (
!isSharedGatewaySessionGenerationOwnershipCurrent(
params.sharedGatewaySessionGenerationState,
generationOwnership,
)
) {
throw new Error("secrets.reload was superseded by a newer config write");
}
break;
}
const {
prepared,
plan,
generationOwnership,
nextSharedGatewaySessionGeneration,
);
sharedGatewaySessionGenerationChanged =
previousSharedGatewaySessionGeneration !== nextSharedGatewaySessionGeneration;
sharedGatewaySessionGenerationChanged,
} = transaction;
if (sharedGatewaySessionGenerationChanged) {
disconnectStaleSharedGatewayAuthClients({
clients: params.clients,
@@ -246,17 +370,38 @@ export function createGatewayAuxHandlers(params: {
}
const restartFailures: ChannelKind[] = [];
for (const channel of restartChannels) {
if (
!isSharedGatewaySessionGenerationOwnershipCurrent(
params.sharedGatewaySessionGenerationState,
generationOwnership,
)
) {
throw new Error("secrets.reload was superseded by a newer config write");
}
params.logChannels.info(`restarting ${channel} channel after secrets reload`);
// Track for rollback before awaiting stopChannel: if stopChannel
// throws after partially stopping the channel (for example, a
// plugin hook rejects after the runtime already closed the
// socket), we still need the outer catch to attempt restart so
// the channel is not left down after a failed reload.
// throws after partially stopping the channel, still attempt recovery.
stoppedChannels.push(channel);
try {
await params.stopChannel(channel);
if (
!isSharedGatewaySessionGenerationOwnershipCurrent(
params.sharedGatewaySessionGenerationState,
generationOwnership,
)
) {
throw new Error("secrets.reload was superseded by a newer config write");
}
await params.startChannel(channel);
restartedChannels.add(channel);
if (
!isSharedGatewaySessionGenerationOwnershipCurrent(
params.sharedGatewaySessionGenerationState,
generationOwnership,
)
) {
throw new Error("secrets.reload was superseded by a newer config write");
}
} catch {
params.logChannels.info(
`failed to restart ${channel} channel after secrets reload`,
@@ -270,19 +415,48 @@ export function createGatewayAuxHandlers(params: {
);
}
}
if (
!finalizeOwnedSharedGatewaySessionGeneration(
params.sharedGatewaySessionGenerationState,
generationOwnership,
)
) {
throw new Error("secrets.reload was superseded by a newer config write");
}
return { warningCount: prepared.warnings.length };
} catch (err) {
await activateSecretsRuntimeSnapshot(previousSnapshot);
params.sharedGatewaySessionGenerationState.current =
previousSharedGatewaySessionGeneration;
params.sharedGatewaySessionGenerationState.required =
previousSharedGatewaySessionGenerationRequired;
if (sharedGatewaySessionGenerationChanged) {
disconnectStaleSharedGatewayAuthClients({
clients: params.clients,
expectedGeneration: previousSharedGatewaySessionGeneration,
});
let generationRestored = false;
if (transaction) {
const failedTransaction = transaction;
await restoreSecretsRuntimeSnapshotIfCurrent(
failedTransaction.previousSnapshot,
failedTransaction.publishedSnapshotRevision,
failedTransaction.prepared,
{
onActivated: () => {
generationRestored = replaceOwnedSharedGatewaySessionGenerationState(
params.sharedGatewaySessionGenerationState,
failedTransaction.generationOwnership,
{
current: failedTransaction.previousSharedGatewaySessionGeneration,
required:
failedTransaction.previousSharedGatewaySessionGenerationRequired,
},
);
},
},
);
}
if (generationRestored && transaction) {
if (transaction.sharedGatewaySessionGenerationChanged) {
disconnectStaleSharedGatewayAuthClients({
clients: params.clients,
expectedGeneration: transaction.previousSharedGatewaySessionGeneration,
});
}
}
// Generation ownership fences state rollback, not liveness.
// Restart stopped channels against whichever runtime is current now.
for (const channel of stoppedChannels) {
params.logChannels.info(
`rolling back ${channel} channel after secrets reload failure`,
+56
View File
@@ -186,6 +186,62 @@ describe("createGatewayCloseHandler", () => {
expect(deps.chatRunState.clear).toHaveBeenCalledTimes(1);
});
it("joins an in-flight config reload before mutable runtime teardown", async () => {
const events: string[] = [];
let releaseReload!: () => void;
const reloadStopped = new Promise<void>((resolve) => {
releaseReload = resolve;
});
const configReloader = {
stop: vi.fn(async () => {
events.push("reload:stopping");
await reloadStopped;
events.push("reload:stopped");
}),
};
const postReadySidecar = {
stop: vi.fn(async () => {
events.push("sidecar:stopped");
}),
};
const pluginServices = {
stop: vi.fn(async () => {
events.push("plugins:stopped");
}),
};
const stopChannel = vi.fn(async () => {
events.push("channel:stopped");
});
const close = createGatewayCloseHandler(
createGatewayCloseTestDeps({
channelIds: ["discord"],
configReloader,
postReadySidecars: [postReadySidecar],
pluginServices: pluginServices as never,
stopChannel,
}),
);
const closePromise = close({ reason: "test" });
await vi.waitFor(() => {
expect(events).toEqual(["reload:stopping"]);
});
expect(postReadySidecar.stop).not.toHaveBeenCalled();
expect(pluginServices.stop).not.toHaveBeenCalled();
expect(stopChannel).not.toHaveBeenCalled();
releaseReload();
await closePromise;
expect(events).toEqual([
"reload:stopping",
"reload:stopped",
"sidecar:stopped",
"plugins:stopped",
"channel:stopped",
]);
});
it("stops plugin services before channel runtimes", async () => {
const events: string[] = [];
const pluginServices = {
+3 -3
View File
@@ -729,6 +729,9 @@ export function createGatewayCloseHandler(
// info, and the completion line below reports duration and outcome.
shutdownLog.debug(`shutdown started: ${reason}`);
await measureCloseStep("config-reloader", () =>
shutdownStep("config-reloader", () => params.configReloader.stop(), warnings),
);
await measureCloseStep("gateway-shutdown-hook", () =>
shutdownStep(
"gateway:shutdown",
@@ -873,9 +876,6 @@ export function createGatewayCloseHandler(
]);
});
await shutdownStep("plugin-state-store", () => closePluginStateDatabase(), warnings);
await measureCloseStep("config-reloader", () =>
shutdownStep("config-reloader", () => params.configReloader.stop(), warnings),
);
await measureCloseStep("gmail-watcher", () =>
shutdownStep("gmail-watcher", () => stopGmailWatcherOnDemand(), warnings),
);
+11
View File
@@ -37,6 +37,17 @@ describe("createLazyGatewayCronState", () => {
hoisted.buildGatewayCronService.mockClear();
});
it("resolves its default store path from the prepared env", () => {
const stateRoot = "/tmp/openclaw-candidate-state";
const lazy = createLazyGatewayCronState({
...createParams(),
env: { ...process.env, OPENCLAW_STATE_DIR: stateRoot },
});
expect(lazy.storePath).toBe(`${stateRoot}/cron/jobs.json`);
expect(hoisted.buildGatewayCronService).not.toHaveBeenCalled();
});
it("does not build the heavy cron service until an async cron operation needs it", async () => {
const cron = createCronService();
const state = createCronState(cron);
+4 -2
View File
@@ -11,6 +11,7 @@ type LazyGatewayCronParams = {
cfg: OpenClawConfig;
deps: CliDeps;
broadcast: (event: string, payload: unknown, opts?: { dropIfSlow?: boolean }) => void;
env?: NodeJS.ProcessEnv;
};
type LoadedGatewayCronState = {
@@ -25,8 +26,9 @@ type LoadedGatewayCronState = {
/** Creates a cron state proxy that imports the real cron service on first use. */
export function createLazyGatewayCronState(params: LazyGatewayCronParams): GatewayCronState {
const storePath = resolveCronJobsStorePath(params.cfg.cron?.store);
const cronEnabled = process.env.OPENCLAW_SKIP_CRON !== "1" && params.cfg.cron?.enabled !== false;
const env = params.env ?? process.env;
const storePath = resolveCronJobsStorePath(params.cfg.cron?.store, env);
const cronEnabled = env.OPENCLAW_SKIP_CRON !== "1" && params.cfg.cron?.enabled !== false;
let loaded: LoadedGatewayCronState | null = null;
let stopped = false;
let lifecycleGeneration = 0;
+4 -2
View File
@@ -202,10 +202,12 @@ export function buildGatewayCronService(params: {
cfg: OpenClawConfig;
deps: CliDeps;
broadcast: (event: string, payload: unknown, opts?: { dropIfSlow?: boolean }) => void;
env?: NodeJS.ProcessEnv;
}): GatewayCronState {
const cronLogger = getChildLogger({ module: "cron" });
const storePath = resolveCronJobsStorePath(params.cfg.cron?.store);
const cronEnabled = process.env.OPENCLAW_SKIP_CRON !== "1" && params.cfg.cron?.enabled !== false;
const env = params.env ?? process.env;
const storePath = resolveCronJobsStorePath(params.cfg.cron?.store, env);
const cronEnabled = env.OPENCLAW_SKIP_CRON !== "1" && params.cfg.cron?.enabled !== false;
const findAgentEntry = (cfg: OpenClawConfig, agentId: string) =>
Array.isArray(cfg.agents?.list)
+15 -4
View File
@@ -81,20 +81,24 @@ describe("gateway startup import boundaries", () => {
expect(serverImpl.match(/await loadWorkerEnvironmentRuntimeModule\(\)/gu)).toHaveLength(3);
});
it("marks gateway close before awaiting gateway_stop hooks", () => {
it("fences config reload before gateway teardown and gateway_stop hooks", () => {
const serverImpl = readSource("src/gateway/server.impl.ts");
const closeStart = /close:\s*async\s*\([^)]*\)\s*=>/u.exec(serverImpl)?.index ?? -1;
const hookStart = serverImpl.indexOf("runGlobalGatewayStopSafely", closeStart);
const markStart = serverImpl.indexOf("markClosePreludeStarted();", closeStart);
const reloadStopStart = serverImpl.indexOf("await beginClosePrelude();", closeStart);
const terminalStopStart = serverImpl.indexOf("terminalSessions.disposeAll();", closeStart);
const markHelperStart = serverImpl.indexOf("const markClosePreludeStarted = () => {");
const markHelperEnd = serverImpl.indexOf("};", markHelperStart);
const beginHelperStart = serverImpl.indexOf("const beginClosePrelude = async () => {");
const beginHelperEnd = serverImpl.indexOf("};", beginHelperStart);
const postReadyStart = serverImpl.indexOf("scheduleGatewayPostReadyMaintenance({");
const postReadyEnd = serverImpl.indexOf("});", postReadyStart);
const postReadyBlock = serverImpl.slice(postReadyStart, postReadyEnd);
expect(closeStart).toBeGreaterThan(-1);
expect(markStart).toBeGreaterThan(closeStart);
expect(markStart).toBeLessThan(hookStart);
expect(reloadStopStart).toBeGreaterThan(closeStart);
expect(reloadStopStart).toBeLessThan(terminalStopStart);
expect(reloadStopStart).toBeLessThan(hookStart);
expect(markHelperStart).toBeGreaterThan(-1);
expect(serverImpl.slice(markHelperStart, markHelperEnd)).toContain(
"clearPostReadyMaintenanceTimer();",
@@ -102,6 +106,13 @@ describe("gateway startup import boundaries", () => {
expect(serverImpl.slice(markHelperStart, markHelperEnd)).toContain(
"cronReconciliation.invalidate();",
);
expect(beginHelperStart).toBeGreaterThan(-1);
expect(serverImpl.slice(beginHelperStart, beginHelperEnd)).toContain(
"markClosePreludeStarted();",
);
expect(serverImpl.slice(beginHelperStart, beginHelperEnd)).toContain(
"await stopConfigReloaderForClose()",
);
expect(postReadyStart).toBeGreaterThan(-1);
expect(postReadyBlock).toContain("isClosing: () => closePreludeStarted");
expect(postReadyBlock).toContain("if (closePreludeStarted)");
+8 -4
View File
@@ -7,7 +7,11 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import { enqueueCommandInLane, resetCommandQueueStateForTest } from "../process/command-queue.js";
import { CommandLane } from "../process/lanes.js";
import { createDeferred } from "../test-utils/deferred.js";
import { applyGatewayLaneConcurrency } from "./server-lanes.js";
import { applyGatewayLaneConcurrency, resolveGatewayLaneConcurrency } from "./server-lanes.js";
function applyConfigLaneConcurrency(config: OpenClawConfig): void {
applyGatewayLaneConcurrency(resolveGatewayLaneConcurrency(config));
}
describe("applyGatewayLaneConcurrency", () => {
afterEach(() => {
@@ -15,7 +19,7 @@ describe("applyGatewayLaneConcurrency", () => {
});
it("uses the higher cron default when maxConcurrentRuns is unset", async () => {
applyGatewayLaneConcurrency({} as OpenClawConfig);
applyConfigLaneConcurrency({} as OpenClawConfig);
let activeRuns = 0;
let peakActiveRuns = 0;
@@ -53,7 +57,7 @@ describe("applyGatewayLaneConcurrency", () => {
});
it("applies cron maxConcurrentRuns to the cron-nested lane used by cron agent turns", async () => {
applyGatewayLaneConcurrency({ cron: { maxConcurrentRuns: 2 } } as OpenClawConfig);
applyConfigLaneConcurrency({ cron: { maxConcurrentRuns: 2 } } as OpenClawConfig);
let activeRuns = 0;
let peakActiveRuns = 0;
@@ -92,7 +96,7 @@ describe("applyGatewayLaneConcurrency", () => {
});
it("keeps the shared nested lane at its default concurrency", async () => {
applyGatewayLaneConcurrency({ cron: { maxConcurrentRuns: 2 } } as OpenClawConfig);
applyConfigLaneConcurrency({ cron: { maxConcurrentRuns: 2 } } as OpenClawConfig);
let startedRuns = 0;
const releaseRuns = createDeferred();
+22 -7
View File
@@ -6,11 +6,26 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import { setCommandLaneConcurrency } from "../process/command-queue.js";
import { CommandLane } from "../process/lanes.js";
export function applyGatewayLaneConcurrency(cfg: OpenClawConfig) {
const cronMaxConcurrentRuns = resolveCronMaxConcurrentRuns(cfg.cron);
setCommandLaneConcurrency(CommandLane.Cron, cronMaxConcurrentRuns);
// Cron isolated agent turns remap inner LLM work to this lane.
setCommandLaneConcurrency(CommandLane.CronNested, cronMaxConcurrentRuns);
setCommandLaneConcurrency(CommandLane.Main, resolveAgentMaxConcurrent(cfg));
setCommandLaneConcurrency(CommandLane.Subagent, resolveSubagentMaxConcurrent(cfg));
export type GatewayLaneConcurrency = {
cron: number;
main: number;
subagent: number;
};
export function resolveGatewayLaneConcurrency(cfg: OpenClawConfig): GatewayLaneConcurrency {
return {
cron: resolveCronMaxConcurrentRuns(cfg.cron),
main: resolveAgentMaxConcurrent(cfg),
subagent: resolveSubagentMaxConcurrent(cfg),
};
}
export function applyGatewayLaneConcurrency(concurrency: GatewayLaneConcurrency): void {
// Resolution is deliberately separate: this commit-edge applier only updates
// live queue state and cannot reject a config midway through publication.
setCommandLaneConcurrency(CommandLane.Cron, concurrency.cron);
// Cron isolated agent turns remap inner LLM work to this lane.
setCommandLaneConcurrency(CommandLane.CronNested, concurrency.cron);
setCommandLaneConcurrency(CommandLane.Main, concurrency.main);
setCommandLaneConcurrency(CommandLane.Subagent, concurrency.subagent);
}
@@ -31,7 +31,7 @@ const mocks = vi.hoisted(() => ({
(params: { agentDir?: string }) => params.agentDir,
),
clearRuntimeAuthProfileStoreSnapshots: vi.fn(),
refreshActiveSecretsRuntimeSnapshot: vi.fn(async () => false),
refreshActiveProviderAuthRuntimeSnapshot: vi.fn(async () => false),
clearCurrentProviderAuthState: vi.fn(),
warmCurrentProviderAuthStateOffMainThread: vi.fn(async (_cfg: unknown) => {}),
buildAuthHealthSummary: vi.fn(
@@ -79,7 +79,7 @@ vi.mock("../../infra/provider-usage.load.js", () => ({
}));
vi.mock("../../secrets/runtime.js", () => ({
refreshActiveSecretsRuntimeSnapshot: mocks.refreshActiveSecretsRuntimeSnapshot,
refreshActiveProviderAuthRuntimeSnapshot: mocks.refreshActiveProviderAuthRuntimeSnapshot,
}));
vi.mock("../../agents/model-provider-auth.js", () => ({
@@ -230,7 +230,7 @@ function resetAuthStatusMocks(): void {
providers: [],
});
mocks.loadProviderUsageSummary.mockResolvedValue(emptyUsageSummary());
mocks.refreshActiveSecretsRuntimeSnapshot.mockResolvedValue(false);
mocks.refreshActiveProviderAuthRuntimeSnapshot.mockResolvedValue(false);
}
function firstExternalCliAuthOption() {
@@ -387,7 +387,7 @@ describe("models.authStatus", () => {
await handler(createOptions({ refresh: true }));
expect(mocks.buildAuthHealthSummary).toHaveBeenCalledTimes(2);
expect(mocks.refreshActiveSecretsRuntimeSnapshot).toHaveBeenCalledTimes(1);
expect(mocks.refreshActiveProviderAuthRuntimeSnapshot).toHaveBeenCalledTimes(1);
expect(mocks.clearRuntimeAuthProfileStoreSnapshots).toHaveBeenCalledTimes(1);
const clearOrder = mocks.clearRuntimeAuthProfileStoreSnapshots.mock.invocationCallOrder[0];
const refreshReadOrder = mocks.ensureAuthProfileStore.mock.invocationCallOrder.at(-1);
@@ -395,21 +395,23 @@ describe("models.authStatus", () => {
});
it("keeps refreshed secrets runtime snapshots on explicit refresh", async () => {
mocks.refreshActiveSecretsRuntimeSnapshot.mockResolvedValueOnce(true);
mocks.refreshActiveProviderAuthRuntimeSnapshot.mockResolvedValueOnce(true);
await handler(createOptions({ refresh: true }));
expect(mocks.refreshActiveSecretsRuntimeSnapshot).toHaveBeenCalledTimes(1);
expect(mocks.refreshActiveProviderAuthRuntimeSnapshot).toHaveBeenCalledTimes(1);
expect(mocks.clearRuntimeAuthProfileStoreSnapshots).not.toHaveBeenCalled();
expect(mocks.ensureAuthProfileStore).toHaveBeenCalledTimes(1);
});
it("keeps last-good secrets runtime snapshots when explicit refresh fails", async () => {
mocks.refreshActiveSecretsRuntimeSnapshot.mockRejectedValueOnce(new Error("refresh failed"));
mocks.refreshActiveProviderAuthRuntimeSnapshot.mockRejectedValueOnce(
new Error("refresh failed"),
);
await handler(createOptions({ refresh: true }));
expect(mocks.refreshActiveSecretsRuntimeSnapshot).toHaveBeenCalledTimes(1);
expect(mocks.refreshActiveProviderAuthRuntimeSnapshot).toHaveBeenCalledTimes(1);
expect(mocks.clearRuntimeAuthProfileStoreSnapshots).not.toHaveBeenCalled();
expect(mocks.ensureAuthProfileStore).toHaveBeenCalledTimes(1);
});
@@ -421,6 +423,40 @@ describe("models.authStatus", () => {
expect(mocks.buildAuthHealthSummary).toHaveBeenCalledTimes(2);
});
it("does not cache status captured before a concurrent logout", async () => {
let releaseUsage: (() => void) | undefined;
const usageBlocked = new Promise<void>((resolve) => {
releaseUsage = resolve;
});
const oauthProfile = {
profileId: "openrouter:default",
provider: "openrouter",
type: "oauth",
status: "ok",
source: "store",
label: "openrouter:default",
} satisfies AuthHealthSummary["profiles"][number];
mocks.buildAuthHealthSummary.mockReturnValue({
now: 0,
warnAfterMs: 0,
profiles: [oauthProfile],
providers: [{ provider: "openrouter", status: "ok", profiles: [oauthProfile] }],
});
mocks.loadProviderUsageSummary.mockImplementationOnce(async () => {
await usageBlocked;
return emptyUsageSummary();
});
const inFlightStatus = handler(createOptions());
await vi.waitFor(() => expect(mocks.loadProviderUsageSummary).toHaveBeenCalledOnce());
await logoutHandler(createLogoutOptions({ provider: "openrouter" }));
releaseUsage?.();
await inFlightStatus;
await handler(createOptions());
expect(mocks.buildAuthHealthSummary).toHaveBeenCalledTimes(2);
});
it("does not query usage for api-key-only providers", async () => {
mocks.buildAuthHealthSummary.mockReturnValue({
now: 0,
@@ -819,7 +855,7 @@ describe("models.authLogout", () => {
provider: "openrouter",
agentDir: "/tmp/agent",
});
expect(mocks.refreshActiveSecretsRuntimeSnapshot).toHaveBeenCalledTimes(1);
expect(mocks.refreshActiveProviderAuthRuntimeSnapshot).toHaveBeenCalledTimes(1);
expect(mocks.clearCurrentProviderAuthState).toHaveBeenCalled();
expect(mocks.warmCurrentProviderAuthStateOffMainThread).toHaveBeenCalledWith({});
const [ok, payload] = firstRespondCall(opts) ?? [];
@@ -942,7 +978,9 @@ describe("models.authLogout", () => {
it("does not abort runs when runtime auth snapshot refresh fails", async () => {
await expectLogoutFailureDoesNotAbortRun({
arrangeFailure: () => {
mocks.refreshActiveSecretsRuntimeSnapshot.mockRejectedValue(new Error("refresh failed"));
mocks.refreshActiveProviderAuthRuntimeSnapshot.mockRejectedValue(
new Error("refresh failed"),
);
},
message: "refresh failed",
});
@@ -37,7 +37,7 @@ import type {
UsageWindow,
} from "../../infra/provider-usage.types.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { refreshActiveSecretsRuntimeSnapshot } from "../../secrets/runtime.js";
import { refreshActiveProviderAuthRuntimeSnapshot } from "../../secrets/runtime.js";
import { asDateTimestampMs } from "../../shared/number-coercion.js";
import { abortChatRunsForProvider, type ChatAbortOps } from "../chat-abort.js";
import { formatForLog } from "../ws-log.js";
@@ -106,6 +106,7 @@ export type ModelAuthLogoutResult = {
const CACHE_TTL_MS = 60_000;
let cached: { ts: number; result: ModelAuthStatusResult } | null = null;
let cacheGeneration = 0;
/**
* Invalidate the in-memory cache. Reserved for future gateway-side auth
@@ -114,6 +115,7 @@ let cached: { ts: number; result: ModelAuthStatusResult } | null = null;
* `{refresh: true}` param cover the stale-data window.
*/
export function invalidateModelAuthStatusCache(): void {
cacheGeneration += 1;
cached = null;
// The prepared provider-auth map (model-provider-auth.ts) was built from
// the pre-mutation auth state, so it must be invalidated alongside this
@@ -126,7 +128,7 @@ export function invalidateModelAuthStatusCache(): void {
async function refreshModelAuthStatusRuntimeState(): Promise<void> {
invalidateModelAuthStatusCache();
try {
if (await refreshActiveSecretsRuntimeSnapshot()) {
if (await refreshActiveProviderAuthRuntimeSnapshot()) {
return;
}
} catch (err) {
@@ -432,9 +434,10 @@ export const modelsAuthStatusHandlers: GatewayRequestHandlers = {
);
return;
}
await refreshActiveSecretsRuntimeSnapshot();
// Fence status work that may have captured the removed profiles before
// it awaits auxiliary usage. It must not repopulate the cache afterward.
invalidateModelAuthStatusCache();
clearCurrentProviderAuthState();
await refreshActiveProviderAuthRuntimeSnapshot();
void warmCurrentProviderAuthStateOffMainThread(context.getRuntimeConfig()).catch(
(err: unknown) => {
log.warn(`provider auth state rewarm after logout failed: ${formatForLog(err)}`);
@@ -468,6 +471,7 @@ export const modelsAuthStatusHandlers: GatewayRequestHandlers = {
if (bypassCache) {
await refreshModelAuthStatusRuntimeState();
}
const publishGeneration = cacheGeneration;
const cfg = context.getRuntimeConfig();
const agentDir = resolveDefaultAgentDir(cfg);
// Use the external-profile-aware store for status reads so the dashboard
@@ -532,7 +536,9 @@ export const modelsAuthStatusHandlers: GatewayRequestHandlers = {
mapProvider(prov, usageByProvider, configured.expectsOAuth),
);
const result: ModelAuthStatusResult = { ts: now, providers };
cached = { ts: now, result };
if (publishGeneration === cacheGeneration) {
cached = { ts: now, result };
}
respond(true, result, undefined);
} catch (err) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)));
@@ -6,6 +6,7 @@
* itself already tracked "active"/"disabled" correctly.
*/
import { describe, expect, it, vi } from "vitest";
import { getRuntimeAuthProfileStoreCredentialsRevision } from "../agents/auth-profiles/runtime-snapshots.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { GatewayPluginReloadResult } from "./server-reload-handlers.js";
import { startManagedGatewayConfigReloader } from "./server-reload-handlers.js";
@@ -73,13 +74,16 @@ describe("startManagedGatewayConfigReloader hotReloadStatus plumbing", () => {
sourceConfig: config,
config,
authStores: [],
authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(),
warnings: [],
webTools: {},
})) as never,
resolveSharedGatewaySessionGenerationForConfig: () => undefined,
sharedGatewaySessionGenerationState: { current: undefined, required: null },
prepareTerminalConfig: vi.fn(),
reconcileTerminalSessions: vi.fn(),
commitTerminalConfig: vi.fn(),
acceptTerminalConfig: vi.fn(),
clients: [],
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -197,18 +197,23 @@ describe("server-runtime-services", () => {
};
const cronReconciliation = createTestCronReconciliation();
const logCron = { error: vi.fn() };
const onStartError = vi.fn(() => {
expect(getActiveGatewayRootWorkCount()).toBe(1);
});
startGatewayCronWithLogging({
cronState: createTestCronState(cron),
cronReconciliation,
reason: "startup",
config: {} as never,
onStartError,
logCron,
});
await vi.waitFor(() =>
expect(logCron.error).toHaveBeenCalledWith("failed to start: Error: store unavailable"),
);
expect(onStartError).toHaveBeenCalledOnce();
expect(cronReconciliation.complete).not.toHaveBeenCalled();
expect(getActiveGatewayRootWorkCount()).toBe(0);
});
+12 -4
View File
@@ -34,6 +34,7 @@ export function startGatewayCronWithLogging(params: {
reason: "startup" | "reload";
config: OpenClawConfig;
afterStart?: () => Promise<void>;
onStartError?: (error: unknown) => void;
logCron: { error: (message: string) => void };
}): void {
const reconciliation = params.cronReconciliation.arm({
@@ -42,10 +43,17 @@ export function startGatewayCronWithLogging(params: {
cronState: params.cronState,
});
void runWithGatewayIndependentRootWorkAdmission(async () => {
await params.cronState.cron.start();
await params.afterStart?.();
await reconciliation.complete();
}).catch((err: unknown) => params.logCron.error(`failed to start: ${String(err)}`));
try {
await params.cronState.cron.start();
await params.afterStart?.();
await reconciliation.complete();
} catch (err) {
params.logCron.error(`failed to start: ${String(err)}`);
// Recovery callbacks must run before this independent root releases its
// admission fence; restart and suspension cannot race past this point.
params.onStartError?.(err);
}
}).catch((err: unknown) => params.logCron.error(`failed to enter start root: ${String(err)}`));
}
function clearGatewayMaintenanceHandles(maintenance: GatewayMaintenanceHandles | null): void {
@@ -0,0 +1,109 @@
import { afterEach, describe, expect, it } from "vitest";
import { getRuntimeAuthProfileStoreCredentialsRevision } from "../agents/auth-profiles/runtime-snapshots.js";
import { createEmptyRuntimeWebToolsMetadata } from "../secrets/runtime-fast-path.js";
import {
activateSecretsRuntimeSnapshot,
clearSecretsRuntimeSnapshot,
getActiveSecretsRuntimeSnapshotRevision,
} from "../secrets/runtime.js";
import {
captureSharedGatewaySessionGenerationOwnership,
claimSharedGatewaySessionGeneration,
enforceSharedGatewaySessionGenerationForConfigWrite,
finalizeOwnedSharedGatewaySessionGeneration,
replaceSharedGatewaySessionGenerationState,
setRequiredSharedGatewaySessionGenerationIfOwned,
type SharedGatewaySessionGenerationState,
} from "./server-shared-auth-generation.js";
describe("shared gateway generation publication", () => {
afterEach(() => {
clearSecretsRuntimeSnapshot();
});
it("normalizes a matching required marker after a same-generation refresh", () => {
const state: SharedGatewaySessionGenerationState = {
current: "generation-a",
required: "generation-a",
};
const ownership = claimSharedGatewaySessionGeneration(state, "generation-a");
const snapshot = {
sourceConfig: {},
config: {},
authStores: [],
authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(),
warnings: [],
webTools: createEmptyRuntimeWebToolsMetadata(),
};
activateSecretsRuntimeSnapshot(snapshot);
const publishedRevision = getActiveSecretsRuntimeSnapshotRevision();
activateSecretsRuntimeSnapshot(snapshot);
expect(getActiveSecretsRuntimeSnapshotRevision()).toBeGreaterThan(publishedRevision);
expect(finalizeOwnedSharedGatewaySessionGeneration(state, ownership)).toBe(true);
expect(state).toEqual({ current: "generation-a", required: null });
});
it("does not clear a same-generation required marker owned by a newer config write", () => {
const state: SharedGatewaySessionGenerationState = {
current: "generation-a",
required: "generation-a",
};
const ownership = claimSharedGatewaySessionGeneration(state, "generation-a");
enforceSharedGatewaySessionGenerationForConfigWrite({
state,
nextConfig: { gateway: { reload: { mode: "off" } } },
resolveRuntimeSnapshotGeneration: () => "generation-a",
clients: [],
});
expect(finalizeOwnedSharedGatewaySessionGeneration(state, ownership)).toBe(false);
expect(state).toEqual({ current: "generation-a", required: "generation-a" });
});
it("clears the previous required generation after a credential rotation commits", () => {
const state: SharedGatewaySessionGenerationState = {
current: "generation-a",
required: "generation-a",
};
const ownership = claimSharedGatewaySessionGeneration(state, "generation-b");
expect(finalizeOwnedSharedGatewaySessionGeneration(state, ownership)).toBe(true);
expect(state).toEqual({ current: "generation-b", required: null });
});
it("does not overwrite a newer published generation", () => {
const state: SharedGatewaySessionGenerationState = {
current: "generation-a",
required: "generation-a",
};
const ownership = claimSharedGatewaySessionGeneration(state, "generation-a");
replaceSharedGatewaySessionGenerationState(state, {
current: "generation-b",
required: "generation-b",
});
expect(finalizeOwnedSharedGatewaySessionGeneration(state, ownership)).toBe(false);
expect(state).toEqual({ current: "generation-b", required: "generation-b" });
});
it("rejects a stale restart marker after a newer config write", () => {
const state: SharedGatewaySessionGenerationState = {
current: "generation-a",
required: null,
};
const restartOwnership = captureSharedGatewaySessionGenerationOwnership(state);
enforceSharedGatewaySessionGenerationForConfigWrite({
state,
nextConfig: { gateway: { reload: { mode: "off" } } },
resolveRuntimeSnapshotGeneration: () => "generation-b",
clients: [],
});
expect(
setRequiredSharedGatewaySessionGenerationIfOwned(state, restartOwnership, "generation-a"),
).toBeNull();
expect(state).toEqual({ current: "generation-b", required: "generation-b" });
});
});
+143 -4
View File
@@ -16,6 +16,31 @@ export type SharedGatewaySessionGenerationState = {
required: string | undefined | null;
};
export type SharedGatewaySessionGenerationOwnership = {
generation: string | undefined;
previousGeneration: string | undefined;
revision: number;
};
const stateRevisions = new WeakMap<SharedGatewaySessionGenerationState, number>();
function advanceStateRevision(state: SharedGatewaySessionGenerationState): number {
const revision = (stateRevisions.get(state) ?? 0) + 1;
stateRevisions.set(state, revision);
return revision;
}
/** Capture current generation-state ownership without mutating it. */
export function captureSharedGatewaySessionGenerationOwnership(
state: SharedGatewaySessionGenerationState,
): SharedGatewaySessionGenerationOwnership {
return {
generation: state.current,
previousGeneration: state.current,
revision: stateRevisions.get(state) ?? 0,
};
}
/** Disconnect shared-auth clients whose generation no longer matches the expected one. */
export function disconnectStaleSharedGatewayAuthClients(params: {
clients: Iterable<SharedGatewayAuthClient>;
@@ -68,11 +93,121 @@ export function setCurrentSharedGatewaySessionGeneration(
state.current = nextGeneration;
if (state.required === nextGeneration) {
state.required = null;
advanceStateRevision(state);
return;
}
if (state.required !== null && previousGeneration !== nextGeneration) {
state.required = null;
}
advanceStateRevision(state);
}
/** Claim current generation while preserving required until its transaction commits. */
export function claimSharedGatewaySessionGeneration(
state: SharedGatewaySessionGenerationState,
generation: string | undefined,
): SharedGatewaySessionGenerationOwnership {
const previousGeneration = state.current;
state.current = generation;
return { generation, previousGeneration, revision: advanceStateRevision(state) };
}
/** Claim current only while no later generation-state writer has run. */
export function claimSharedGatewaySessionGenerationIfOwned(
state: SharedGatewaySessionGenerationState,
ownership: SharedGatewaySessionGenerationOwnership,
generation: string | undefined,
): SharedGatewaySessionGenerationOwnership | null {
if (!isSharedGatewaySessionGenerationOwnershipCurrent(state, ownership)) {
return null;
}
return claimSharedGatewaySessionGeneration(state, generation);
}
/** Check whether a transaction still owns all generation-state mutations. */
export function isSharedGatewaySessionGenerationOwnershipCurrent(
state: SharedGatewaySessionGenerationState,
ownership: SharedGatewaySessionGenerationOwnership,
): boolean {
return (stateRevisions.get(state) ?? 0) === ownership.revision;
}
/** Replace both generation fields as one ownership-changing mutation. */
export function replaceSharedGatewaySessionGenerationState(
state: SharedGatewaySessionGenerationState,
next: Pick<SharedGatewaySessionGenerationState, "current" | "required">,
): void {
state.current = next.current;
state.required = next.required;
advanceStateRevision(state);
}
/** Replace both fields only while the caller still owns generation state. */
export function replaceOwnedSharedGatewaySessionGenerationState(
state: SharedGatewaySessionGenerationState,
ownership: SharedGatewaySessionGenerationOwnership,
next: Pick<SharedGatewaySessionGenerationState, "current" | "required">,
): boolean {
if (!isSharedGatewaySessionGenerationOwnershipCurrent(state, ownership)) {
return false;
}
replaceSharedGatewaySessionGenerationState(state, next);
return true;
}
/** Restore current only while preserving the required marker owned by the transaction. */
export function restoreOwnedCurrentSharedGatewaySessionGeneration(
state: SharedGatewaySessionGenerationState,
ownership: SharedGatewaySessionGenerationOwnership,
current: string | undefined,
): boolean {
if (!isSharedGatewaySessionGenerationOwnershipCurrent(state, ownership)) {
return false;
}
state.current = current;
advanceStateRevision(state);
return true;
}
/** Update the required marker as one ownership-changing mutation. */
export function setRequiredSharedGatewaySessionGeneration(
state: SharedGatewaySessionGenerationState,
required: string | undefined | null,
): void {
state.required = required;
advanceStateRevision(state);
}
/** Update required only while no later generation-state writer has run. */
export function setRequiredSharedGatewaySessionGenerationIfOwned(
state: SharedGatewaySessionGenerationState,
ownership: SharedGatewaySessionGenerationOwnership,
required: string | undefined | null,
): SharedGatewaySessionGenerationOwnership | null {
if (!isSharedGatewaySessionGenerationOwnershipCurrent(state, ownership)) {
return null;
}
setRequiredSharedGatewaySessionGeneration(state, required);
return captureSharedGatewaySessionGenerationOwnership(state);
}
/** Finalize only while no later generation-state writer has replaced this owner. */
export function finalizeOwnedSharedGatewaySessionGeneration(
state: SharedGatewaySessionGenerationState,
ownership: SharedGatewaySessionGenerationOwnership,
): boolean {
if (!isSharedGatewaySessionGenerationOwnershipCurrent(state, ownership)) {
return false;
}
state.current = ownership.generation;
if (
state.required === ownership.generation ||
(state.required !== null && ownership.previousGeneration !== ownership.generation)
) {
state.required = null;
}
advanceStateRevision(state);
return true;
}
/** Enforce shared auth generation behavior after a config write. */
@@ -85,16 +220,20 @@ export function enforceSharedGatewaySessionGenerationForConfigWrite(params: {
const reloadMode = resolveGatewayReloadSettings(params.nextConfig).mode;
const nextSharedGatewaySessionGeneration = params.resolveRuntimeSnapshotGeneration();
if (reloadMode === "off") {
params.state.current = nextSharedGatewaySessionGeneration;
params.state.required = nextSharedGatewaySessionGeneration;
replaceSharedGatewaySessionGenerationState(params.state, {
current: nextSharedGatewaySessionGeneration,
required: nextSharedGatewaySessionGeneration,
});
disconnectStaleSharedGatewayAuthClients({
clients: params.clients,
expectedGeneration: nextSharedGatewaySessionGeneration,
});
return;
}
params.state.required = null;
setCurrentSharedGatewaySessionGeneration(params.state, nextSharedGatewaySessionGeneration);
replaceSharedGatewaySessionGenerationState(params.state, {
current: nextSharedGatewaySessionGeneration,
required: null,
});
disconnectStaleSharedGatewayAuthClients({
clients: params.clients,
expectedGeneration: nextSharedGatewaySessionGeneration,
@@ -4,9 +4,21 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import { loadAuthProfileStoreWithoutExternalProfiles } from "../agents/auth-profiles.js";
import {
getRuntimeAuthProfileStoreCredentialsRevision,
getRuntimeAuthProfileStoreSnapshot,
setRuntimeAuthProfileStoreSnapshot,
} from "../agents/auth-profiles/runtime-snapshots.js";
import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.js";
import { measureDiagnosticsTimelineSpan } from "../infra/diagnostics-timeline.js";
import {
activateSecretsRuntimeSnapshotState,
clearSecretsRuntimeSnapshot,
getActiveSecretsRuntimeSnapshot,
getActiveSecretsRuntimeSnapshotRevision,
} from "../secrets/runtime-state.js";
import type { PreparedSecretsRuntimeSnapshot, SecretResolverWarning } from "../secrets/runtime.js";
import { KNOWN_WEAK_GATEWAY_TOKEN_PLACEHOLDERS } from "./known-weak-gateway-secrets.js";
import {
@@ -37,6 +49,15 @@ type GatewayStartupStateEmitterMock = ReturnType<
>;
const RESOLVED_GATEWAY_TOKEN = "resolved-gateway-token";
const autoCleanupTempDirs = useAutoCleanupTempDirTracker(afterEach);
function activateSecretsRuntimeSnapshotForTest(snapshot: PreparedSecretsRuntimeSnapshot): void {
activateSecretsRuntimeSnapshotState({
snapshot,
refreshContext: null,
refreshHandler: null,
});
}
function gatewayTokenConfig(config: OpenClawConfig): OpenClawConfig {
return {
@@ -75,6 +96,7 @@ function preparedSnapshot(config: OpenClawConfig): PreparedSecretsRuntimeSnapsho
sourceConfig: config,
config,
authStores: [],
authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(),
warnings: [],
webTools: {
search: {
@@ -255,6 +277,25 @@ function installGatewayStartupSecretsRuntimeMock(state: GatewayStartupSecretsRun
return {
prepareSecretsRuntimeSnapshot: runtimeState.prepareRuntimeSecretsSnapshot,
activateSecretsRuntimeSnapshot: runtimeState.activateRuntimeSecretsSnapshot,
preflightActiveSecretsRuntimeSnapshotRefresh: async ({
sourceConfig,
}: {
sourceConfig: OpenClawConfig;
}) => await runtimeState.prepareRuntimeSecretsSnapshot({ config: sourceConfig }),
refreshActiveSecretsRuntimeSnapshotForConfig: async ({
sourceConfig,
preflightResult,
}: {
sourceConfig: OpenClawConfig;
preflightResult?: unknown;
}) => {
const snapshot =
preflightResult && typeof preflightResult === "object"
? (preflightResult as PreparedSecretsRuntimeSnapshot)
: await runtimeState.prepareRuntimeSecretsSnapshot({ config: sourceConfig });
runtimeState.activateRuntimeSecretsSnapshot(snapshot);
return true;
},
};
});
}
@@ -348,6 +389,7 @@ describe("gateway startup config secret preflight", () => {
const previousSkipProviders = process.env.OPENCLAW_SKIP_PROVIDERS;
afterEach(() => {
clearSecretsRuntimeSnapshot();
if (previousSkipChannels === undefined) {
delete process.env.OPENCLAW_SKIP_CHANNELS;
} else {
@@ -360,6 +402,140 @@ describe("gateway startup config secret preflight", () => {
}
});
it("activates a prepared snapshot only while its expected predecessor is current", async () => {
const initial = preparedSnapshot(gatewayTokenConfig({}));
const refreshed = preparedSnapshotWithGatewayToken(initial.sourceConfig, "refreshed-token");
const candidate = preparedSnapshotWithGatewayToken(initial.sourceConfig, "candidate-token");
const activateRuntimeSecretsSnapshot = vi.fn(activateSecretsRuntimeSnapshotForTest);
const activateRuntimeSecrets = runtimeSecretsActivatorForTest({
prepareRuntimeSecretsSnapshot: vi.fn(async ({ config }) => preparedSnapshot(config)),
activateRuntimeSecretsSnapshot,
});
activateSecretsRuntimeSnapshotForTest(initial);
const initialRevision = getActiveSecretsRuntimeSnapshotRevision();
activateSecretsRuntimeSnapshotForTest(refreshed);
const refreshedRevision = getActiveSecretsRuntimeSnapshotRevision();
await expect(
activateRuntimeSecrets.activatePreparedSnapshotIfCurrent?.(candidate, initialRevision, {
reason: "reload",
activate: true,
}),
).resolves.toBeNull();
expect(activateRuntimeSecretsSnapshot).not.toHaveBeenCalled();
await expect(
activateRuntimeSecrets.activatePreparedSnapshotIfCurrent?.(candidate, refreshedRevision, {
reason: "reload",
activate: true,
}),
).resolves.toBe(candidate);
expect(activateRuntimeSecretsSnapshot).toHaveBeenCalledOnce();
});
it("rejects a managed reload prepared before an OAuth credential mutation", async () => {
const agentDir = "/tmp/openclaw-managed-auth-store-cas";
const initial = preparedSnapshot(gatewayTokenConfig({}));
const candidate: PreparedSecretsRuntimeSnapshot = {
...preparedSnapshotWithGatewayToken(initial.sourceConfig, "candidate-token"),
authStores: [
{
agentDir,
store: {
version: 1,
profiles: {
"openai:default": {
type: "oauth",
provider: "openai",
access: "access-old",
refresh: "refresh-old",
expires: Date.now() + 60_000,
},
},
},
},
],
};
const activateRuntimeSecretsSnapshot = vi.fn(activateSecretsRuntimeSnapshotForTest);
const activateRuntimeSecrets = runtimeSecretsActivatorForTest({
prepareRuntimeSecretsSnapshot: vi.fn(async ({ config }) => preparedSnapshot(config)),
activateRuntimeSecretsSnapshot,
});
activateSecretsRuntimeSnapshotForTest(initial);
const initialRevision = getActiveSecretsRuntimeSnapshotRevision();
setRuntimeAuthProfileStoreSnapshot(
{
version: 1,
profiles: {
"openai:default": {
type: "oauth",
provider: "openai",
access: "access-new",
refresh: "refresh-new",
expires: Date.now() + 120_000,
},
},
},
agentDir,
);
await expect(
activateRuntimeSecrets.activatePreparedSnapshotIfCurrent?.(candidate, initialRevision, {
reason: "reload",
activate: true,
}),
).resolves.toBeNull();
expect(activateRuntimeSecretsSnapshot).not.toHaveBeenCalled();
expect(getRuntimeAuthProfileStoreSnapshot(agentDir)?.profiles["openai:default"]).toMatchObject({
access: "access-new",
refresh: "refresh-new",
});
});
it("holds activation ownership through the accepted publication callback", async () => {
const initial = preparedSnapshot(gatewayTokenConfig({}));
const candidate = preparedSnapshotWithGatewayToken(initial.sourceConfig, "candidate-token");
const later = preparedSnapshotWithGatewayToken(initial.sourceConfig, "later-token");
const activateRuntimeSecrets = runtimeSecretsActivatorForTest({
prepareRuntimeSecretsSnapshot: vi.fn(async ({ config }) => preparedSnapshot(config)),
activateRuntimeSecretsSnapshot: vi.fn(activateSecretsRuntimeSnapshotForTest),
});
activateSecretsRuntimeSnapshotForTest(initial);
const initialRevision = getActiveSecretsRuntimeSnapshotRevision();
let releasePublication: (() => void) | undefined;
const publicationBlocked = new Promise<void>((resolve) => {
releasePublication = resolve;
});
let publicationStarted: (() => void) | undefined;
const publicationEntered = new Promise<void>((resolve) => {
publicationStarted = resolve;
});
const candidateActivation = activateRuntimeSecrets.activatePreparedSnapshotIfCurrent?.(
candidate,
initialRevision,
{ reason: "reload", activate: true },
async () => {
publicationStarted?.();
await publicationBlocked;
},
);
await publicationEntered;
let laterActivated = false;
const laterActivation = activateRuntimeSecrets
.activatePreparedSnapshot?.(later, { reason: "reload", activate: true })
.then(() => {
laterActivated = true;
});
await Promise.resolve();
expect(laterActivated).toBe(false);
releasePublication?.();
await candidateActivation;
await laterActivation;
expect(getActiveSecretsRuntimeSnapshot()?.config.gateway?.auth?.token).toBe("later-token");
});
it("measures startup auth subphases", async () => {
const prepareRuntimeSecretsSnapshot = vi.fn(async ({ config }) => preparedSnapshot(config));
const measured: string[] = [];
@@ -831,12 +1007,33 @@ describe("gateway startup config secret preflight", () => {
return {
prepareSecretsRuntimeSnapshot: state.prepareRuntimeSecretsSnapshot,
activateSecretsRuntimeSnapshot: state.activateRuntimeSecretsSnapshot,
preflightActiveSecretsRuntimeSnapshotRefresh: async ({
sourceConfig,
}: {
sourceConfig: OpenClawConfig;
}) => await state.prepareRuntimeSecretsSnapshot({ config: sourceConfig }),
refreshActiveSecretsRuntimeSnapshotForConfig: async ({
sourceConfig,
preflightResult,
}: {
sourceConfig: OpenClawConfig;
preflightResult?: unknown;
}) => {
const snapshot =
preflightResult && typeof preflightResult === "object"
? (preflightResult as PreparedSecretsRuntimeSnapshot)
: await state.prepareRuntimeSecretsSnapshot({ config: sourceConfig });
state.activateRuntimeSecretsSnapshot(snapshot);
return true;
},
};
});
try {
const { clearSecretsRuntimeSnapshot, getActiveSecretsRuntimeSnapshot } =
await import("../secrets/runtime-state.js");
const {
clearSecretsRuntimeSnapshot: clearImportedSecretsRuntimeSnapshot,
getActiveSecretsRuntimeSnapshot: getImportedSecretsRuntimeSnapshot,
} = await import("../secrets/runtime-state.js");
const { getRuntimeConfigSnapshotRefreshHandler } =
await import("../config/runtime-snapshot.js");
const result = await activateImportedStartupConfig(
@@ -852,7 +1049,7 @@ describe("gateway startup config secret preflight", () => {
expect(activateRuntimeSecretsSnapshot).not.toHaveBeenCalled();
expect(loadAuthProfileStoreWithoutExternalProfilesMock).not.toHaveBeenCalled();
expect(result.config.gateway?.auth?.token).toBe("startup-test-token");
expect(getActiveSecretsRuntimeSnapshot()?.config.gateway?.auth?.token).toBe(
expect(getImportedSecretsRuntimeSnapshot()?.config.gateway?.auth?.token).toBe(
"startup-test-token",
);
const refreshHandler = getRuntimeConfigSnapshotRefreshHandler();
@@ -872,7 +1069,7 @@ describe("gateway startup config secret preflight", () => {
loadAuthStore?: unknown;
}>(prepareRuntimeSecretsSnapshot);
expect(refreshInput.loadAuthStore).toBeUndefined();
clearSecretsRuntimeSnapshot();
clearImportedSecretsRuntimeSnapshot();
} finally {
isolatedEnv.cleanup();
vi.doUnmock("../agents/auth-profiles.js");
@@ -887,6 +1084,127 @@ describe("gateway startup config secret preflight", () => {
}
});
it("retries a stale startup fast-path preflight against the newer runtime context", async () => {
const agentDir = autoCleanupTempDirs.make("openclaw-startup-fast-path-cas-");
let clearImportedSecretsRuntimeSnapshot: (() => void) | undefined;
const config = (port: number) =>
gatewayTokenConfig(
asConfig({
agents: { list: [{ id: "default", agentDir }] },
gateway: { port },
}),
);
try {
// A preceding lazy-import test resets Vitest's module cache. Import this
// whole runtime graph together so the activator and handler share state.
const { createRuntimeSecretsActivator: createImportedRuntimeSecretsActivator } =
await import("./server-startup-config.js");
const secretsRuntime = await import("../secrets/runtime.js");
clearImportedSecretsRuntimeSnapshot = secretsRuntime.clearSecretsRuntimeSnapshot;
const activateRuntimeSecrets = createImportedRuntimeSecretsActivator(
runtimeSecretsActivatorOptionsForTest(),
);
await activateRuntimeSecrets(config(19_021), {
reason: "startup",
activate: true,
});
const { getRuntimeConfigSnapshotRefreshHandler } =
await import("../config/runtime-snapshot.js");
const staleRefreshHandler = getRuntimeConfigSnapshotRefreshHandler();
if (!staleRefreshHandler?.preflight) {
throw new Error("expected startup fast-path refresh preflight handler");
}
const desiredConfig = config(19_023);
const preflightResult = await staleRefreshHandler.preflight({
sourceConfig: desiredConfig,
});
const concurrent = await secretsRuntime.prepareSecretsRuntimeSnapshot({
config: config(19_022),
agentDirs: [agentDir],
loadAuthStore: () => ({
version: 1,
profiles: {
"openai:default": {
type: "api_key",
provider: "openai",
key: "newer-context-key",
},
},
}),
});
secretsRuntime.activateSecretsRuntimeSnapshot(concurrent);
await expect(
staleRefreshHandler.refresh({ sourceConfig: desiredConfig, preflightResult }),
).resolves.toBe(true);
const active = secretsRuntime.getActiveSecretsRuntimeSnapshot();
expect(active?.sourceConfig.gateway?.port).toBe(19_023);
expect(active?.authStores[0]?.store.profiles["openai:default"]).toMatchObject({
key: "newer-context-key",
});
} finally {
clearImportedSecretsRuntimeSnapshot?.();
rmSync(agentDir, { recursive: true, force: true });
}
});
it("grafts live auth stores onto one-shot config-write snapshots", async () => {
const agentDir = "/tmp/openclaw-managed-write-auth-store";
const credential = {
type: "api_key" as const,
provider: "openai",
key: "live-auth-store-key",
};
setRuntimeAuthProfileStoreSnapshot(
{ version: 1, profiles: { "openai:default": credential } },
agentDir,
);
const active = preparedSnapshot(gatewayTokenConfig({}));
active.authStores = [
{
agentDir,
store: { version: 1, profiles: { "openai:default": credential } },
},
];
active.authStoreCredentialsRevision = getRuntimeAuthProfileStoreCredentialsRevision();
activateSecretsRuntimeSnapshotState({
snapshot: active,
refreshContext: {
env: {},
explicitAgentDirs: null,
includeAuthStoreRefs: true,
loadablePluginOrigins: new Map(),
},
refreshHandler: null,
});
const prepareRuntimeSecretsSnapshot = vi.fn(async (params: { config: OpenClawConfig }) =>
preparedSnapshot(params.config),
);
const activateRuntimeSecrets = runtimeSecretsActivatorForTest({
prepareRuntimeSecretsSnapshot,
activateRuntimeSecretsSnapshot: activateSecretsRuntimeSnapshotForTest,
});
const prepared = await activateRuntimeSecrets(
gatewayTokenConfig({ logging: { level: "debug" } }),
{
reason: "reload",
activate: false,
includeAuthStoreRefs: false,
},
);
expect(prepared.authStores[0]?.store.profiles["openai:default"]).toEqual(credential);
await activateRuntimeSecrets.activatePreparedSnapshot?.(prepared, {
reason: "reload",
activate: true,
includeAuthStoreRefs: false,
});
expect(getRuntimeAuthProfileStoreSnapshot(agentDir)?.profiles["openai:default"]).toEqual(
credential,
);
});
it("keeps the full secrets runtime path when startup config has a SecretRef", async () => {
const harness = createGatewayStartupSecretsRuntimeHarness("openclaw-startup-secret-ref-");
await expectImportedStartupConfigUsesFullSecretsRuntime(
+78 -74
View File
@@ -21,19 +21,16 @@ import { measureDiagnosticsTimelineSpan } from "../infra/diagnostics-timeline.js
import { isTruthyEnvValue } from "../infra/env.js";
import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
import {
prepareSecretsRuntimeFastPathSnapshot,
resolveRefreshAgentDirs,
} from "../secrets/runtime-fast-path.js";
import { prepareSecretsRuntimeFastPathSnapshot } from "../secrets/runtime-fast-path.js";
import {
GATEWAY_AUTH_SURFACE_PATHS,
evaluateGatewayAuthSurfaceStates,
} from "../secrets/runtime-gateway-auth-surfaces.js";
import {
activateSecretsRuntimeSnapshotState,
getActiveSecretsRuntimeSnapshot,
getLiveSecretsRuntimeAuthStores,
setPreparedSecretsRuntimeSnapshotRefreshContext,
graftActiveSecretsRuntimeAuthState,
getActiveSecretsRuntimeSnapshotRevision,
hasCurrentAuthStoreCredentialsRevision,
} from "../secrets/runtime-state.js";
import { createLazyPromise } from "../shared/lazy-runtime.js";
import { resolveGatewayAuth } from "./auth.js";
@@ -61,6 +58,8 @@ type PreparedRuntimeSecretsSnapshot = Awaited<ReturnType<PrepareRuntimeSecretsSn
type RuntimeSecretsActivationParams = {
reason: "startup" | "reload" | "restart-check";
activate: boolean;
env?: NodeJS.ProcessEnv;
includeAuthStoreRefs?: boolean;
};
/** Gateway startup hook that prepares secrets and optionally activates the prepared snapshot. */
@@ -72,6 +71,13 @@ export type ActivateRuntimeSecrets = ((
snapshot: PreparedRuntimeSecretsSnapshot,
params: RuntimeSecretsActivationParams,
) => Promise<PreparedRuntimeSecretsSnapshot>;
activatePreparedSnapshotIfCurrent?: (
snapshot: PreparedRuntimeSecretsSnapshot,
expectedRevision: number,
params: RuntimeSecretsActivationParams,
onActivated?: () => void | Promise<void>,
canActivate?: () => boolean,
) => Promise<PreparedRuntimeSecretsSnapshot | null>;
};
type GatewayStartupConfigOverrides = {
@@ -217,6 +223,7 @@ export function createRuntimeSecretsActivator(params: {
activationParams: RuntimeSecretsActivationParams,
options?: {
activateRuntimeSecretsSnapshot?: (snapshot: PreparedRuntimeSecretsSnapshot) => void;
onActivated?: () => void;
},
) => {
assertRuntimeGatewayAuthNotKnownWeak(prepared.config);
@@ -224,6 +231,9 @@ export function createRuntimeSecretsActivator(params: {
const activateRuntimeSecretsSnapshot =
options?.activateRuntimeSecretsSnapshot ?? (await loadActivateRuntimeSecretsSnapshot());
activateRuntimeSecretsSnapshot(prepared);
// Invoke publication at the activation edge so no microtask can replace
// the candidate before its runtime commit begins.
options?.onActivated?.();
logGatewayAuthSurfaceDiagnostics(prepared, params.logSecrets);
}
for (const warning of prepared.warnings) {
@@ -284,78 +294,20 @@ export function createRuntimeSecretsActivator(params: {
if (fastPath) {
// The startup fast path avoids importing the full secrets runtime
// until refresh/preflight needs dynamic provider or auth-store work.
const coercePreflightSnapshot = (
value: unknown,
sourceConfig: OpenClawConfig,
): PreparedRuntimeSecretsSnapshot | null => {
if (!value || typeof value !== "object") {
return null;
}
const candidate = value as PreparedRuntimeSecretsSnapshot;
return isDeepStrictEqual(candidate.sourceConfig, sourceConfig) ? candidate : null;
};
const prepareFastPathRuntimeSnapshot = async (
secretsRuntime: typeof import("../secrets/runtime.js"),
sourceConfig: OpenClawConfig,
includeAuthStoreRefs: boolean | undefined,
) =>
await secretsRuntime.prepareSecretsRuntimeSnapshot({
config: sourceConfig,
env: fastPath.refreshContext.env,
agentDirs: resolveRefreshAgentDirs(sourceConfig, fastPath.refreshContext),
includeAuthStoreRefs:
includeAuthStoreRefs ?? fastPath.refreshContext.includeAuthStoreRefs,
loadablePluginOrigins: fastPath.refreshContext.loadablePluginOrigins,
...(fastPath.refreshContext.manifestRegistry
? { manifestRegistry: fastPath.refreshContext.manifestRegistry }
: {}),
...(fastPath.usesAuthStoreFallback || !fastPath.refreshContext.loadAuthStore
? {}
: { loadAuthStore: fastPath.refreshContext.loadAuthStore }),
});
return await finishPreparedSnapshot(fastPath.snapshot, activationParams, {
activateRuntimeSecretsSnapshot: (snapshot) =>
activateSecretsRuntimeSnapshotState({
snapshot,
refreshContext: fastPath.refreshContext,
refreshHandler: {
preflight: async ({ sourceConfig, includeAuthStoreRefs }) => {
const secretsRuntime = await loadSecretsRuntime();
const activeSnapshot = getActiveSecretsRuntimeSnapshot();
if (!activeSnapshot) {
return false;
}
return await prepareFastPathRuntimeSnapshot(
secretsRuntime,
sourceConfig,
includeAuthStoreRefs,
);
},
refresh: async ({ sourceConfig, includeAuthStoreRefs, preflightResult }) => {
const secretsRuntime = await loadSecretsRuntime();
const activeSnapshot = getActiveSecretsRuntimeSnapshot();
const oneShotSkipAuthStoreRefs =
includeAuthStoreRefs === false &&
fastPath.refreshContext.includeAuthStoreRefs;
const refreshed =
coercePreflightSnapshot(preflightResult, sourceConfig) ??
(await prepareFastPathRuntimeSnapshot(
secretsRuntime,
sourceConfig,
includeAuthStoreRefs,
));
if (oneShotSkipAuthStoreRefs && activeSnapshot) {
// Preserve live auth-store handles across a one-shot
// preflight that intentionally skipped auth-store refs.
refreshed.authStores = getLiveSecretsRuntimeAuthStores();
setPreparedSecretsRuntimeSnapshotRefreshContext(
refreshed,
fastPath.refreshContext,
);
}
secretsRuntime.activateSecretsRuntimeSnapshot(refreshed);
return true;
},
preflight: async (refreshParams) =>
await (
await loadSecretsRuntime()
).preflightActiveSecretsRuntimeSnapshotRefresh(refreshParams),
refresh: async (refreshParams) =>
await (
await loadSecretsRuntime()
).refreshActiveSecretsRuntimeSnapshotForConfig(refreshParams),
},
}),
});
@@ -375,6 +327,8 @@ export function createRuntimeSecretsActivator(params: {
() =>
prepareRuntimeSecretsSnapshot({
config: pruneSkippedStartupSecretSurfaces(config),
...(activationParams.env ? { env: activationParams.env } : {}),
includeAuthStoreRefs: activationParams.includeAuthStoreRefs,
...(startupManifestRegistry ? { manifestRegistry: startupManifestRegistry } : {}),
...(params.pluginMetadataSnapshot
? { pluginMetadataSnapshot: params.pluginMetadataSnapshot }
@@ -384,11 +338,14 @@ export function createRuntimeSecretsActivator(params: {
{
attributes: secretsPrepareTimelineAttributes(config, activationParams),
config,
env: process.env,
env: activationParams.env ?? process.env,
omitErrorMessage: true,
phase: activationParams.reason,
},
);
if (activationParams.includeAuthStoreRefs === false) {
graftActiveSecretsRuntimeAuthState(prepared);
}
return await finishPreparedSnapshot(prepared, activationParams);
} catch (err) {
return handleSecretsActivationError(err, activationParams, config);
@@ -404,6 +361,53 @@ export function createRuntimeSecretsActivator(params: {
}
});
activateRuntimeSecrets.activatePreparedSnapshotIfCurrent = async (
snapshot,
expectedRevision,
activationParams,
onActivated,
canActivate,
) => {
// Resolve the lazy activator before entering the compare-and-activate
// section so no await separates revision ownership from state publication.
const activateRuntimeSecretsSnapshot = activationParams.activate
? await loadActivateRuntimeSecretsSnapshot()
: undefined;
return await runWithSecretsActivationLock(async () => {
if (
getActiveSecretsRuntimeSnapshotRevision() !== expectedRevision ||
!hasCurrentAuthStoreCredentialsRevision(snapshot) ||
(canActivate && !canActivate())
) {
return null;
}
let activated: PreparedRuntimeSecretsSnapshot;
let publication: Promise<void> | undefined;
try {
activated = await finishPreparedSnapshot(
snapshot,
activationParams,
activateRuntimeSecretsSnapshot
? {
activateRuntimeSecretsSnapshot,
...(onActivated
? {
onActivated: () => {
publication = Promise.resolve(onActivated());
},
}
: {}),
}
: undefined,
);
} catch (err) {
return handleSecretsActivationError(err, activationParams, snapshot.sourceConfig);
}
await publication;
return activated;
});
};
return activateRuntimeSecrets;
}
+255 -55
View File
@@ -17,18 +17,28 @@ import {
import type { ChannelId } from "../channels/plugins/types.public.js";
import { createDefaultDeps } from "../cli/deps.js";
import { isRestartEnabled } from "../config/commands.flags.js";
import {
collectConfigRuntimeEnvOwnership,
initializePublishedConfigRuntimeEnv,
prepareConfigRuntimeEnv,
} from "../config/config-env-vars.js";
import { assertGatewayConfigEnvSelectionUnchanged } from "../config/gateway-env-selection.js";
import {
getRuntimeConfig,
getRuntimeConfigSourceSnapshot,
promoteConfigSnapshotToLastKnownGood,
readConfigFileSnapshot,
readConfigFileSnapshotForRuntimeTransaction,
registerConfigWriteListener,
setRuntimeConfigSnapshot,
type ReadConfigFileSnapshotWithPluginMetadataResult,
} from "../config/io.js";
import { isNixMode, normalizeStateDirEnv } from "../config/paths.js";
import { applyConfigOverrides } from "../config/runtime-overrides.js";
import { captureConfigOverrideApplier } from "../config/runtime-overrides.js";
import { resolveMainSessionKey } from "../config/sessions.js";
import type { GatewayAuthConfig } from "../config/types.gateway.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { isSecretRef } from "../config/types.secrets.js";
import { getActiveCronJobCount } from "../cron/active-jobs.js";
import {
isDiagnosticsEnabled,
@@ -42,7 +52,11 @@ import { isTruthyEnvValue, isVitestRuntimeEnv, logAcceptedEnvOption } from "../i
import { ensureOpenClawCliOnPath } from "../infra/path-env.js";
import type { PluginApprovalRequestPayload } from "../infra/plugin-approvals.js";
import { readGatewayRestartHandoffSync } from "../infra/restart-handoff.js";
import { setGatewaySigusr1RestartPolicy, setPreRestartDeferralCheck } from "../infra/restart.js";
import {
type GatewayRestartEmitter,
setGatewaySigusr1RestartPolicy,
setPreRestartDeferralCheck,
} from "../infra/restart.js";
import { enqueueSystemEvent } from "../infra/system-events.js";
import { upsertPresence } from "../infra/system-presence.js";
import type { VoiceWakeRoutingConfig } from "../infra/voicewake-routing.js";
@@ -91,7 +105,11 @@ import {
import { isLoopbackHost } from "./net.js";
import { disposeNodeConnectionNotifications } from "./node-connection-notifications.js";
import { createNodeReapprovalCoordinator } from "./node-reapproval-coordinator.js";
import { resolveGatewayStartupPluginActivationConfig } from "./plugin-activation-runtime-config.js";
import {
mergeActivationSectionsIntoRuntimeConfig,
resolveGatewayReloadPluginActivationCandidate,
resolveGatewayStartupPluginActivationConfig,
} from "./plugin-activation-runtime-config.js";
import {
listChannelPluginConfigTargetIds,
pluginConfigTargetsChanged,
@@ -109,7 +127,7 @@ import type { ChannelAutostartSuppression } from "./server-channels.js";
import { resolveGatewayControlUiRootState } from "./server-control-ui-root.js";
import { createLazyGatewayCronState } from "./server-cron-lazy.js";
import { createGatewayCronReconciliation } from "./server-cron-reconciled.js";
import { applyGatewayLaneConcurrency } from "./server-lanes.js";
import { applyGatewayLaneConcurrency, resolveGatewayLaneConcurrency } from "./server-lanes.js";
import { createGatewayServerLiveState, type GatewayServerLiveState } from "./server-live-state.js";
import { GATEWAY_EVENTS } from "./server-methods-list.js";
import { clearNodeWakeState } from "./server-methods/nodes-wake-state.js";
@@ -137,6 +155,7 @@ import { broadcastPresenceSnapshot } from "./server/presence-events.js";
import { createReadinessChecker } from "./server/readiness.js";
import { loadGatewayTlsRuntime } from "./server/tls.js";
import { resolveSharedGatewaySessionGeneration } from "./server/ws-shared-generation.js";
import { mergeGatewayAuthConfig, mergeGatewayTailscaleConfig } from "./startup-auth.js";
import { maybeSeedControlUiAllowedOriginsAtStartup } from "./startup-control-ui-origins.js";
import type { WorkerBundleProducer, WorkerNpmArtifact } from "./worker-environments/bundle.js";
import { createWorkerLiveEventReceiver } from "./worker-environments/live-events.js";
@@ -237,6 +256,7 @@ const logHealth = log.child("health");
const logCron = log.child("cron");
const logReload = log.child("reload");
const logHooks = log.child("hooks");
const logPlugins = log.child("plugins");
const logWsControl = log.child("ws");
const logSecrets = log.child("secrets");
@@ -547,6 +567,8 @@ export type GatewayServerOptions = {
* reparsing openclaw.json during server startup.
*/
startupConfigSnapshotRead?: ReadConfigFileSnapshotWithPluginMetadataResult;
/** Restart request override; direct servers fail closed on restart-required reloads. */
hotReloadRecovery?: GatewayRestartEmitter;
};
type SetupWizardRunner = NonNullable<GatewayServerOptions["wizardRunner"]>;
@@ -609,6 +631,7 @@ export async function startGatewayServer(
});
const { loadGatewayStartupConfigSnapshot } = await startupConfigModulePromise;
const envBeforeStartupConfigLoad = { ...process.env };
const startupConfigLoad = await startupTrace.measure("config.snapshot", () =>
loadGatewayStartupConfigSnapshot({
minimalTestGateway,
@@ -620,6 +643,27 @@ export async function startGatewayServer(
}),
);
const configSnapshot = startupConfigLoad.snapshot;
const startupAuthOverride = opts.auth ? structuredClone(opts.auth) : undefined;
const startupTailscaleOverride = opts.tailscale ? structuredClone(opts.tailscale) : undefined;
// Seed before secrets activation so every active/rollback snapshot carries
// the same runtime-only browser origin baseline.
const controlUiSeed = minimalTestGateway
? { config: configSnapshot.config, seededAllowedOrigins: false }
: await startupTrace.measure("control-ui.seed", () =>
maybeSeedControlUiAllowedOriginsAtStartup({
config: configSnapshot.config,
log,
runtimeBind: opts.bind,
runtimePort: port,
}),
);
const startupConfigSnapshot = controlUiSeed.seededAllowedOrigins
? {
...configSnapshot,
runtimeConfig: controlUiSeed.config,
config: controlUiSeed.config,
}
: configSnapshot;
const emitSecretsStateEvent = (
code: "SECRETS_RELOADER_DEGRADED" | "SECRETS_RELOADER_RECOVERED",
@@ -640,31 +684,66 @@ export async function startGatewayServer(
: {}),
});
let cfgAtStart: OpenClawConfig;
let startupInternalWriteHash: string | null = null;
let startupLastGoodSnapshot = configSnapshot;
const startupActivationSourceConfig = configSnapshot.sourceConfig;
const startupRuntimeConfig = applyConfigOverrides(configSnapshot.config);
const startupRuntimeConfig = captureConfigOverrideApplier()(startupConfigSnapshot.config);
startupTrace.setConfig(startupRuntimeConfig);
const { prepareGatewayStartupConfig } = await startupConfigModulePromise;
const authBootstrap = await startupTrace.measure(
"config.auth",
() =>
prepareGatewayStartupConfig({
configSnapshot,
authOverride: opts.auth,
tailscaleOverride: opts.tailscale,
configSnapshot: startupConfigSnapshot,
authOverride: startupAuthOverride,
tailscaleOverride: startupTailscaleOverride,
activateRuntimeSecrets,
log,
measure: (name, run, measureOptions) => startupTrace.measure(name, run, measureOptions),
}),
{ omitErrorMessage: true },
);
cfgAtStart = authBootstrap.cfg;
const cfgAtStart = authBootstrap.cfg;
startupTrace.setConfig(cfgAtStart);
if (authBootstrap.generatedToken) {
log.warn(formatRuntimeGatewayAuthTokenWarning());
}
const resolvedStartupAuthOverride = startupAuthOverride
? (Object.fromEntries(
(
[
"mode",
"token",
"password",
"allowTailscale",
"rateLimit",
"trustedProxy",
] as const satisfies readonly (keyof GatewayAuthConfig)[]
).flatMap((key) => {
if (startupAuthOverride[key] === undefined) {
return [];
}
if ((key === "token" || key === "password") && isSecretRef(startupAuthOverride[key])) {
return [];
}
const resolvedValue = cfgAtStart.gateway?.auth?.[key];
return resolvedValue === undefined ? [] : [[key, structuredClone(resolvedValue)]];
}),
) as GatewayAuthConfig)
: undefined;
const startupAuthSecretRefOverride = startupAuthOverride
? {
...(isSecretRef(startupAuthOverride.token)
? { token: structuredClone(startupAuthOverride.token) }
: {}),
...(isSecretRef(startupAuthOverride.password)
? { password: structuredClone(startupAuthOverride.password) }
: {}),
}
: undefined;
const reloadAuthOverride = authBootstrap.generatedToken
? mergeGatewayAuthConfig(resolvedStartupAuthOverride, { token: authBootstrap.generatedToken })
: resolvedStartupAuthOverride;
const diagnosticsEnabled = isDiagnosticsEnabled(cfgAtStart);
setDiagnosticsEnabledForProcess(diagnosticsEnabled);
if (diagnosticsEnabled) {
@@ -682,22 +761,103 @@ export async function startGatewayServer(
getActiveEmbeddedRunCount() +
getActiveCronJobCount() +
getActiveBackgroundExecSessionCount() +
getActiveGatewayRootWorkCount() +
getActiveGatewayRootWorkCount({ excludeCurrent: true }) +
getActiveTaskCount(),
);
// Unconditional startup migration: seed gateway.controlUi.allowedOrigins for existing
// non-loopback installs that upgraded to v2026.2.26+ without required origins.
const controlUiSeed = minimalTestGateway
? { config: cfgAtStart, seededAllowedOrigins: false }
: await startupTrace.measure("control-ui.seed", () =>
maybeSeedControlUiAllowedOriginsAtStartup({
config: cfgAtStart,
log,
runtimeBind: opts.bind,
runtimePort: port,
const seededControlUiAllowedOrigins = controlUiSeed.seededAllowedOrigins
? cfgAtStart.gateway?.controlUi?.allowedOrigins
: undefined;
const applyFixedGatewayOverlays = (config: OpenClawConfig): OpenClawConfig => {
let runtimeConfig = config;
if (reloadAuthOverride || startupTailscaleOverride) {
runtimeConfig = {
...runtimeConfig,
gateway: {
...runtimeConfig.gateway,
...(reloadAuthOverride
? { auth: mergeGatewayAuthConfig(runtimeConfig.gateway?.auth, reloadAuthOverride) }
: {}),
...(startupTailscaleOverride
? {
tailscale: mergeGatewayTailscaleConfig(
runtimeConfig.gateway?.tailscale,
startupTailscaleOverride,
),
}
: {}),
},
};
}
if (
seededControlUiAllowedOrigins &&
runtimeConfig.gateway?.controlUi?.allowedOrigins === undefined
) {
runtimeConfig = {
...runtimeConfig,
gateway: {
...runtimeConfig.gateway,
controlUi: {
...runtimeConfig.gateway?.controlUi,
allowedOrigins: seededControlUiAllowedOrigins,
},
},
};
}
return runtimeConfig;
};
const applyReloadableGatewayAuthRefs = (config: OpenClawConfig): OpenClawConfig => {
if (!startupAuthSecretRefOverride?.token && !startupAuthSecretRefOverride?.password) {
return config;
}
return {
...config,
gateway: {
...config.gateway,
auth: mergeGatewayAuthConfig(config.gateway?.auth, startupAuthSecretRefOverride),
},
};
};
const prepareReloadCandidate = (params: {
runtimeConfig: OpenClawConfig;
sourceConfig: OpenClawConfig;
previousSourceConfig?: OpenClawConfig;
}) => {
const previousSourceConfig =
params.previousSourceConfig ??
getRuntimeConfigSourceSnapshot() ??
startupLastGoodSnapshot.sourceConfig;
assertGatewayConfigEnvSelectionUnchanged(previousSourceConfig, params.sourceConfig);
const runtimeEnv = prepareConfigRuntimeEnv({
previousConfig: previousSourceConfig,
nextConfig: params.sourceConfig,
});
const metadata = startupConfigLoad.pluginMetadataSnapshot;
const pluginCandidate = minimalTestGateway
? { runtimeConfig: params.runtimeConfig, compareConfig: params.sourceConfig }
: resolveGatewayReloadPluginActivationCandidate({
...params,
env: runtimeEnv.env,
...(metadata?.manifestRegistry ? { manifestRegistry: metadata.manifestRegistry } : {}),
discovery: metadata?.discovery,
});
const applyCandidateOverrides = captureConfigOverrideApplier();
const reapplyCompareOverlays = (config: OpenClawConfig): OpenClawConfig =>
applyCandidateOverrides(
mergeActivationSectionsIntoRuntimeConfig({
runtimeConfig: config,
activationConfig: pluginCandidate.compareConfig,
}),
);
cfgAtStart = controlUiSeed.config;
const reapplyRuntimeOverlays = (config: OpenClawConfig): OpenClawConfig =>
applyFixedGatewayOverlays(applyReloadableGatewayAuthRefs(reapplyCompareOverlays(config)));
return {
runtimeConfig: reapplyRuntimeOverlays(params.runtimeConfig),
compareConfig: reapplyCompareOverlays(params.sourceConfig),
runtimeEnv,
reapplyRuntimeOverlays,
reapplyCompareOverlays,
};
};
// Keep the old startup-write suppression path intact for compatibility with
// callers that may still report a write, but startup itself no longer mutates config.
if (startupConfigLoad.wroteConfig || authBootstrap.persistedGeneratedToken) {
@@ -708,6 +868,14 @@ export async function startGatewayServer(
startupLastGoodSnapshot = startupSnapshot;
}
setRuntimeConfigSnapshot(cfgAtStart, startupLastGoodSnapshot.sourceConfig);
initializePublishedConfigRuntimeEnv(startupLastGoodSnapshot.sourceConfig, {
ownedEnv: collectConfigRuntimeEnvOwnership(
startupLastGoodSnapshot.sourceConfig,
envBeforeStartupConfigLoad,
process.env,
),
preserveExistingOwnership: true,
});
const workerEnvironmentStore = minimalTestGateway ? undefined : createWorkerEnvironmentStore();
const hasWorkerEnvironmentRecords = (workerEnvironmentStore?.list().length ?? 0) > 0;
// Durable rows can outlive profiles. Startup planning still enforces plugin trust/disable gates.
@@ -898,8 +1066,8 @@ export async function startGatewayServer(
controlUiEnabled: opts.controlUiEnabled,
openAiChatCompletionsEnabled: opts.openAiChatCompletionsEnabled,
openResponsesEnabled: opts.openResponsesEnabled,
auth: opts.auth,
tailscale: opts.tailscale,
auth: resolvedStartupAuthOverride,
tailscale: startupTailscaleOverride,
});
});
const {
@@ -921,7 +1089,7 @@ export async function startGatewayServer(
authConfig:
getActiveSecretsRuntimeConfigSnapshot()?.config.gateway?.auth ??
getRuntimeConfig().gateway?.auth,
authOverride: opts.auth,
authOverride: resolvedStartupAuthOverride,
env: process.env,
tailscaleMode,
});
@@ -929,7 +1097,7 @@ export async function startGatewayServer(
resolveSharedGatewaySessionGeneration(
resolveGatewayAuth({
authConfig: config.gateway?.auth,
authOverride: opts.auth,
authOverride: resolvedStartupAuthOverride,
env: process.env,
tailscaleMode,
}),
@@ -944,7 +1112,7 @@ export async function startGatewayServer(
resolveSharedGatewaySessionGeneration(
resolveGatewayAuth({
authConfig: getRuntimeConfig().gateway?.auth,
authOverride: opts.auth,
authOverride: resolvedStartupAuthOverride,
env: process.env,
tailscaleMode,
}),
@@ -1165,7 +1333,7 @@ export async function startGatewayServer(
(cfgAtStart.gateway?.terminal?.detachedSessionTimeoutSeconds ??
DEFAULT_TERMINAL_DETACH_SECONDS) * 1000,
});
applyGatewayLaneConcurrency(cfgAtStart);
applyGatewayLaneConcurrency(resolveGatewayLaneConcurrency(cfgAtStart));
runtimeState = createGatewayServerLiveState({
hooksConfig: initialHooksConfig,
@@ -1213,8 +1381,19 @@ export async function startGatewayServer(
cronReconciliation.invalidate();
clearPostReadyMaintenanceTimer();
};
const runClosePrelude = async () => {
let configReloaderStopPromise: Promise<void> | null = null;
const stopConfigReloaderForClose = () => {
configReloaderStopPromise ??= runtimeState.configReloader.stop();
return configReloaderStopPromise;
};
const beginClosePrelude = async () => {
markClosePreludeStarted();
// Join the last reload before any owner it can publish into is torn down.
// The close handler re-awaits this same promise to retain warning reporting.
await stopConfigReloaderForClose().catch(() => {});
};
const runClosePrelude = async () => {
await beginClosePrelude();
disposeNodeConnectionNotifications(nodeRegistry);
watchNodeHttpRuntime.close();
clearPluginMetadataLifecycleCaches();
@@ -1330,7 +1509,7 @@ export async function startGatewayServer(
},
getPendingReplyCount: getTotalPendingReplies,
clients,
configReloader: runtimeState.configReloader,
configReloader: { stop: stopConfigReloaderForClose },
wss,
httpServer,
httpServers,
@@ -1340,6 +1519,7 @@ export async function startGatewayServer(
let clearFallbackGatewayContextForServer = () => {};
const closeOnStartupFailure = async () => {
try {
await beginClosePrelude();
await stopRegisteredGatewayLifetimeSidecars();
await stopRegisteredPostReadySidecars();
await runClosePrelude();
@@ -1594,6 +1774,8 @@ export async function startGatewayServer(
nextConfig: OpenClawConfig;
changedPaths: readonly string[];
beforeReplace: (channels: ReadonlySet<ChannelId>) => Promise<void>;
commitRuntime: () => Promise<void>;
env: NodeJS.ProcessEnv;
isAborted?: () => boolean;
}): Promise<GatewayPluginReloadResult> => {
const beforeChannelTargets = listAttachedChannelConfigTargets();
@@ -1607,12 +1789,12 @@ export async function startGatewayServer(
const nextPluginActivationConfig = resolveGatewayStartupPluginActivationConfig({
runtimeConfig: params.nextConfig,
activationSourceConfig: params.nextConfig,
env: process.env,
env: params.env,
});
const nextPluginLookUpTable = loadPluginLookUpTable({
config: nextPluginActivationConfig,
workspaceDir: defaultWorkspaceDir,
env: process.env,
env: params.env,
activationSourceConfig: params.nextConfig,
workerProviderIds: listDurableWorkerProviderIds(),
});
@@ -1650,11 +1832,8 @@ export async function startGatewayServer(
cancelled: true,
};
}
setCurrentPluginMetadataSnapshot(nextPluginLookUpTable, {
config: params.nextConfig,
env: process.env,
workspaceDir: defaultWorkspaceDir,
});
const previousPluginServices = runtimeState.pluginServices;
await params.commitRuntime();
const loaded = prepareGatewayPluginLoad({
cfg: params.nextConfig,
workspaceDir: defaultWorkspaceDir,
@@ -1664,24 +1843,22 @@ export async function startGatewayServer(
baseMethods,
pluginLookUpTable: nextPluginLookUpTable,
});
const previousPluginServices = runtimeState.pluginServices;
setCurrentPluginMetadataSnapshot(nextPluginLookUpTable, {
config: params.nextConfig,
env: params.env,
workspaceDir: defaultWorkspaceDir,
});
replaceAttachedPluginRuntime(loaded);
runtimeState.pluginServices = null;
if (previousPluginServices) {
await previousPluginServices.stop().catch((err: unknown) => {
log.warn(`plugin services stop failed during reload: ${String(err)}`);
});
await previousPluginServices.stop();
}
replaceAttachedPluginRuntime(loaded);
await refreshAttachedGatewayDiscovery(loaded.pluginRegistry);
try {
runtimeState.pluginServices = await startPluginServices({
registry: loaded.pluginRegistry,
config: params.nextConfig,
workspaceDir: defaultWorkspaceDir,
});
} catch (err) {
log.warn(`plugin services failed to start after reload: ${String(err)}`);
}
runtimeState.pluginServices = await startPluginServices({
registry: loaded.pluginRegistry,
config: params.nextConfig,
workspaceDir: defaultWorkspaceDir,
});
const afterChannelTargets = listAttachedChannelConfigTargets();
const afterChannelIds = new Set(afterChannelTargets.keys());
const restartChannels = new Set<ChannelId>();
@@ -2033,9 +2210,25 @@ export async function startGatewayServer(
initialCompareConfig: startupLastGoodSnapshot.sourceConfig,
initialInternalWriteHash: startupInternalWriteHash,
watchPath: configSnapshot.path,
readSnapshot: readConfigFileSnapshot,
readSnapshot: readConfigFileSnapshotForRuntimeTransaction,
promoteSnapshot: promoteConfigSnapshotToLastKnownGood,
subscribeToWrites: registerConfigWriteListener,
subscribeToWrites: (listener) =>
registerConfigWriteListener(listener, {
ownsRuntimeActivationFor: configSnapshot.path,
preCommitRuntimePreflight: async (sourceConfig, runtimeRefresh) => {
const candidate = prepareReloadCandidate({
runtimeConfig: sourceConfig,
sourceConfig,
});
await activateRuntimeSecrets(candidate.runtimeConfig, {
reason: "reload",
activate: false,
env: candidate.runtimeEnv.env,
includeAuthStoreRefs: runtimeRefresh?.includeAuthStoreRefs,
});
return candidate;
},
}),
deps,
broadcast,
getState: () => ({
@@ -2070,8 +2263,10 @@ export async function startGatewayServer(
onCronRestart: () => {
gatewayCronStartHandled = true;
},
reconcileTerminalSessions: (plan, nextConfig) => {
prepareTerminalConfig: (plan, nextConfig) => {
terminalLaunchPolicy.prepareConfig(nextConfig, { restartPending: plan.restartGateway });
},
reconcileTerminalSessions: () => {
terminalSessions.closeDisallowedAgents(
(agentId) => terminalLaunchPolicy.resolve(agentId).ok,
);
@@ -2080,11 +2275,16 @@ export async function startGatewayServer(
terminalLaunchPolicy.commitConfig();
workerLiveEvents?.rebindAll(nextConfig);
},
acceptTerminalConfig: terminalLaunchPolicy.acceptConfig,
channelManager,
activateRuntimeSecrets,
prepareConfigCandidate: prepareReloadCandidate,
applyRuntimeConfigOverrides: applyFixedGatewayOverlays,
resolveSharedGatewaySessionGenerationForConfig,
sharedGatewaySessionGenerationState,
clients,
...(opts.hotReloadRecovery ? { requestRecoveryRestart: opts.hotReloadRecovery } : {}),
restartRecoveryAvailable: opts.hotReloadRecovery !== undefined,
});
await promoteConfigSnapshotToLastKnownGood(startupLastGoodSnapshot).catch((err: unknown) => {
log.warn(`gateway: failed to promote config last-known-good backup: ${String(err)}`);
@@ -2148,7 +2348,7 @@ export async function startGatewayServer(
return {
close: async (optsLocal) => {
try {
markClosePreludeStarted();
await beginClosePrelude();
// Kill any live operator shells before the socket layer tears down.
terminalSessions.disposeAll();
await stopRegisteredGatewayLifetimeSidecars();
+116
View File
@@ -283,6 +283,122 @@ describe("createTerminalLaunchPolicy", () => {
);
restartPolicy.prepareConfig(baseConfig, { restartPending: true });
expect(restartPolicy.resolve().ok).toBe(false);
restartPolicy.acceptConfig({ retireRejectedRestart: false });
restartPolicy.commitConfig();
expect(restartPolicy.resolve().ok).toBe(true);
});
it("releases a rejected restart restriction after an accepted revert", () => {
const baseConfig: OpenClawConfig = {
gateway: { terminal: { enabled: true } },
};
const policy = createTerminalLaunchPolicy(baseConfig);
policy.prepareConfig({}, { restartPending: true });
policy.prepareConfig(
{
...baseConfig,
agents: { defaults: { sandbox: { mode: "all" } } },
},
{ restartPending: false },
);
policy.commitConfig();
expect(policy.isEnabled()).toBe(false);
policy.acceptConfig({ retireRejectedRestart: true });
policy.commitConfig();
expect(policy.isEnabled()).toBe(true);
});
it("commits a newer hot candidate after a rejected restart is retired", () => {
const baseConfig: OpenClawConfig = {
gateway: { terminal: { enabled: true } },
agents: { defaults: { sandbox: { mode: "all" } } },
};
const policy = createTerminalLaunchPolicy(baseConfig);
policy.prepareConfig({}, { restartPending: true });
policy.prepareConfig(
{
gateway: { terminal: { enabled: true } },
agents: { defaults: { sandbox: { mode: "off" } } },
},
{ restartPending: false },
);
policy.commitConfig();
expect(policy.resolve().ok).toBe(false);
policy.acceptConfig({ retireRejectedRestart: true });
policy.commitConfig();
expect(policy.resolve().ok).toBe(true);
});
it("retires failed hot candidates without clearing committed restart restrictions", () => {
const baseConfig: OpenClawConfig = {
gateway: { terminal: { enabled: true } },
agents: { defaults: { sandbox: { mode: "off" } } },
};
const policy = createTerminalLaunchPolicy(baseConfig);
policy.prepareConfig(
{
...baseConfig,
agents: { defaults: { sandbox: { mode: "all" } } },
},
{ restartPending: false },
);
expect(policy.resolve().ok).toBe(false);
policy.acceptConfig({ retireRejectedRestart: false });
policy.commitConfig();
expect(policy.resolve().ok).toBe(true);
const skippedPolicy = createTerminalLaunchPolicy({
...baseConfig,
agents: { defaults: { sandbox: { mode: "all" } } },
});
skippedPolicy.prepareConfig(baseConfig, { restartPending: false });
skippedPolicy.acceptConfig({ retireRejectedRestart: false });
skippedPolicy.commitConfig();
expect(skippedPolicy.resolve().ok).toBe(false);
const pendingPolicy = createTerminalLaunchPolicy(baseConfig);
pendingPolicy.prepareConfig(baseConfig, { restartPending: true });
pendingPolicy.prepareConfig(
{
...baseConfig,
agents: { defaults: { sandbox: { mode: "all" } } },
},
{ restartPending: false },
);
expect(pendingPolicy.resolve().ok).toBe(false);
pendingPolicy.acceptConfig({ retireRejectedRestart: false });
pendingPolicy.commitConfig();
expect(pendingPolicy.resolve().ok).toBe(true);
const appliedPendingPolicy = createTerminalLaunchPolicy(baseConfig);
appliedPendingPolicy.prepareConfig(baseConfig, { restartPending: true });
appliedPendingPolicy.prepareConfig(
{
...baseConfig,
agents: { defaults: { sandbox: { mode: "all" } } },
},
{ restartPending: false },
);
appliedPendingPolicy.commitConfig();
appliedPendingPolicy.acceptConfig({ retireRejectedRestart: false });
appliedPendingPolicy.commitConfig();
expect(appliedPendingPolicy.resolve().ok).toBe(false);
appliedPendingPolicy.prepareConfig(baseConfig, { restartPending: false });
appliedPendingPolicy.commitConfig();
appliedPendingPolicy.acceptConfig({ retireRejectedRestart: false });
appliedPendingPolicy.commitConfig();
expect(appliedPendingPolicy.resolve().ok).toBe(true);
policy.prepareConfig({}, { restartPending: true });
policy.acceptConfig({ retireRejectedRestart: false });
policy.commitConfig();
expect(policy.isEnabled()).toBe(false);
});
it("does not promote a terminal setting previously ignored by reload mode", () => {
+45 -10
View File
@@ -36,6 +36,7 @@ type TerminalLaunchPolicy = {
isEnabled: () => boolean;
prepareConfig: (config: OpenClawConfig, options: { restartPending: boolean }) => void;
commitConfig: () => void;
acceptConfig: (options: { retireRejectedRestart: boolean }) => void;
};
/** Picks the interactive shell: explicit config, then the host login shell. */
@@ -116,6 +117,7 @@ export function createTerminalLaunchPolicy(initialConfig: OpenClawConfig): Termi
let hasPendingRestart = false;
let terminalDisabledUntilRestart = false;
let preparedConfig: OpenClawConfig | null = null;
let appliedConfigWhileRestartPending: OpenClawConfig | null = null;
let terminalDisabledUntilCommit = false;
const blockedAgentsUntilRestart = new Map<string, TerminalLaunchBlock>();
const blockedAgentsUntilCommit = new Map<string, TerminalLaunchBlock>();
@@ -189,8 +191,9 @@ export function createTerminalLaunchPolicy(initialConfig: OpenClawConfig): Termi
if (preparedBlock) {
return { ok: false, block: preparedBlock };
}
if (preparedConfig) {
const prepared = resolveForConfig(preparedConfig, active.plan.agentId);
const candidateConfig = preparedConfig ?? appliedConfigWhileRestartPending;
if (candidateConfig) {
const prepared = resolveForConfig(candidateConfig, active.plan.agentId);
if (!prepared.ok) {
return prepared;
}
@@ -205,12 +208,8 @@ export function createTerminalLaunchPolicy(initialConfig: OpenClawConfig): Termi
prepareConfig: (config, options) => {
if (options.restartPending) {
hasPendingRestart = true;
terminalDisabledUntilRestart ||= terminalDisabledUntilCommit;
for (const [agentId, block] of blockedAgentsUntilCommit) {
blockedAgentsUntilRestart.set(agentId, block);
}
terminalDisabledUntilCommit = false;
blockedAgentsUntilCommit.clear();
// Keep an older candidate fail-closed only until this transaction is
// accepted; do not mix its restrictions into the restart-owned bucket.
preparedConfig = null;
accumulateRestartRestrictions(config);
return;
@@ -219,20 +218,56 @@ export function createTerminalLaunchPolicy(initialConfig: OpenClawConfig): Termi
// earlier reload mode ignored. Advance agent policy, but preserve the
// terminal subtree already owned by the active or pending process.
if (hasPendingRestart) {
accumulateRestartRestrictions(config);
preparedConfig = preserveTerminalConfig(config, activeConfig);
accumulateCommitRestrictions(preparedConfig);
return;
}
preparedConfig = preserveTerminalConfig(config, activeConfig);
accumulateCommitRestrictions(preparedConfig);
},
commitConfig: () => {
if (preparedConfig && !hasPendingRestart) {
if (hasPendingRestart) {
// The applied marker separates runtime truth from a later candidate
// that may fail before publication while this restart remains pending.
if (preparedConfig) {
appliedConfigWhileRestartPending = preparedConfig;
}
preparedConfig = null;
terminalDisabledUntilCommit = false;
blockedAgentsUntilCommit.clear();
if (appliedConfigWhileRestartPending) {
accumulateCommitRestrictions(appliedConfigWhileRestartPending);
}
return;
}
if (preparedConfig) {
activeConfig = preparedConfig;
}
preparedConfig = null;
terminalDisabledUntilCommit = false;
blockedAgentsUntilCommit.clear();
},
acceptConfig: (options) => {
// Baseline acceptance retires an un-published candidate, including config
// intentionally skipped by reload policy. Only onConfigApplied may stage
// runtime truth for promotion after a rejected restart.
preparedConfig = null;
terminalDisabledUntilCommit = false;
blockedAgentsUntilCommit.clear();
if (options.retireRejectedRestart) {
hasPendingRestart = false;
terminalDisabledUntilRestart = false;
blockedAgentsUntilRestart.clear();
if (appliedConfigWhileRestartPending) {
activeConfig = appliedConfigWhileRestartPending;
}
appliedConfigWhileRestartPending = null;
return;
}
if (appliedConfigWhileRestartPending) {
accumulateCommitRestrictions(appliedConfigWhileRestartPending);
}
},
};
}
+5 -3
View File
@@ -642,10 +642,12 @@ export async function startGatewayServer(port: number, opts?: GatewayServerOptio
resetConfigRuntimeState();
clearSessionStoreCacheForTest();
const mod = await getServerModule();
const resolvedOpts =
opts?.controlUiEnabled === undefined ? { ...opts, controlUiEnabled: false } : opts;
const resolvedOpts = {
...opts,
controlUiEnabled: opts?.controlUiEnabled ?? false,
};
if (
resolvedOpts?.controlUiEnabled === true &&
resolvedOpts.controlUiEnabled &&
process.env.OPENCLAW_TEST_MINIMAL_GATEWAY === "1" &&
tempControlUiRoot &&
typeof (testState.gatewayControlUi as { root?: unknown } | undefined)?.root !== "string"
+9 -4
View File
@@ -19,6 +19,7 @@ import {
isGatewaySigusr1RestartExternallyAllowed,
markGatewaySigusr1RestartHandled,
peekGatewaySigusr1RestartReason,
requestGatewayRestartWithSignalAdmission,
scheduleGatewaySigusr1Restart,
setGatewaySigusr1RestartPolicy,
setPreRestartDeferralCheck,
@@ -180,8 +181,8 @@ describe("infra runtime", () => {
const handler = () => {};
process.on("SIGUSR1", handler);
try {
expect(emitGatewayRestart()).toBe(true);
expect(emitGatewayRestart()).toBe(false);
expect(requestGatewayRestartWithSignalAdmission()).toEqual({ status: "emitted" });
expect(requestGatewayRestartWithSignalAdmission()).toEqual({ status: "coalesced" });
expect(consumeGatewaySigusr1RestartAuthorization()).toBe(true);
markGatewaySigusr1RestartHandled();
@@ -233,9 +234,13 @@ describe("infra runtime", () => {
.mockReturnValueOnce({ ok: false, method: "schtasks", detail: "denied" })
.mockReturnValueOnce({ ok: true, method: "schtasks" });
expect(emitGatewayRestart("windows-fallback")).toBe(false);
expect(requestGatewayRestartWithSignalAdmission("windows-fallback")).toEqual({
status: "failed",
});
expect(consumeGatewaySigusr1RestartAuthorization()).toBe(false);
expect(emitGatewayRestart("windows-retry")).toBe(true);
expect(requestGatewayRestartWithSignalAdmission("windows-retry")).toEqual({
status: "emitted",
});
expect(relaunchGatewayScheduledTaskMock).toHaveBeenCalledTimes(2);
});
});
+3 -3
View File
@@ -114,7 +114,7 @@ describe("scheduled restart during gateway suspension", () => {
});
expect(prepared).toMatchObject({
status: "busy",
reason: "active-work",
reason: "gateway-draining",
activeCount: 1,
});
expect(countSigusr1Emits(emitSpy.mock.calls)).toBe(0);
@@ -145,7 +145,7 @@ describe("scheduled restart during gateway suspension", () => {
scheduleGatewaySigusr1Restart({ delayMs: 0, skipCooldown: true });
await vi.advanceTimersByTimeAsync(0);
expect(countSigusr1Emits(emitSpy.mock.calls)).toBe(1);
expect(preRestartCheck).toHaveBeenCalledOnce();
expect(preRestartCheck).toHaveBeenCalledTimes(2);
expect(isGatewayWorkAdmissionClosed()).toBe(true);
testing.resetSigusr1TransientState();
@@ -155,7 +155,7 @@ describe("scheduled restart during gateway suspension", () => {
scheduleGatewaySigusr1Restart({ delayMs: 0, skipCooldown: true });
await vi.advanceTimersByTimeAsync(0);
expect(countSigusr1Emits(emitSpy.mock.calls)).toBe(2);
expect(preRestartCheck).toHaveBeenCalledTimes(2);
expect(preRestartCheck).toHaveBeenCalledTimes(4);
});
it("cancels delayed restart work during a transient reset", async () => {
+77 -4
View File
@@ -1,5 +1,10 @@
// Tests restart deferral timeout behavior and fallback cleanup.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
isGatewayWorkAdmissionClosed,
resetGatewayWorkAdmission,
tryBeginGatewayRootWorkAdmission,
} from "../process/gateway-work-admission.js";
import {
testing,
consumeGatewaySigusr1RestartIntent,
@@ -11,6 +16,7 @@ describe("deferGatewayRestartUntilIdle timeout", () => {
beforeEach(() => {
vi.useFakeTimers();
testing.resetSigusr1State();
resetGatewayWorkAdmission();
// Add a listener so emitGatewayRestart uses process.emit instead of process.kill
process.on("SIGUSR1", () => {});
});
@@ -19,6 +25,7 @@ describe("deferGatewayRestartUntilIdle timeout", () => {
vi.useRealTimers();
vi.restoreAllMocks();
testing.resetSigusr1State();
resetGatewayWorkAdmission();
process.removeAllListeners("SIGUSR1");
});
@@ -106,7 +113,7 @@ describe("deferGatewayRestartUntilIdle timeout", () => {
});
});
it("calls onReady and does not timeout when pending count drops to 0", () => {
it("calls onReady and does not timeout when pending count drops to 0", async () => {
const hooks: RestartDeferralHooks = {
onTimeout: vi.fn(),
onReady: vi.fn(),
@@ -124,12 +131,78 @@ describe("deferGatewayRestartUntilIdle timeout", () => {
expect(hooks.onReady).not.toHaveBeenCalled();
pending = 0;
vi.advanceTimersByTime(500); // Next poll interval
await vi.advanceTimersByTimeAsync(500); // Next poll interval and fenced emission
expect(hooks.onReady).toHaveBeenCalledOnce();
expect(hooks.onTimeout).not.toHaveBeenCalled();
});
it("immediately restarts when pending count is 0", () => {
it("cancels a pending deferral before it can emit", () => {
let pending = 1;
const emitRestart = vi.fn(() => ({ status: "emitted" as const }));
const handle = deferGatewayRestartUntilIdle({
getPendingCount: () => pending,
emitHooks: { emitRestart },
});
handle.cancel();
pending = 0;
vi.advanceTimersByTime(1_000);
expect(emitRestart).not.toHaveBeenCalled();
});
it("forces a timed-out restart while an admitted root remains", async () => {
const root = tryBeginGatewayRootWorkAdmission();
expect(root).not.toBeNull();
const emitRestart = vi.fn(() => ({ status: "emitted" as const }));
deferGatewayRestartUntilIdle({
getPendingCount: () => 1,
maxWaitMs: 10,
pollMs: 10,
timeoutIntent: { force: true },
emitHooks: { emitRestart },
});
await vi.advanceTimersByTimeAsync(10);
expect(emitRestart).toHaveBeenCalledOnce();
root?.release();
});
it("reopens admission when a blocked preparation is cancelled", async () => {
let releasePreparation: (() => void) | undefined;
const preparation = new Promise<void>((resolve) => {
releasePreparation = resolve;
});
const emitRestart = vi.fn(() => ({ status: "emitted" as const }));
const handle = deferGatewayRestartUntilIdle({
getPendingCount: () => 0,
emitHooks: {
beforeEmit: async () => await preparation,
emitRestart,
},
});
await vi.advanceTimersByTimeAsync(0);
expect(isGatewayWorkAdmissionClosed()).toBe(true);
handle.cancel();
expect(isGatewayWorkAdmissionClosed()).toBe(false);
releasePreparation?.();
await vi.advanceTimersByTimeAsync(0);
expect(emitRestart).not.toHaveBeenCalled();
});
it("reopens admission when a prepared restart is superseded", async () => {
deferGatewayRestartUntilIdle({
getPendingCount: () => 0,
emitHooks: { emitRestart: () => ({ status: "coalesced" }) },
});
await vi.advanceTimersByTimeAsync(0);
expect(isGatewayWorkAdmissionClosed()).toBe(false);
});
it("immediately restarts when pending count is 0", async () => {
const hooks: RestartDeferralHooks = {
onReady: vi.fn(),
onTimeout: vi.fn(),
@@ -140,7 +213,7 @@ describe("deferGatewayRestartUntilIdle timeout", () => {
hooks,
});
// onReady should be called synchronously
await vi.advanceTimersByTimeAsync(0);
expect(hooks.onReady).toHaveBeenCalledOnce();
expect(hooks.onTimeout).not.toHaveBeenCalled();
});
+184 -43
View File
@@ -11,6 +11,7 @@ import {
import { createSubsystemLogger } from "../logging/subsystem.js";
import {
beginGatewayRestartSignalAdmission,
getActiveGatewayRootWorkCount,
isGatewayRestartDraining,
runWithGatewayIndependentRootWorkAdmission,
type GatewayRestartSignalAdmissionLease,
@@ -447,6 +448,18 @@ export function emitGatewayRestartWithSignalAdmission(
return emitted;
}
/** Closed restart result for owners that must distinguish coalescing from delivery failure. */
export function requestGatewayRestartWithSignalAdmission(
reasonOverride?: string,
intent?: GatewayRestartIntent,
): GatewayRestartEmitResult {
const hadUnconsumedRestartSignal = hasUnconsumedRestartSignal();
if (emitGatewayRestartWithSignalAdmission(reasonOverride, intent)) {
return { status: "emitted" };
}
return { status: hadUnconsumedRestartSignal ? "coalesced" : "failed" };
}
function resetSigusr1AuthorizationIfExpired(now = Date.now()) {
if (sigusr1AuthorizedCount <= 0) {
return;
@@ -539,8 +552,24 @@ export type RestartDeferralHooks = {
export type RestartEmitHooks = {
beforeEmit?: () => Promise<void>;
afterEmitRejected?: () => Promise<void>;
afterEmitFailed?: () => Promise<void>;
emitRestart?: GatewayRestartEmitter;
};
export type RestartDeferralHandle = {
cancel: () => void;
};
export type GatewayRestartEmitter = (
reasonOverride?: string,
intent?: GatewayRestartIntent,
) => GatewayRestartEmitResult;
export type GatewayRestartEmitResult =
| { status: "emitted" }
| { status: "coalesced" }
| { status: "failed" };
export function resolveGatewayRestartDeferralTimeoutMs(timeoutMs: unknown): number | undefined {
if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs)) {
return DEFAULT_RESTART_DEFERRAL_TIMEOUT_MS;
@@ -592,9 +621,11 @@ async function emitPreparedGatewayRestartUnderAdmission(
reasonOverride?: string,
intent?: GatewayRestartIntent,
transientGeneration = restartTransientGeneration,
): Promise<void> {
if (transientGeneration !== restartTransientGeneration) {
return;
canEmit: () => boolean = () => true,
): Promise<GatewayRestartEmitResult | null> {
const isCurrent = () => transientGeneration === restartTransientGeneration && canEmit();
if (!isCurrent()) {
return null;
}
let nextHooks = hooks ?? pendingRestartEmitHooks;
// Keep pendingRestartSessionKey alive across the await beforeEmit() window:
@@ -609,8 +640,8 @@ async function emitPreparedGatewayRestartUnderAdmission(
if (preparedHooks) {
await rejectPreparedRestartHook(preparedHooks);
preparedHooks = undefined;
if (transientGeneration !== restartTransientGeneration) {
return;
if (!isCurrent()) {
return null;
}
}
try {
@@ -621,9 +652,9 @@ async function emitPreparedGatewayRestartUnderAdmission(
`restart preparation failed; restart will continue without it: ${String(err)}`,
);
}
if (transientGeneration !== restartTransientGeneration) {
if (!isCurrent()) {
await rejectPreparedRestartHook(preparedHooks);
return;
return null;
}
if (hooks) {
break;
@@ -634,46 +665,97 @@ async function emitPreparedGatewayRestartUnderAdmission(
if (!hooks) {
pendingRestartSessionKey = undefined;
}
if (!isCurrent()) {
await rejectPreparedRestartHook(preparedHooks);
return null;
}
// A managed update can coalesce while beforeEmit awaits. Promote that reason
// at the last possible moment so the run loop performs a process exit.
const preferredReason = shouldPreferRestartReason(pendingRestartReason, reasonOverride)
? pendingRestartReason
: undefined;
const emitted = emitGatewayRestartWithSignalAdmission(
preferredReason ?? reasonOverride,
preferredReason && intent ? { ...intent, reason: preferredReason } : intent,
);
if (!emitted) {
const resolvedReason = preferredReason ?? reasonOverride;
const resolvedIntent =
preferredReason && intent ? { ...intent, reason: preferredReason } : intent;
const emitResult = preparedHooks?.emitRestart
? preparedHooks.emitRestart(resolvedReason, resolvedIntent)
: requestGatewayRestartWithSignalAdmission(resolvedReason, resolvedIntent);
if (emitResult.status !== "emitted") {
await rejectPreparedRestartHook(preparedHooks);
}
if (emitResult.status === "failed") {
await preparedHooks?.afterEmitFailed?.();
}
return emitResult;
}
async function emitPreparedGatewayRestart(
hooks?: RestartEmitHooks,
reasonOverride?: string,
intent?: GatewayRestartIntent,
): Promise<void> {
finalIdleCheck?: () => boolean,
setFenceRollback?: (rollback: (() => void) | null) => void,
): Promise<boolean> {
const transientGeneration = restartTransientGeneration;
try {
// A delayed restart can become due after host suspension prepared. Independent
// root admission makes the transition atomic: due restarts block preparation,
// while a prepared suspension defers emission until it resumes.
await runWithGatewayIndependentRootWorkAdmission(async () => {
return await runWithGatewayIndependentRootWorkAdmission(async () => {
if (transientGeneration !== restartTransientGeneration) {
return;
return false;
}
await emitPreparedGatewayRestartUnderAdmission(
// Close new roots before the final synchronous idle check. The independent
// emission owner is excluded; any other admitted root makes this attempt retry.
const signalAdmission = beginGatewayRestartSignalAdmission();
pendingRestartSignalAdmission = signalAdmission;
let fenceActive = true;
const rollbackFence = () => {
fenceActive = false;
signalAdmission.rollback();
if (pendingRestartSignalAdmission === signalAdmission) {
pendingRestartSignalAdmission = null;
}
};
setFenceRollback?.(rollbackFence);
let isIdle: boolean;
try {
isIdle = finalIdleCheck
? finalIdleCheck() && getActiveGatewayRootWorkCount({ excludeCurrent: true }) === 0
: true;
} catch (err) {
rollbackFence();
setFenceRollback?.(null);
throw err;
}
if (!isIdle) {
rollbackFence();
setFenceRollback?.(null);
return false;
}
const emitResult = await emitPreparedGatewayRestartUnderAdmission(
hooks,
reasonOverride,
intent,
transientGeneration,
() => fenceActive,
);
if (
!emitResult ||
emitResult.status === "failed" ||
(emitResult.status === "coalesced" && !hasUnconsumedRestartSignal())
) {
rollbackFence();
}
setFenceRollback?.(null);
return emitResult !== null;
});
} catch (err) {
if (!isGatewayRestartDraining()) {
throw err;
}
return true;
}
}
@@ -690,46 +772,86 @@ export function deferGatewayRestartUntilIdle(opts: {
maxWaitMs?: number;
reason?: string;
timeoutIntent?: GatewayRestartIntent;
}): void {
}): RestartDeferralHandle {
const pollMs = resolveTimerTimeoutMs(opts.pollMs, DEFAULT_DEFERRAL_POLL_MS, 10);
const maxWaitMs =
typeof opts.maxWaitMs === "number" && Number.isFinite(opts.maxWaitMs) && opts.maxWaitMs > 0
? Math.max(pollMs, Math.floor(opts.maxWaitMs))
: undefined;
let pending: number;
try {
pending = opts.getPendingCount();
} catch (err) {
opts.hooks?.onCheckError?.(err);
void emitPreparedGatewayRestart(opts.emitHooks, opts.reason);
return;
}
if (pending <= 0) {
opts.hooks?.onReady?.();
void emitPreparedGatewayRestart(opts.emitHooks, opts.reason);
return;
}
opts.hooks?.onDeferring?.(pending);
let cancelled = false;
let attemptingEmission = false;
let cancelEmissionFence: (() => void) | null = null;
let poll: ReturnType<typeof setInterval> | null = null;
const stopPoll = () => {
if (!poll) {
return;
}
clearInterval(poll);
activeDeferralPolls.delete(poll);
poll = null;
};
const cancel = () => {
cancelled = true;
cancelEmissionFence?.();
cancelEmissionFence = null;
stopPoll();
};
const handle = { cancel };
const startedAt = Date.now();
let nextStillPendingAt = startedAt + DEFAULT_DEFERRAL_STILL_PENDING_WARN_MS;
const poll = setInterval(() => {
const attemptEmission = (params: {
intent?: GatewayRestartIntent;
notifyReady: boolean;
skipIdleCheck?: boolean;
}) => {
if (cancelled || attemptingEmission) {
return;
}
attemptingEmission = true;
void emitPreparedGatewayRestart(
opts.emitHooks,
opts.reason,
params.intent,
params.skipIdleCheck ? undefined : () => opts.getPendingCount() <= 0,
(rollback) => {
cancelEmissionFence = rollback;
},
)
.then((attempted) => {
attemptingEmission = false;
cancelEmissionFence = null;
if (cancelled || !attempted) {
return;
}
stopPoll();
if (params.notifyReady) {
opts.hooks?.onReady?.();
}
})
.catch((err: unknown) => {
attemptingEmission = false;
cancelEmissionFence = null;
stopPoll();
opts.hooks?.onCheckError?.(err);
void emitPreparedGatewayRestart(opts.emitHooks, opts.reason, params.intent);
});
};
const inspectPending = () => {
if (cancelled) {
return;
}
let current: number;
try {
current = opts.getPendingCount();
} catch (err) {
clearInterval(poll);
activeDeferralPolls.delete(poll);
stopPoll();
opts.hooks?.onCheckError?.(err);
void emitPreparedGatewayRestart(opts.emitHooks, opts.reason);
return;
}
if (current <= 0) {
clearInterval(poll);
activeDeferralPolls.delete(poll);
opts.hooks?.onReady?.();
void emitPreparedGatewayRestart(opts.emitHooks, opts.reason);
attemptEmission({ notifyReady: true });
return;
}
const elapsedMs = Date.now() - startedAt;
@@ -738,13 +860,32 @@ export function deferGatewayRestartUntilIdle(opts: {
nextStillPendingAt = Date.now() + DEFAULT_DEFERRAL_STILL_PENDING_WARN_MS;
}
if (maxWaitMs !== undefined && elapsedMs >= maxWaitMs) {
clearInterval(poll);
activeDeferralPolls.delete(poll);
stopPoll();
opts.hooks?.onTimeout?.(current, elapsedMs);
void emitPreparedGatewayRestart(opts.emitHooks, opts.reason, opts.timeoutIntent);
attemptEmission({
intent: opts.timeoutIntent,
notifyReady: false,
skipIdleCheck: true,
});
}
}, pollMs);
};
let pending: number;
try {
pending = opts.getPendingCount();
} catch (err) {
opts.hooks?.onCheckError?.(err);
void emitPreparedGatewayRestart(opts.emitHooks, opts.reason);
return handle;
}
if (pending > 0) {
opts.hooks?.onDeferring?.(pending);
}
poll = setInterval(inspectPending, pollMs);
activeDeferralPolls.add(poll);
if (pending <= 0) {
attemptEmission({ notifyReady: true });
}
return handle;
}
function formatSpawnDetail(result: {
@@ -13,6 +13,7 @@ import {
runWithGatewayRootWorkAdmission,
tryBeginGatewayRootWorkAdmission,
tryBeginGatewaySuspendAdmission,
waitForActiveGatewayRootWork,
} from "./gateway-work-admission.js";
beforeEach(resetGatewayWorkAdmission);
@@ -35,6 +36,17 @@ it("counts one nested root chain once and excludes the preparing caller", async
expect(getActiveGatewayRootWorkCount()).toBe(0);
});
it("waits for admitted roots and reports a bounded timeout", async () => {
const root = tryBeginGatewayRootWorkAdmission();
expect(root).not.toBeNull();
const pending = waitForActiveGatewayRootWork();
await expect(waitForActiveGatewayRootWork(0)).resolves.toEqual({ drained: false, active: 1 });
root?.release();
await expect(pending).resolves.toEqual({ drained: true, active: 0 });
});
it("rolls back or releases a generation-bound suspension without resetting roots", () => {
const invalidated = vi.fn();
const preparing = tryBeginGatewaySuspendAdmission(invalidated);
+52
View File
@@ -24,6 +24,7 @@ type GatewayWorkAdmissionState = {
suspendGeneration: number;
suspendInvalidated?: () => void;
activeRootWork: Set<GatewayRootWorkAdmission>;
rootDrainWaiters?: Set<() => void>;
currentRootWork: AsyncLocalStorage<GatewayRootWorkAdmission>;
suspendOpenWaiters: Set<() => void>;
};
@@ -37,6 +38,7 @@ const GATEWAY_WORK_ADMISSION_STATE = resolveGlobalSingleton(
suspendPhase: "accepting",
suspendGeneration: 0,
activeRootWork: new Set(),
rootDrainWaiters: new Set(),
currentRootWork: new AsyncLocalStorage(),
suspendOpenWaiters: new Set(),
}),
@@ -83,9 +85,24 @@ function createGatewayRootWorkRelease(admission: GatewayRootWorkAdmission): () =
}
admission.released = true;
GATEWAY_WORK_ADMISSION_STATE.activeRootWork.delete(admission);
if (GATEWAY_WORK_ADMISSION_STATE.activeRootWork.size === 0) {
resolveRootDrainWaiters();
}
};
}
function resolveRootDrainWaiters(): void {
const rootDrainWaiters = GATEWAY_WORK_ADMISSION_STATE.rootDrainWaiters;
if (!rootDrainWaiters) {
return;
}
const waiters = Array.from(rootDrainWaiters);
rootDrainWaiters.clear();
for (const resolve of waiters) {
resolve();
}
}
function invalidateSuspendAdmission(): void {
const callback = GATEWAY_WORK_ADMISSION_STATE.suspendInvalidated;
GATEWAY_WORK_ADMISSION_STATE.suspendInvalidated = undefined;
@@ -299,6 +316,40 @@ export function getActiveGatewayRootWorkCount(opts?: { excludeCurrent?: boolean
return Math.max(0, count);
}
/** Waits for admitted root transactions after restart has closed new admission. */
export async function waitForActiveGatewayRootWork(
timeoutMs?: number,
): Promise<{ drained: boolean; active: number }> {
if (GATEWAY_WORK_ADMISSION_STATE.activeRootWork.size === 0) {
return { drained: true, active: 0 };
}
const timeout =
typeof timeoutMs === "number" && Number.isFinite(timeoutMs)
? Math.max(0, Math.floor(timeoutMs))
: undefined;
if (timeout === 0) {
return { drained: false, active: GATEWAY_WORK_ADMISSION_STATE.activeRootWork.size };
}
let timer: ReturnType<typeof setTimeout> | undefined;
let resolveDrain = () => {};
await new Promise<void>((resolve) => {
resolveDrain = () => resolve();
const waiters =
GATEWAY_WORK_ADMISSION_STATE.rootDrainWaiters ??
(GATEWAY_WORK_ADMISSION_STATE.rootDrainWaiters = new Set());
waiters.add(resolveDrain);
if (timeout !== undefined) {
timer = setTimeout(resolve, timeout);
}
});
if (timer) {
clearTimeout(timer);
}
GATEWAY_WORK_ADMISSION_STATE.rootDrainWaiters?.delete(resolveDrain);
const active = GATEWAY_WORK_ADMISSION_STATE.activeRootWork.size;
return { drained: active === 0, active };
}
/** Atomically closes new suspension admission before synchronous inspection. */
export function tryBeginGatewaySuspendAdmission(
onInvalidated: () => void,
@@ -348,6 +399,7 @@ export function resetGatewayWorkAdmission(): void {
admission.released = true;
}
GATEWAY_WORK_ADMISSION_STATE.activeRootWork.clear();
resolveRootDrainWaiters();
GATEWAY_WORK_ADMISSION_STATE.restartDraining = false;
GATEWAY_WORK_ADMISSION_STATE.restartSignalPending = false;
GATEWAY_WORK_ADMISSION_STATE.restartSignalGeneration += 1;
+255 -2
View File
@@ -4,8 +4,21 @@ import os from "node:os";
import path from "node:path";
import { expectDefined } from "@openclaw/normalization-core";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { resolveAuthProfileDatabasePath } from "../agents/auth-profiles/sqlite.js";
import { saveAuthProfileStore } from "../agents/auth-profiles/store.js";
import { registerResolvedAgentDir } from "../agents/agent-dir-registry.js";
import { getRuntimeAuthProfileStoreCredentialMutationRevision } from "../agents/auth-profiles/runtime-snapshots.js";
import {
readPersistedAuthProfileStateRaw,
readPersistedAuthProfileStoreRaw,
resolveAuthProfileDatabasePath,
writePersistedAuthProfileStateRaw,
} from "../agents/auth-profiles/sqlite.js";
import {
clearRuntimeAuthProfileStoreSnapshots,
getRuntimeAuthProfileStoreSnapshot,
replaceRuntimeAuthProfileStoreSnapshots,
saveAuthProfileStore,
testing as storeTesting,
} from "../agents/auth-profiles/store.js";
import type { AuthProfileStore } from "../agents/auth-profiles/types.js";
import {
closeOpenClawAgentDatabasesForTest,
@@ -286,6 +299,8 @@ describe("secrets apply", () => {
afterEach(async () => {
clearSecretsRuntimeSnapshot();
storeTesting.resetRuntimeSnapshotPublisherForTest();
clearRuntimeAuthProfileStoreSnapshots();
closeOpenClawAgentDatabasesForTest();
await fs.rm(fixture.rootDir, { recursive: true, force: true });
});
@@ -572,6 +587,51 @@ describe("secrets apply", () => {
});
});
it("rolls back committed auth rows when runtime publication fails", async () => {
await writeJsonFile(fixture.authStorePath, {
version: 1,
profiles: {
"openai:default": {
type: "api_key",
provider: "openai",
key: "fake",
},
},
});
const credentialsBefore = readPersistedAuthProfileStoreRaw(fixture.agentDir);
const stateBefore = readPersistedAuthProfileStateRaw(fixture.agentDir);
const plan = createPlan({
targets: [
{
type: "auth-profiles.api_key.key",
path: "profiles.openai:default.key",
pathSegments: ["profiles", "openai:default", "key"],
agentId: "main",
ref: OPENAI_API_KEY_ENV_REF,
authProfileProvider: "openai",
},
],
options: {
scrubEnv: false,
scrubAuthProfilesForProviderTargets: false,
scrubLegacyAuthJson: false,
},
});
let publicationAttempted = false;
storeTesting.setRuntimeSnapshotPublisherForTest(() => {
publicationAttempted = true;
throw new Error("injected postcommit publication failure");
});
await expect(runSecretsApply({ plan, env: fixture.env, write: true })).rejects.toThrow(
"auth profile runtime publication failed",
);
expect(publicationAttempted).toBe(true);
expect(readPersistedAuthProfileStoreRaw(fixture.agentDir)).toEqual(credentialsBefore);
expect(readPersistedAuthProfileStateRaw(fixture.agentDir)).toEqual(stateBefore);
});
it("uses the configured agent id for custom auth-profile target agent dirs", async () => {
const coderAgentDir = path.join(fixture.rootDir, "custom-coder-agent");
const coderStorePath = resolveAuthProfileDatabasePath(coderAgentDir);
@@ -612,6 +672,199 @@ describe("secrets apply", () => {
expect(database.agentId).toBe("coder");
});
it("atomically deletes a newly created auth store when a later auth write fails", async () => {
const firstAgentDir = path.join(fixture.rootDir, "custom-first-agent");
const secondAgentDir = path.join(fixture.rootDir, "custom-second-agent");
const firstStorePath = resolveAuthProfileDatabasePath(firstAgentDir);
const secondStorePath = resolveAuthProfileDatabasePath(secondAgentDir);
await writeJsonFile(fixture.configPath, {
agents: {
list: [
{ id: "first", agentDir: firstAgentDir },
{ id: "second", agentDir: secondAgentDir },
],
},
});
const firstState = {
version: 1 as const,
order: { openai: ["openai:preexisting"] },
};
const firstDatabase = openOpenClawAgentDatabase({
agentId: "first",
path: firstStorePath,
});
writePersistedAuthProfileStateRaw(firstState, firstAgentDir, firstDatabase);
replaceRuntimeAuthProfileStoreSnapshots([
{ agentDir: firstAgentDir, store: { profiles: {}, ...firstState } },
]);
const firstMutationRevision =
getRuntimeAuthProfileStoreCredentialMutationRevision(firstAgentDir);
const secondDatabase = openOpenClawAgentDatabase({
agentId: "second",
path: secondStorePath,
});
secondDatabase.db.exec(`
CREATE TRIGGER reject_second_auth_store_insert
BEFORE INSERT ON auth_profile_store
BEGIN
SELECT RAISE(ABORT, 'injected second auth store failure');
END;
`);
const authTarget = (agentId: string): SecretsApplyPlan["targets"][number] => ({
type: "auth-profiles.api_key.key",
path: "profiles.openai:default.key",
pathSegments: ["profiles", "openai:default", "key"],
agentId,
ref: OPENAI_API_KEY_ENV_REF,
authProfileProvider: "openai",
});
const plan = createPlan({
targets: [authTarget("first"), authTarget("second")],
options: {
scrubEnv: false,
scrubAuthProfilesForProviderTargets: false,
scrubLegacyAuthJson: false,
},
});
await expect(runSecretsApply({ plan, env: fixture.env, write: true })).rejects.toThrow(
"injected second auth store failure",
);
expect(await fs.stat(firstStorePath)).toBeDefined();
expect(readPersistedAuthProfileStoreRaw(firstAgentDir)).toBeNull();
expect(readPersistedAuthProfileStateRaw(firstAgentDir)).toEqual(firstState);
expect(getRuntimeAuthProfileStoreSnapshot(firstAgentDir)).toMatchObject({
profiles: {},
order: firstState.order,
});
expect(getRuntimeAuthProfileStoreCredentialMutationRevision(firstAgentDir)).toBeGreaterThan(
firstMutationRevision,
);
});
it.each(["credentials", "state"] as const)(
"preserves a concurrent auth %s write when a later auth store write fails",
async (concurrentMutation) => {
const firstAgentDir = path.join(fixture.rootDir, `concurrent-${concurrentMutation}-agent`);
const secondAgentDir = path.join(fixture.rootDir, "concurrent-failing-agent");
const secondStorePath = resolveAuthProfileDatabasePath(secondAgentDir);
registerResolvedAgentDir({ agentId: "first", agentDir: firstAgentDir });
registerResolvedAgentDir({ agentId: "second", agentDir: secondAgentDir });
await writeJsonFile(fixture.configPath, {
agents: {
list: [
{ id: "first", agentDir: firstAgentDir },
{ id: "second", agentDir: secondAgentDir },
],
},
});
const initialStore: AuthProfileStore = {
version: 1,
profiles: {
"openai:default": {
type: "api_key",
provider: "openai",
key: "sk-before-apply", // pragma: allowlist secret
},
"openai:oauth": {
type: "oauth",
provider: "openai",
access: "oauth-before-apply",
refresh: "refresh-before-apply",
expires: Date.now() + 60_000,
},
},
order: { openai: ["openai:default"] },
};
saveAuthProfileStore(initialStore, firstAgentDir, { syncExternalCli: false });
replaceRuntimeAuthProfileStoreSnapshots([{ agentDir: firstAgentDir, store: initialStore }]);
const secondDatabase = openOpenClawAgentDatabase({
agentId: "second",
path: secondStorePath,
});
secondDatabase.db.exec(`
CREATE TRIGGER reject_concurrent_second_auth_store_insert
BEFORE INSERT ON auth_profile_store
BEGIN
SELECT RAISE(ABORT, 'injected concurrent second auth store failure');
END;
`);
const authTarget = (agentId: string): SecretsApplyPlan["targets"][number] => ({
type: "auth-profiles.api_key.key",
path: "profiles.openai:default.key",
pathSegments: ["profiles", "openai:default", "key"],
agentId,
ref: OPENAI_API_KEY_ENV_REF,
authProfileProvider: "openai",
});
const plan = createPlan({
targets: [authTarget("first"), authTarget("second")],
options: {
scrubEnv: false,
scrubAuthProfilesForProviderTargets: false,
scrubLegacyAuthJson: false,
},
});
storeTesting.setRuntimeSnapshotPublisherForTest((publish) => {
// Mutate persisted rows after the candidate commit but before its
// runtime ownership capture. Rollback must retain this newer writer.
storeTesting.resetRuntimeSnapshotPublisherForTest();
const concurrentStore = readPersistedAuthProfileStoreRaw(firstAgentDir) as {
version: number;
profiles: AuthProfileStore["profiles"];
};
const currentState = readPersistedAuthProfileStateRaw(firstAgentDir) as {
order?: Record<string, string[]>;
} | null;
if (concurrentMutation === "credentials") {
concurrentStore.profiles["openai:oauth"] = {
type: "oauth",
provider: "openai",
access: "oauth-concurrent",
refresh: "refresh-concurrent",
expires: Date.now() + 120_000,
};
}
saveAuthProfileStore(
{
...concurrentStore,
...currentState,
...(concurrentMutation === "state"
? { order: { openai: ["openai:oauth", "openai:default"] } }
: {}),
},
firstAgentDir,
{ syncExternalCli: false },
);
publish();
});
await expect(runSecretsApply({ plan, env: fixture.env, write: true })).rejects.toThrow(
"injected concurrent second auth store failure",
);
const persisted = await readAuthStore({ ...fixture, agentDir: firstAgentDir });
const runtime = getRuntimeAuthProfileStoreSnapshot(firstAgentDir);
if (concurrentMutation === "credentials") {
expect(persisted.profiles["openai:oauth"]).toMatchObject({
access: "oauth-concurrent",
refresh: "refresh-concurrent",
});
expect(runtime?.profiles["openai:oauth"]).toMatchObject({
access: "oauth-concurrent",
refresh: "refresh-concurrent",
});
} else {
expect(persisted.profiles["openai:default"]).toMatchObject({ key: "sk-before-apply" });
expect(persisted.order?.openai).toEqual(["openai:oauth", "openai:default"]);
expect(runtime?.profiles["openai:default"]).toMatchObject({ key: "sk-before-apply" });
expect(runtime?.order?.openai).toEqual(["openai:oauth", "openai:default"]);
}
},
);
it("preserves unrelated oauth profiles while applying auth-profile key ref targets", async () => {
const codexOAuthRef = {
id: "codex-sidecar-ref",
+31 -14
View File
@@ -11,11 +11,12 @@ import {
coercePersistedAuthProfileStore,
loadPersistedAuthProfileStore,
} from "../agents/auth-profiles/persisted.js";
import { resolveAuthProfileDatabasePath } from "../agents/auth-profiles/sqlite.js";
import {
deletePersistedAuthProfileStoreRaw,
resolveAuthProfileDatabasePath,
} from "../agents/auth-profiles/sqlite.js";
import { saveAuthProfileStore } from "../agents/auth-profiles/store.js";
captureAuthProfileStorePersistenceSnapshot,
restoreAuthProfileStorePersistenceSnapshot,
saveAuthProfileStoreIfPersistenceSnapshotMatches,
} from "../agents/auth-profiles/store.js";
import { normalizeProviderId } from "../agents/model-selection.js";
import {
replaceConfigFile,
@@ -64,7 +65,8 @@ type ApplyWrite = {
type AuthStoreSnapshot = {
agentDir: string;
store: ReturnType<typeof loadPersistedAuthProfileStore>;
persistence: ReturnType<typeof captureAuthProfileStorePersistenceSnapshot>;
owned?: ReturnType<typeof captureAuthProfileStorePersistenceSnapshot>;
};
type ProjectedState = {
@@ -928,7 +930,7 @@ export async function runSecretsApply(params: {
if (!authStoreSnapshots.has(pathname)) {
authStoreSnapshots.set(pathname, {
agentDir,
store: loadPersistedAuthProfileStore(agentDir),
persistence: captureAuthProfileStorePersistenceSnapshot(agentDir),
});
}
};
@@ -966,7 +968,21 @@ export async function runSecretsApply(params: {
const agentDir = projected.authStoreAgentDirByPath.get(pathname);
const store = coercePersistedAuthProfileStore(value);
if (agentDir && store) {
saveAuthProfileStore(store, agentDir);
const snapshot = authStoreSnapshots.get(pathname);
if (!snapshot) {
throw new Error(`missing captured auth profile store for ${pathname}`);
}
const committed = saveAuthProfileStoreIfPersistenceSnapshotMatches({
store,
snapshot: snapshot.persistence,
agentDir,
});
// Persisted rows commit before runtime publication. Record their exact
// ownership first so a publication failure can still roll them back.
snapshot.owned = committed.owned;
if (!committed.publishRuntimeSnapshots()) {
throw new Error(`auth profile runtime publication failed for ${pathname}`);
}
}
}
} catch (err) {
@@ -980,14 +996,15 @@ export async function runSecretsApply(params: {
}
}
for (const snapshot of authStoreSnapshots.values()) {
if (!snapshot.owned) {
continue;
}
try {
if (snapshot.store) {
saveAuthProfileStore(snapshot.store, snapshot.agentDir, {
syncExternalCli: false,
});
} else {
deletePersistedAuthProfileStoreRaw(snapshot.agentDir);
}
restoreAuthProfileStorePersistenceSnapshot(
snapshot.persistence,
snapshot.owned,
snapshot.agentDir,
);
} catch {
// Best effort only; preserve original error.
}
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { getRuntimeAuthProfileStoreCredentialsRevision } from "../agents/auth-profiles/runtime-snapshots.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveCommandSecretsFromActiveRuntimeSnapshot } from "./runtime-command-secrets.js";
import { createEmptyRuntimeWebToolsMetadata } from "./runtime-fast-path.js";
@@ -71,6 +72,7 @@ function activateMinimalSecretsRuntimeSnapshot(params: {
sourceConfig: structuredClone(params.config),
config: structuredClone(params.resolvedConfig ?? params.config),
authStores: [],
authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(),
warnings: [],
webTools: createEmptyRuntimeWebToolsMetadata(),
};
+3
View File
@@ -12,6 +12,7 @@ import {
AUTH_STATE_FILENAME,
LEGACY_AUTH_FILENAME,
} from "../agents/auth-profiles/path-constants.js";
import { getRuntimeAuthProfileStoreCredentialsRevision } from "../agents/auth-profiles/runtime-snapshots.js";
import { resolveAuthProfileDatabasePath } from "../agents/auth-profiles/sqlite.js";
import type { AuthProfileStore } from "../agents/auth-profiles/types.js";
import { resolveOAuthPath } from "../config/paths.js";
@@ -232,6 +233,7 @@ export function prepareSecretsRuntimeFastPathSnapshot(params: {
usesAuthStoreFallback: boolean;
} | null {
const runtimeEnv = mergeSecretsRuntimeEnv(params.env);
const authStoreCredentialsRevision = getRuntimeAuthProfileStoreCredentialsRevision();
const sourceConfig = structuredClone(params.config);
const resolvedConfig = structuredClone(params.config);
const includeAuthStoreRefs = params.includeAuthStoreRefs ?? true;
@@ -271,6 +273,7 @@ export function prepareSecretsRuntimeFastPathSnapshot(params: {
sourceConfig,
config: resolvedConfig,
authStores,
authStoreCredentialsRevision,
warnings: [],
webTools: createEmptyRuntimeWebToolsMetadata(),
};
@@ -2,7 +2,8 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import type { OpenClawConfig } from "../config/config.js";
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
@@ -21,6 +22,7 @@ function createOpenAiFileModelsConfig(): NonNullable<OpenClawConfig["models"]> {
}
const { prepareSecretsRuntimeSnapshot } = setupSecretsRuntimeSnapshotTestHooks();
const autoCleanupTempDirs = useAutoCleanupTempDirTracker(afterEach);
function envTokenRef(id: string) {
return { source: "env" as const, provider: "default" as const, id };
@@ -153,6 +155,144 @@ describe("secrets runtime provider and media surfaces", () => {
}
});
it("refreshes provider auth without resolving or republishing gateway state", async () => {
if (process.platform === "win32") {
return;
}
const root = autoCleanupTempDirs.make("openclaw-provider-auth-refresh-");
const secretsPath = path.join(root, "secrets.json");
const writeSecrets = async (gatewayToken: string | undefined, modelKey: string) => {
await fs.writeFile(
secretsPath,
JSON.stringify({ ...(gatewayToken ? { gatewayToken } : {}), modelKey }, null, 2),
"utf8",
);
await fs.chmod(secretsPath, 0o600);
};
try {
const config = asConfig({
secrets: {
providers: {
default: { source: "file", path: secretsPath, mode: "json" },
},
defaults: { file: "default" },
},
gateway: {
auth: {
mode: "token",
token: { source: "file", provider: "default", id: "/gatewayToken" },
},
},
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: { source: "file", provider: "default", id: "/modelKey" },
models: [],
},
},
},
});
await writeSecrets("gateway-old", "model-old");
const initial = await prepareSecretsRuntimeSnapshot({
config,
agentDirs: ["/tmp/openclaw-agent-main"],
loadAuthStore: () => ({ version: 1, profiles: {} }),
});
const {
activateSecretsRuntimeSnapshot,
getActiveSecretsRuntimeSnapshot,
refreshActiveProviderAuthRuntimeSnapshot,
} = await import("./runtime.js");
const { getRuntimeConfigSnapshot, setRuntimeConfigSnapshot } =
await import("../config/runtime-snapshot.js");
activateSecretsRuntimeSnapshot(initial);
setRuntimeConfigSnapshot(
{
...initial.config,
auth: { order: { openai: ["runtime-only-profile"] } },
gateway: {
...initial.config.gateway,
controlUi: { allowedOrigins: ["https://runtime-only.example"] },
},
models: {
...initial.config.models,
pricing: { enabled: true },
},
},
initial.sourceConfig,
);
await writeSecrets(undefined, "model-new");
await expect(refreshActiveProviderAuthRuntimeSnapshot()).resolves.toBe(true);
const active = getActiveSecretsRuntimeSnapshot();
expect(active?.config.gateway?.auth?.token).toBe("gateway-old");
expect(active?.config.gateway?.controlUi?.allowedOrigins).toEqual([
"https://runtime-only.example",
]);
expect(active?.config.auth?.order?.openai).toEqual(["runtime-only-profile"]);
expect(active?.config.models?.pricing?.enabled).toBe(true);
expect(active?.config.models?.providers?.openai?.apiKey).toBe("model-new");
expect(getRuntimeConfigSnapshot()).toEqual(active?.config);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("patches env shorthand model refs into the pinned runtime config", async () => {
const config = asConfig({
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: "$OPENAI_API_KEY",
models: [],
},
},
},
});
const initial = await prepareSecretsRuntimeSnapshot({
config,
env: { OPENAI_API_KEY: "sk-env-current" },
agentDirs: ["/tmp/openclaw-agent-main"],
loadAuthStore: () => ({ version: 1, profiles: {} }),
});
const {
activateSecretsRuntimeSnapshot,
getActiveSecretsRuntimeSnapshot,
refreshActiveProviderAuthRuntimeSnapshot,
} = await import("./runtime.js");
const { setRuntimeConfigSnapshot } = await import("../config/runtime-snapshot.js");
activateSecretsRuntimeSnapshot(initial);
const openaiProvider = initial.config.models?.providers?.openai;
if (!openaiProvider) {
throw new Error("expected resolved OpenAI provider");
}
setRuntimeConfigSnapshot(
{
...initial.config,
models: {
...initial.config.models,
providers: {
...initial.config.models?.providers,
openai: {
...openaiProvider,
apiKey: "sk-stale-pinned",
},
},
},
},
initial.sourceConfig,
);
await expect(refreshActiveProviderAuthRuntimeSnapshot()).resolves.toBe(true);
expect(getActiveSecretsRuntimeSnapshot()?.config.models?.providers?.openai?.apiKey).toBe(
"sk-env-current",
);
});
it("fails when file provider payload is not a JSON object", async () => {
if (process.platform === "win32") {
return;
File diff suppressed because it is too large Load Diff
+817 -6
View File
@@ -1,19 +1,33 @@
/** Holds active secrets runtime snapshots, refresh context, and cleanup hooks. */
import { isDeepStrictEqual } from "node:util";
import {
clearRuntimeAuthProfileStoreSnapshots,
getRuntimeAuthProfileStoreSnapshot,
getRuntimeAuthProfileStoreCredentialMutationToken,
getRuntimeAuthProfileStoreCredentialsRevision,
getRuntimeAuthProfileStoreProfileSetMutationToken,
getRuntimeAuthProfileStoreStateMutationToken,
listRuntimeAuthProfileStoreSnapshots,
replaceRuntimeAuthProfileStoreSnapshots,
} from "../agents/auth-profiles/runtime-snapshots.js";
import type { AuthProfileStore } from "../agents/auth-profiles/types.js";
import type { RuntimeAuthProfileStoreMutationToken } from "../agents/auth-profiles/runtime-snapshots.js";
import type {
AuthProfileCredential,
AuthProfileStore,
RuntimeAuthProfileStore,
} from "../agents/auth-profiles/types.js";
import {
clearRuntimeConfigSnapshot,
setRuntimeConfigSourceSnapshotIfCurrent,
setRuntimeConfigSnapshot,
setRuntimeConfigSnapshotRefreshHandler,
type RuntimeConfigSnapshotRefreshHandler,
} from "../config/runtime-snapshot.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { coerceSecretRef, isSecretRef, type SecretRef } from "../config/types.secrets.js";
import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
import type { PluginOrigin } from "../plugins/plugin-origin.types.js";
import { isRecord } from "../utils.js";
import type { SecretResolverWarning } from "./runtime-shared.js";
import {
clearActiveRuntimeWebToolsMetadata,
@@ -25,7 +39,8 @@ import type { RuntimeWebToolsMetadata } from "./runtime-web-tools.types.js";
export type PreparedSecretsRuntimeSnapshot = {
sourceConfig: OpenClawConfig;
config: OpenClawConfig;
authStores: Array<{ agentDir: string; store: AuthProfileStore }>;
authStores: Array<{ agentDir: string; store: RuntimeAuthProfileStore }>;
authStoreCredentialsRevision: number;
warnings: SecretResolverWarning[];
webTools: RuntimeWebToolsMetadata;
};
@@ -41,6 +56,28 @@ export type SecretsRuntimeRefreshContext = {
};
let activeSnapshot: PreparedSecretsRuntimeSnapshot | null = null;
let activeSnapshotRevision = 0;
let activeSnapshotLineageStartRevision = 0;
// Capture auth truth at candidate publication; descendant credential refreshes keep this base so
// rollback can distinguish pre-activation auth writes from candidate-owned resolved values.
let activeSnapshotLineageAuthStores: PreparedSecretsRuntimeSnapshot["authStores"] = [];
let activeSnapshotLineageAuthMutations: Record<
string,
{
store: {
baseline: StoreMutationLineage;
candidate: StoreMutationLineage;
};
state: { token: RuntimeAuthProfileStoreMutationToken; includeMain: boolean };
profiles: Record<
string,
{
baseline: ProfileOwnerMutationLineage;
candidate: ProfileOwnerMutationLineage;
}
>;
}
> = {};
let activeRefreshContext: SecretsRuntimeRefreshContext | null = null;
const clearHooks = new Set<() => void>();
const preparedSnapshotRefreshContext = new WeakMap<
@@ -48,6 +85,16 @@ const preparedSnapshotRefreshContext = new WeakMap<
SecretsRuntimeRefreshContext
>();
type ProfileOwner = "absent" | "external" | "inherited" | "local";
type ProfileOwnerMutationLineage = {
owner: ProfileOwner;
token: RuntimeAuthProfileStoreMutationToken;
};
type StoreMutationLineage = {
mainProfileSetToken?: RuntimeAuthProfileStoreMutationToken;
token: RuntimeAuthProfileStoreMutationToken;
};
/**
* Clones refresh context while preserving callback identity and isolating mutable maps/config.
*/
@@ -77,11 +124,597 @@ function cloneSnapshot(snapshot: PreparedSecretsRuntimeSnapshot): PreparedSecret
agentDir: entry.agentDir,
store: structuredClone(entry.store),
})),
authStoreCredentialsRevision: snapshot.authStoreCredentialsRevision,
warnings: snapshot.warnings.map((warning) => ({ ...warning })),
webTools: structuredClone(snapshot.webTools),
};
}
function mergeLiveAuthStoreBookkeeping(
authStores: PreparedSecretsRuntimeSnapshot["authStores"],
): PreparedSecretsRuntimeSnapshot["authStores"] {
return authStores.map((entry) => {
const live = getRuntimeAuthProfileStoreSnapshot(entry.agentDir);
if (!live) {
return entry;
}
return {
agentDir: entry.agentDir,
store: {
...entry.store,
order: live.order,
lastGood: live.lastGood,
usageStats: live.usageStats,
},
};
});
}
function profileOwner(store: RuntimeAuthProfileStore | undefined, profileId: string): ProfileOwner {
if (!store?.profiles[profileId]) {
return "absent";
}
if (store.runtimeExternalProfileIds?.includes(profileId)) {
return "external";
}
return store.runtimeLocalProfileIds?.includes(profileId) ? "local" : "inherited";
}
function captureProfileOwnerMutationLineage(
agentDir: string,
store: RuntimeAuthProfileStore | undefined,
profileId: string,
): ProfileOwnerMutationLineage {
const owner = profileOwner(store, profileId);
return {
owner,
token:
owner === "external"
? { revision: 0, known: true }
: getRuntimeAuthProfileStoreCredentialMutationToken(agentDir, profileId, {
includeMain: owner === "absent" || owner === "inherited",
}),
};
}
function captureStoreMutationLineage(
agentDir: string,
store: RuntimeAuthProfileStore | undefined,
): StoreMutationLineage {
const includeMain =
!store ||
Object.keys(store.profiles).length === 0 ||
Object.keys(store.profiles).some((profileId) => profileOwner(store, profileId) === "inherited");
return {
...(includeMain
? { mainProfileSetToken: getRuntimeAuthProfileStoreProfileSetMutationToken() }
: {}),
token: getRuntimeAuthProfileStoreCredentialMutationToken(agentDir),
};
}
function captureAuthStoreMutationLineage(
baselineAuthStores: PreparedSecretsRuntimeSnapshot["authStores"],
candidateAuthStores: PreparedSecretsRuntimeSnapshot["authStores"],
): typeof activeSnapshotLineageAuthMutations {
const baseline = Object.fromEntries(
baselineAuthStores.map((entry) => [entry.agentDir, entry.store]),
);
const candidate = Object.fromEntries(
candidateAuthStores.map((entry) => [entry.agentDir, entry.store]),
);
const agentDirs = new Set([...Object.keys(baseline), ...Object.keys(candidate)]);
return Object.fromEntries(
[...agentDirs].map((agentDir) => {
const baselineStore = baseline[agentDir];
const candidateStore = candidate[agentDir];
const effectiveStore = candidateStore ?? baselineStore;
const profileIds = new Set([
...Object.keys(baselineStore?.profiles ?? {}),
...Object.keys(candidateStore?.profiles ?? {}),
]);
return [
agentDir,
{
store: {
baseline: captureStoreMutationLineage(agentDir, baselineStore),
candidate: captureStoreMutationLineage(agentDir, candidateStore),
},
state: {
token: getRuntimeAuthProfileStoreStateMutationToken(agentDir, {
includeMain: effectiveStore?.runtimeInheritsMainState === true,
}),
includeMain: effectiveStore?.runtimeInheritsMainState === true,
},
profiles: Object.fromEntries(
[...profileIds].map((profileId) => [
profileId,
{
baseline: captureProfileOwnerMutationLineage(agentDir, baselineStore, profileId),
candidate: captureProfileOwnerMutationLineage(agentDir, candidateStore, profileId),
},
]),
),
},
];
}),
);
}
function mergeRollbackValue(previous: unknown, candidate: unknown, current: unknown): unknown {
if (isDeepStrictEqual(candidate, current)) {
return structuredClone(previous);
}
if (isDeepStrictEqual(candidate, previous)) {
return structuredClone(current);
}
if (!isRecord(previous) || !isRecord(candidate) || !isRecord(current)) {
return structuredClone(previous);
}
const merged: Record<string, unknown> = {};
const keys = new Set([
...Object.keys(previous),
...Object.keys(candidate),
...Object.keys(current),
]);
for (const key of keys) {
const value = mergeRollbackValue(previous[key], candidate[key], current[key]);
if (value !== undefined) {
merged[key] = value;
}
}
return merged;
}
function hasSameSecretProviderDefinition(ref: SecretRef, configs: OpenClawConfig[]): boolean {
const definition = configs[0]?.secrets?.providers?.[ref.provider];
if (
!configs.every((config) =>
isDeepStrictEqual(config.secrets?.providers?.[ref.provider], definition),
)
) {
return false;
}
if (!definition || !("pluginIntegration" in definition)) {
return true;
}
// Plugin integration ownership is not fully normalized to one entry. Preserve a resolved value
// only across an unchanged plugin/channel snapshot, or rollback can pair it with rejected owner state.
const dependency = (config: OpenClawConfig) => ({
plugins: config.plugins,
channels: config.channels,
});
const previous = dependency(configs[0]!);
return configs.every((config) => isDeepStrictEqual(dependency(config), previous));
}
function preserveResolvedSecretRefValues(
source: unknown,
currentSource: unknown,
current: unknown,
restored: unknown,
sourceConfig: OpenClawConfig,
currentSourceConfig: OpenClawConfig,
): unknown {
const sourceRef = coerceSecretRef(source, sourceConfig.secrets?.defaults);
if (sourceRef) {
const currentRef = coerceSecretRef(currentSource, currentSourceConfig.secrets?.defaults);
return currentRef &&
isDeepStrictEqual(sourceRef, currentRef) &&
hasSameSecretProviderDefinition(sourceRef, [sourceConfig, currentSourceConfig])
? structuredClone(current)
: restored;
}
if (Array.isArray(source) && Array.isArray(current) && Array.isArray(restored)) {
const next = [...restored];
for (const [index, value] of source.entries()) {
next[index] = preserveResolvedSecretRefValues(
value,
Array.isArray(currentSource) ? currentSource[index] : undefined,
current[index],
next[index],
sourceConfig,
currentSourceConfig,
);
}
return next;
}
if (isRecord(source) && isRecord(current) && isRecord(restored)) {
const next = { ...restored };
for (const [key, value] of Object.entries(source)) {
next[key] = preserveResolvedSecretRefValues(
value,
isRecord(currentSource) ? currentSource[key] : undefined,
current[key],
next[key],
sourceConfig,
currentSourceConfig,
);
}
return next;
}
return restored;
}
function preserveResolvedAuthStoreSecretValues(
previous: Record<string, AuthProfileStore>,
candidate: Record<string, AuthProfileStore>,
restored: Record<string, AuthProfileStore>,
current: Record<string, AuthProfileStore>,
previousConfig: OpenClawConfig,
candidateConfig: OpenClawConfig,
currentConfig: OpenClawConfig,
): Record<string, AuthProfileStore> {
const next = structuredClone(restored);
for (const [agentDir, store] of Object.entries(next)) {
const previousStore = previous[agentDir];
const candidateStore = candidate[agentDir];
const currentStore = current[agentDir];
if (!previousStore || !candidateStore || !currentStore) {
continue;
}
for (const [profileId, credential] of Object.entries(store.profiles)) {
const previousCredential = previousStore.profiles[profileId];
const candidateCredential = candidateStore.profiles[profileId];
const currentCredential = currentStore.profiles[profileId];
if (
credential.type === "api_key" &&
previousCredential?.type === "api_key" &&
candidateCredential?.type === "api_key" &&
currentCredential?.type === "api_key" &&
isSecretRef(credential.keyRef) &&
isDeepStrictEqual(credential.keyRef, previousCredential.keyRef) &&
isDeepStrictEqual(credential.keyRef, candidateCredential.keyRef) &&
isDeepStrictEqual(credential.keyRef, currentCredential.keyRef) &&
hasSameSecretProviderDefinition(credential.keyRef, [
previousConfig,
candidateConfig,
currentConfig,
]) &&
currentCredential.key !== undefined
) {
store.profiles[profileId] = { ...credential, key: currentCredential.key };
} else if (
credential.type === "token" &&
previousCredential?.type === "token" &&
candidateCredential?.type === "token" &&
currentCredential?.type === "token" &&
isSecretRef(credential.tokenRef) &&
isDeepStrictEqual(credential.tokenRef, previousCredential.tokenRef) &&
isDeepStrictEqual(credential.tokenRef, candidateCredential.tokenRef) &&
isDeepStrictEqual(credential.tokenRef, currentCredential.tokenRef) &&
hasSameSecretProviderDefinition(credential.tokenRef, [
previousConfig,
candidateConfig,
currentConfig,
]) &&
currentCredential.token !== undefined
) {
store.profiles[profileId] = { ...credential, token: currentCredential.token };
}
}
}
return next;
}
function preserveLiveAuthStoreBookkeeping(
restored: Record<string, AuthProfileStore>,
current: Record<string, AuthProfileStore>,
): Record<string, AuthProfileStore> {
const next = structuredClone(restored);
for (const [agentDir, store] of Object.entries(next)) {
const currentStore = current[agentDir];
if (!currentStore) {
continue;
}
if (currentStore.order === undefined) {
delete store.order;
} else {
store.order = structuredClone(currentStore.order);
}
if (currentStore.lastGood === undefined) {
delete store.lastGood;
} else {
store.lastGood = structuredClone(currentStore.lastGood);
}
if (currentStore.usageStats === undefined) {
delete store.usageStats;
} else {
store.usageStats = structuredClone(currentStore.usageStats);
}
}
return next;
}
function credentialSecretRef(credential: AuthProfileCredential | undefined): SecretRef | null {
if (credential?.type === "api_key" && isSecretRef(credential.keyRef)) {
return credential.keyRef;
}
if (credential?.type === "token" && isSecretRef(credential.tokenRef)) {
return credential.tokenRef;
}
return null;
}
function rebuildSelectedRuntimeProfileMetadata(
store: RuntimeAuthProfileStore,
selectedSources: Map<string, RuntimeAuthProfileStore>,
): void {
const profileIdsFor = (
field: "runtimeExternalProfileIds" | "runtimeLocalProfileIds" | "runtimePersistedProfileIds",
) =>
[...selectedSources]
.flatMap(([profileId, source]) => (source[field]?.includes(profileId) ? [profileId] : []))
.toSorted();
const persistedProfileIds = profileIdsFor("runtimePersistedProfileIds");
store.runtimePersistedProfileIds =
persistedProfileIds.length > 0 ? persistedProfileIds : undefined;
const localProfileIds = profileIdsFor("runtimeLocalProfileIds");
store.runtimeLocalProfileIds = localProfileIds.length > 0 ? localProfileIds : undefined;
const externalProfileIds = profileIdsFor("runtimeExternalProfileIds");
// Authority is store-wide three-way state; profile selection must not import it
// from an unrelated credential source.
const externalAuthoritative = store.runtimeExternalProfileIdsAuthoritative === true;
store.runtimeExternalProfileIds =
externalProfileIds.length > 0 || externalAuthoritative ? externalProfileIds : undefined;
store.runtimeExternalProfileIdsAuthoritative = externalAuthoritative ? true : undefined;
}
function compareMutationTokens(
captured: RuntimeAuthProfileStoreMutationToken,
current: RuntimeAuthProfileStoreMutationToken,
): "mutated" | "unchanged" | "unknown" {
if (!captured.known || !current.known) {
return "unknown";
}
return captured.revision === current.revision ? "unchanged" : "mutated";
}
function readProfileOwnerMutationToken(
agentDir: string,
profileId: string,
owner: ProfileOwner,
): RuntimeAuthProfileStoreMutationToken {
return owner === "external"
? { revision: 0, known: true }
: getRuntimeAuthProfileStoreCredentialMutationToken(agentDir, profileId, {
includeMain: owner === "absent" || owner === "inherited",
});
}
function getProfileMutationDecision(params: {
agentDir: string;
profileId: string;
mutationLineage: typeof activeSnapshotLineageAuthMutations;
}): {
baselineOwner: ProfileOwner;
candidateOwner: ProfileOwner;
candidateStatus: "mutated" | "unchanged" | "unknown";
ownerChanged: boolean;
status: "mutated" | "unchanged" | "unknown";
} {
const captured = params.mutationLineage[params.agentDir]?.profiles[params.profileId];
if (!captured) {
return {
baselineOwner: "absent",
candidateOwner: "absent",
candidateStatus: "mutated",
ownerChanged: false,
status: "mutated",
};
}
const ownerChanged = captured.baseline.owner !== captured.candidate.owner;
const relevant = ownerChanged ? captured.baseline : captured.candidate;
return {
baselineOwner: captured.baseline.owner,
candidateOwner: captured.candidate.owner,
candidateStatus: compareMutationTokens(
captured.candidate.token,
readProfileOwnerMutationToken(params.agentDir, params.profileId, captured.candidate.owner),
),
ownerChanged,
status: compareMutationTokens(
relevant.token,
readProfileOwnerMutationToken(params.agentDir, params.profileId, relevant.owner),
),
};
}
function mergeRollbackAuthStoreCredentials(
baseline: Record<string, AuthProfileStore>,
candidate: Record<string, AuthProfileStore>,
current: Record<string, AuthProfileStore>,
restored: Record<string, AuthProfileStore>,
configs: [OpenClawConfig, OpenClawConfig, OpenClawConfig],
mutationLineage: typeof activeSnapshotLineageAuthMutations,
): Record<string, AuthProfileStore> {
const next = structuredClone(restored);
const agentDirs = new Set([
...Object.keys(baseline),
...Object.keys(candidate),
...Object.keys(current),
]);
for (const agentDir of agentDirs) {
let invalidateStore = false;
const baselineStore = baseline[agentDir];
const candidateStore = candidate[agentDir];
const currentStore = current[agentDir];
const currentStoreMutationStatus = (lineage: StoreMutationLineage | undefined) => {
const ownerStatus = compareMutationTokens(
lineage?.token ?? { revision: 0, known: true },
getRuntimeAuthProfileStoreCredentialMutationToken(agentDir),
);
const mainProfileSetStatus = lineage?.mainProfileSetToken
? compareMutationTokens(
lineage.mainProfileSetToken,
getRuntimeAuthProfileStoreProfileSetMutationToken(),
)
: "unchanged";
return ownerStatus === "mutated" || mainProfileSetStatus === "mutated"
? "mutated"
: ownerStatus === "unknown" || mainProfileSetStatus === "unknown"
? "unknown"
: "unchanged";
};
const baselineStoreMutationStatus = currentStoreMutationStatus(
mutationLineage[agentDir]?.store.baseline,
);
const candidateStoreMutationStatus = currentStoreMutationStatus(
mutationLineage[agentDir]?.store.candidate,
);
const stateMutationStatus = compareMutationTokens(
mutationLineage[agentDir]?.state.token ?? { revision: 0, known: true },
getRuntimeAuthProfileStoreStateMutationToken(agentDir, {
includeMain: mutationLineage[agentDir]?.state.includeMain === true,
}),
);
const profileOwnerMutated = Object.keys(baselineStore?.profiles ?? {}).some((profileId) => {
const decision = getProfileMutationDecision({
agentDir,
profileId,
mutationLineage,
});
return decision.status !== "unchanged" || decision.candidateStatus !== "unchanged";
});
if (!currentStore) {
if (
!candidateStore &&
baselineStore &&
baselineStoreMutationStatus === "unchanged" &&
candidateStoreMutationStatus === "unchanged" &&
stateMutationStatus === "unchanged" &&
!profileOwnerMutated
) {
next[agentDir] = structuredClone(baselineStore);
} else {
delete next[agentDir];
}
continue;
}
const store = next[agentDir] ?? structuredClone(baselineStore ?? currentStore);
const profiles: AuthProfileStore["profiles"] = {};
const selectedSources = new Map<string, AuthProfileStore>();
const profileIds = new Set([
...Object.keys(baselineStore?.profiles ?? {}),
...Object.keys(candidateStore?.profiles ?? {}),
...Object.keys(currentStore.profiles),
]);
for (const profileId of profileIds) {
const baselineCredential = baselineStore?.profiles[profileId];
const candidateCredential = candidateStore?.profiles[profileId];
const currentCredential = currentStore.profiles[profileId];
const profileMutationDecision = getProfileMutationDecision({
agentDir,
profileId,
mutationLineage,
});
const profileMutationStatus = profileMutationDecision.status;
const profileMutated = profileMutationStatus === "mutated";
const currentOwner = profileOwner(currentStore, profileId);
let credential: AuthProfileCredential | undefined;
let selectedSource: AuthProfileStore | undefined;
if (currentOwner !== profileMutationDecision.candidateOwner) {
credential = currentCredential;
selectedSource = currentStore;
} else if (profileMutationDecision.ownerChanged) {
if (
profileMutationStatus !== "unchanged" ||
profileMutationDecision.candidateStatus !== "unchanged"
) {
invalidateStore = true;
} else {
credential = baselineCredential;
selectedSource = baselineStore;
}
} else if (profileMutationStatus === "unknown") {
if (isDeepStrictEqual(baselineCredential, candidateCredential)) {
credential = currentCredential;
selectedSource = currentStore;
} else {
invalidateStore = true;
}
} else {
if (isDeepStrictEqual(currentCredential, candidateCredential)) {
if (profileMutated) {
credential = currentCredential;
selectedSource = currentStore;
} else {
credential = baselineCredential;
selectedSource = baselineStore;
}
} else {
credential = currentCredential;
selectedSource = currentStore;
}
}
const baselineRef = credentialSecretRef(baselineCredential);
const candidateRef = credentialSecretRef(candidateCredential);
const currentRef = credentialSecretRef(currentCredential);
if (
currentOwner === profileMutationDecision.candidateOwner &&
profileMutationStatus === "unchanged" &&
candidateRef &&
currentRef &&
isDeepStrictEqual(candidateRef, currentRef) &&
!isDeepStrictEqual(baselineRef, candidateRef)
) {
// Candidate activation owns the ref transition. Descendant resolution may refresh the
// literal, but without a persisted write rollback still restores the previous owner/ref.
credential = baselineCredential;
selectedSource = baselineStore;
}
if (
baselineRef &&
candidateRef &&
currentRef &&
isDeepStrictEqual(baselineRef, candidateRef) &&
isDeepStrictEqual(baselineRef, currentRef) &&
!hasSameSecretProviderDefinition(baselineRef, configs)
) {
if (
currentOwner !== profileMutationDecision.candidateOwner ||
profileMutationStatus !== "unchanged"
) {
invalidateStore = true;
credential = undefined;
selectedSource = undefined;
} else {
credential = baselineCredential;
selectedSource = baselineStore;
}
}
const selectedRef = credentialSecretRef(credential);
if (
selectedSource === currentStore &&
selectedRef &&
!hasSameSecretProviderDefinition(selectedRef, [configs[0], configs[1]])
) {
invalidateStore = true;
credential = undefined;
selectedSource = undefined;
}
if (credential && selectedSource) {
profiles[profileId] = structuredClone(credential);
selectedSources.set(profileId, selectedSource);
}
}
if (invalidateStore) {
// Exact persisted ownership was evicted. Remove the runtime store so the
// next auth load reads durable truth instead of publishing a partial clone.
delete next[agentDir];
continue;
}
if (!baselineStore && Object.keys(profiles).length === 0) {
delete next[agentDir];
continue;
}
store.profiles = profiles;
rebuildSelectedRuntimeProfileMetadata(store, selectedSources);
next[agentDir] = store;
}
return next;
}
/**
* Associates a prepared snapshot with the refresh context needed after activation.
*/
@@ -109,6 +742,16 @@ export function getActiveSecretsRuntimeRefreshContext(): SecretsRuntimeRefreshCo
return activeRefreshContext ? cloneSecretsRuntimeRefreshContext(activeRefreshContext) : null;
}
/** Retain live auth state when a one-shot config write intentionally skips auth-store refs. */
export function graftActiveSecretsRuntimeAuthState(snapshot: PreparedSecretsRuntimeSnapshot): void {
if (!activeRefreshContext) {
return;
}
snapshot.authStores = getLiveSecretsRuntimeAuthStores();
snapshot.authStoreCredentialsRevision = getRuntimeAuthProfileStoreCredentialsRevision();
setPreparedSecretsRuntimeSnapshotRefreshContext(snapshot, activeRefreshContext);
}
/**
* Returns the env used by the active runtime snapshot, falling back to process env.
*/
@@ -132,14 +775,43 @@ export function activateSecretsRuntimeSnapshotState(params: {
snapshot: PreparedSecretsRuntimeSnapshot;
refreshContext: SecretsRuntimeRefreshContext | null;
refreshHandler: RuntimeConfigSnapshotRefreshHandler | null;
mergeLiveAuthBookkeeping?: boolean;
preserveActivationLineage?: boolean;
}): void {
if (!hasCurrentAuthStoreCredentialsRevision(params.snapshot)) {
throw new Error(
"Cannot activate stale secrets runtime snapshot: auth credentials changed during preparation.",
);
}
const next = cloneSnapshot(params.snapshot);
if (params.mergeLiveAuthBookkeeping !== false) {
next.authStores = mergeLiveAuthStoreBookkeeping(next.authStores);
}
const activationAuthStores = structuredClone(listRuntimeAuthProfileStoreSnapshots());
const previousLineageAuthStores = activeSnapshotLineageAuthStores;
const activationAuthMutations = captureAuthStoreMutationLineage(
activationAuthStores,
next.authStores,
);
const previousLineageAuthMutations = activeSnapshotLineageAuthMutations;
const nextRefreshContext = params.refreshContext
? cloneSecretsRuntimeRefreshContext(params.refreshContext)
: null;
setRuntimeConfigSnapshot(next.config, next.sourceConfig);
replaceRuntimeAuthProfileStoreSnapshots(next.authStores);
next.authStoreCredentialsRevision = getRuntimeAuthProfileStoreCredentialsRevision();
const previousLineageStartRevision = activeSnapshotLineageStartRevision;
activeSnapshot = next;
activeSnapshotRevision += 1;
activeSnapshotLineageStartRevision = params.preserveActivationLineage
? previousLineageStartRevision
: activeSnapshotRevision;
activeSnapshotLineageAuthStores = params.preserveActivationLineage
? previousLineageAuthStores
: activationAuthStores;
activeSnapshotLineageAuthMutations = params.preserveActivationLineage
? previousLineageAuthMutations
: activationAuthMutations;
activeRefreshContext = nextRefreshContext;
if (nextRefreshContext) {
preparedSnapshotRefreshContext.set(next, cloneSecretsRuntimeRefreshContext(nextRefreshContext));
@@ -148,6 +820,102 @@ export function activateSecretsRuntimeSnapshotState(params: {
setRuntimeConfigSnapshotRefreshHandler(params.refreshHandler);
}
/** Whether a prepared snapshot still owns the credential state it cloned. */
export function hasCurrentAuthStoreCredentialsRevision(
snapshot: PreparedSecretsRuntimeSnapshot,
): boolean {
return snapshot.authStoreCredentialsRevision === getRuntimeAuthProfileStoreCredentialsRevision();
}
/** Activates only while the caller still owns the snapshot revision it prepared against. */
export function activateSecretsRuntimeSnapshotStateIfCurrent(
params: Parameters<typeof activateSecretsRuntimeSnapshotState>[0] & {
expectedRevision: number;
},
): boolean {
if (
activeSnapshotRevision !== params.expectedRevision ||
!hasCurrentAuthStoreCredentialsRevision(params.snapshot)
) {
return false;
}
activateSecretsRuntimeSnapshotState(params);
return true;
}
/** Restores an owned predecessor while retaining changes after candidate preparation. */
export function restoreSecretsRuntimeSnapshotStateIfCurrent(
params: Parameters<typeof activateSecretsRuntimeSnapshotState>[0] & {
expectedRevision: number;
ownedSnapshot: PreparedSecretsRuntimeSnapshot;
},
): boolean {
if (!activeSnapshot || activeSnapshotLineageStartRevision !== params.expectedRevision) {
return false;
}
const baselineAuthStores = Object.fromEntries(
activeSnapshotLineageAuthStores.map((entry) => [entry.agentDir, entry.store]),
);
const candidateAuthStores = Object.fromEntries(
params.ownedSnapshot.authStores.map((entry) => [entry.agentDir, entry.store]),
);
const currentAuthStores = Object.fromEntries(
listRuntimeAuthProfileStoreSnapshots().map((entry) => [entry.agentDir, entry.store]),
);
const mergedAuthStores = mergeRollbackAuthStoreCredentials(
baselineAuthStores,
candidateAuthStores,
currentAuthStores,
mergeRollbackValue(baselineAuthStores, candidateAuthStores, currentAuthStores) as Record<
string,
AuthProfileStore
>,
[params.snapshot.sourceConfig, params.ownedSnapshot.sourceConfig, activeSnapshot.sourceConfig],
activeSnapshotLineageAuthMutations,
);
const currentCredentialsRevision = getRuntimeAuthProfileStoreCredentialsRevision();
const restoredAuthStores = preserveLiveAuthStoreBookkeeping(
preserveResolvedAuthStoreSecretValues(
baselineAuthStores,
candidateAuthStores,
mergedAuthStores,
currentAuthStores,
params.snapshot.sourceConfig,
params.ownedSnapshot.sourceConfig,
activeSnapshot.sourceConfig,
),
currentAuthStores,
);
const restoredSourceConfig = mergeRollbackValue(
params.snapshot.sourceConfig,
params.ownedSnapshot.sourceConfig,
activeSnapshot.sourceConfig,
) as OpenClawConfig;
const restoredConfig = preserveResolvedSecretRefValues(
restoredSourceConfig,
activeSnapshot.sourceConfig,
activeSnapshot.config,
mergeRollbackValue(params.snapshot.config, params.ownedSnapshot.config, activeSnapshot.config),
restoredSourceConfig,
activeSnapshot.sourceConfig,
) as OpenClawConfig;
return activateSecretsRuntimeSnapshotStateIfCurrent({
...params,
snapshot: {
...params.snapshot,
sourceConfig: restoredSourceConfig,
config: restoredConfig,
authStores: Object.entries(restoredAuthStores)
.map(([agentDir, store]) => ({ agentDir, store }))
.toSorted((left, right) => left.agentDir.localeCompare(right.agentDir)),
authStoreCredentialsRevision: currentCredentialsRevision,
},
mergeLiveAuthBookkeeping: false,
preserveActivationLineage: false,
expectedRevision: activeSnapshotRevision,
});
}
/**
* Returns a cloned active secrets runtime snapshot for callers that need mutable data.
*/
@@ -156,6 +924,8 @@ export function getActiveSecretsRuntimeSnapshot(): PreparedSecretsRuntimeSnapsho
return null;
}
const snapshot = cloneSnapshot(activeSnapshot);
snapshot.authStores = listRuntimeAuthProfileStoreSnapshots();
snapshot.authStoreCredentialsRevision = getRuntimeAuthProfileStoreCredentialsRevision();
if (activeRefreshContext) {
preparedSnapshotRefreshContext.set(
snapshot,
@@ -165,6 +935,43 @@ export function getActiveSecretsRuntimeSnapshot(): PreparedSecretsRuntimeSnapsho
return snapshot;
}
/** Stable token for compare-and-activate ownership across cloned snapshot reads. */
export function getActiveSecretsRuntimeSnapshotRevision(): number {
return activeSnapshotRevision;
}
/** Advance canonical source ownership without replacing resolved runtime or auth bytes. */
export function setSecretsRuntimeSourceSnapshotIfCurrent(params: {
expectedSecretsRevision: number;
expectedRuntimeConfigRevision: number;
runtimeSourceConfig: OpenClawConfig;
secretsSourceConfig: OpenClawConfig;
}): boolean {
if (activeSnapshotRevision !== params.expectedSecretsRevision) {
return false;
}
const nextRuntimeSourceConfig = structuredClone(params.runtimeSourceConfig);
const nextSecretsSourceConfig = structuredClone(params.secretsSourceConfig);
const currentAuthStores = structuredClone(listRuntimeAuthProfileStoreSnapshots());
const nextAuthMutations = captureAuthStoreMutationLineage(currentAuthStores, currentAuthStores);
if (
!setRuntimeConfigSourceSnapshotIfCurrent({
expectedRevision: params.expectedRuntimeConfigRevision,
sourceConfig: nextRuntimeSourceConfig,
})
) {
return false;
}
if (activeSnapshot) {
activeSnapshot.sourceConfig = nextSecretsSourceConfig;
activeSnapshotRevision += 1;
activeSnapshotLineageStartRevision = activeSnapshotRevision;
activeSnapshotLineageAuthStores = currentAuthStores;
activeSnapshotLineageAuthMutations = nextAuthMutations;
}
return true;
}
// Hot-path readers only need the config pair for availability decisions.
// Return the active references and keep full snapshot clone isolation on
// getActiveSecretsRuntimeSnapshot() for callers that need mutable data.
@@ -188,16 +995,20 @@ export function getLiveSecretsRuntimeAuthStores(): PreparedSecretsRuntimeSnapsho
if (!activeSnapshot) {
return [];
}
return activeSnapshot.authStores.map((entry) => ({
agentDir: entry.agentDir,
store: getRuntimeAuthProfileStoreSnapshot(entry.agentDir) ?? structuredClone(entry.store),
}));
return activeSnapshot.authStores.flatMap((entry) => {
const store = getRuntimeAuthProfileStoreSnapshot(entry.agentDir);
return store ? [{ agentDir: entry.agentDir, store }] : [];
});
}
/**
* Clears active secrets runtime state and all linked config/auth/web-tool snapshots.
*/
export function clearSecretsRuntimeSnapshot(): void {
activeSnapshotRevision += 1;
activeSnapshotLineageStartRevision = 0;
activeSnapshotLineageAuthStores = [];
activeSnapshotLineageAuthMutations = {};
activeSnapshot = null;
activeRefreshContext = null;
clearActiveRuntimeWebToolsMetadata();
+141 -2
View File
@@ -263,7 +263,7 @@ describe("secrets runtime fast path", () => {
const { prepareSecretsRuntimeFastPathSnapshot } = await import("./runtime-fast-path.js");
const { activateSecretsRuntimeSnapshotState, getActiveSecretsRuntimeSnapshot } =
await import("./runtime-state.js");
const { refreshActiveSecretsRuntimeSnapshot } = await import("./runtime.js");
const { refreshActiveProviderAuthRuntimeSnapshot } = await import("./runtime.js");
const root = mkdtempSync(path.join(tmpdir(), "openclaw-runtime-fast-path-refresh-"));
const env: NodeJS.ProcessEnv = {
HOME: root,
@@ -290,7 +290,7 @@ describe("secrets runtime fast path", () => {
});
writeAuthProfileStore(agentDir);
await expect(refreshActiveSecretsRuntimeSnapshot()).resolves.toBe(true);
await expect(refreshActiveProviderAuthRuntimeSnapshot()).resolves.toBe(true);
const active = getActiveSecretsRuntimeSnapshot();
expect(active?.authStores[0]?.agentDir).toBe(agentDir);
expect(active?.authStores[0]?.store.profiles["openai:default"]).toMatchObject({
@@ -303,6 +303,145 @@ describe("secrets runtime fast path", () => {
}
});
it("does not let an active refresh overwrite a snapshot published during preparation", async () => {
const {
activateSecretsRuntimeSnapshot,
getActiveSecretsRuntimeSnapshot,
prepareSecretsRuntimeSnapshot,
refreshActiveProviderAuthRuntimeSnapshot,
} = await import("./runtime.js");
const agentDir = "/tmp/openclaw-agent-refresh-cas";
let publishNewerSnapshot = false;
let newerSnapshot: Awaited<ReturnType<typeof prepareSecretsRuntimeSnapshot>> | null = null;
const loadInitialAuthStore = () => {
if (publishNewerSnapshot && newerSnapshot) {
publishNewerSnapshot = false;
activateSecretsRuntimeSnapshot(newerSnapshot);
}
return emptyAuthStore();
};
const config = (port: number) =>
asConfig({
agents: { list: [{ id: "default", agentDir }] },
gateway: { port },
});
const initialSnapshot = await prepareSecretsRuntimeSnapshot({
config: config(19_001),
agentDirs: [agentDir],
loadAuthStore: loadInitialAuthStore,
});
newerSnapshot = await prepareSecretsRuntimeSnapshot({
config: config(19_002),
agentDirs: [agentDir],
loadAuthStore: emptyAuthStore,
});
activateSecretsRuntimeSnapshot(initialSnapshot);
publishNewerSnapshot = true;
await expect(refreshActiveProviderAuthRuntimeSnapshot()).resolves.toBe(true);
expect(getActiveSecretsRuntimeSnapshot()?.sourceConfig.gateway?.port).toBe(19_002);
});
it("does not let an active refresh overwrite auth stores mutated during preparation", async () => {
const { getRuntimeAuthProfileStoreSnapshot, setRuntimeAuthProfileStoreSnapshot } =
await import("../agents/auth-profiles/runtime-snapshots.js");
const {
activateSecretsRuntimeSnapshot,
getActiveSecretsRuntimeSnapshot,
prepareSecretsRuntimeSnapshot,
refreshActiveProviderAuthRuntimeSnapshot,
} = await import("./runtime.js");
const agentDir = "/tmp/openclaw-agent-auth-store-refresh-cas";
const oldStore: AuthProfileStore = {
version: 1,
profiles: {
"openai:default": { type: "api_key", provider: "openai", key: "sk-old" },
},
};
const newStore: AuthProfileStore = {
version: 1,
profiles: {
"openai:default": { type: "api_key", provider: "openai", key: "sk-new" },
},
};
let mutateDuringRefresh = false;
const loadAuthStore = () => {
if (mutateDuringRefresh) {
mutateDuringRefresh = false;
setRuntimeAuthProfileStoreSnapshot(newStore, agentDir);
return oldStore;
}
return getRuntimeAuthProfileStoreSnapshot(agentDir) ?? oldStore;
};
const initial = await prepareSecretsRuntimeSnapshot({
config: asConfig({ agents: { list: [{ id: "default", agentDir }] } }),
agentDirs: [agentDir],
loadAuthStore,
});
activateSecretsRuntimeSnapshot(initial);
mutateDuringRefresh = true;
await expect(refreshActiveProviderAuthRuntimeSnapshot()).resolves.toBe(true);
expect(
getActiveSecretsRuntimeSnapshot()?.authStores[0]?.store.profiles["openai:default"],
).toMatchObject({ key: "sk-new" });
expect(getRuntimeAuthProfileStoreSnapshot(agentDir)?.profiles["openai:default"]).toMatchObject({
key: "sk-new",
});
});
it("re-prepares a preflighted config refresh after its snapshot revision goes stale", async () => {
const { getRuntimeConfigSnapshotRefreshHandler } =
await import("../config/runtime-snapshot.js");
const {
activateSecretsRuntimeSnapshot,
getActiveSecretsRuntimeSnapshot,
prepareSecretsRuntimeSnapshot,
} = await import("./runtime.js");
const agentDir = "/tmp/openclaw-agent-preflight-cas";
const authStore = (key: string): AuthProfileStore => ({
version: 1,
profiles: {
"openai:default": { type: "api_key", provider: "openai", key },
},
});
const config = (port: number) =>
asConfig({
agents: { list: [{ id: "default", agentDir }] },
gateway: { port },
});
const initial = await prepareSecretsRuntimeSnapshot({
config: config(19_011),
agentDirs: [agentDir],
loadAuthStore: () => authStore("old-key"),
});
activateSecretsRuntimeSnapshot(initial);
const concurrent = await prepareSecretsRuntimeSnapshot({
config: config(19_012),
agentDirs: [agentDir],
loadAuthStore: () => authStore("new-key"),
});
const staleRefreshHandler = getRuntimeConfigSnapshotRefreshHandler();
if (!staleRefreshHandler?.preflight) {
throw new Error("expected active runtime refresh preflight handler");
}
const desiredConfig = config(19_013);
const preflightResult = await staleRefreshHandler.preflight({
sourceConfig: desiredConfig,
});
activateSecretsRuntimeSnapshot(concurrent);
await expect(
staleRefreshHandler.refresh({ sourceConfig: desiredConfig, preflightResult }),
).resolves.toBe(true);
const activeStore = getActiveSecretsRuntimeSnapshot()?.authStores[0]?.store;
expect(activeStore?.profiles["openai:default"]).toMatchObject({ key: "new-key" });
expect(getActiveSecretsRuntimeSnapshot()?.sourceConfig.gateway?.port).toBe(19_013);
});
it("pins empty auth stores on startup-only fast-path snapshots until refresh", async () => {
const { ensureAuthProfileStoreWithoutExternalProfiles } =
await import("../agents/auth-profiles/store.js");
@@ -101,7 +101,7 @@ describe("prepareSecretsRuntimeSnapshot loadable plugin origins", () => {
expect(manifestMocks.listPluginOriginsFromMetadataSnapshot).toHaveBeenCalledWith(snapshot);
});
it("carries the shared manifest registry into plugin-managed SecretRef resolution", async () => {
it("keeps full plugin policy while projecting provider-auth assignments", async () => {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "oc-runtime-secret-provider-"));
fs.chmodSync(rootDir, 0o700);
fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8");
@@ -151,32 +151,51 @@ describe("prepareSecretsRuntimeSnapshot loadable plugin origins", () => {
};
try {
const snapshot = await prepareSecretsRuntimeSnapshot({
config: asConfig({
gateway: {
auth: {
mode: "token",
token: { source: "exec", provider: "vault", id: "gateway/token" },
const config = asConfig({
plugins: {
entries: {
"vault-secrets": { enabled: true },
},
},
gateway: {
auth: {
mode: "token",
token: { source: "exec", provider: "vault", id: "gateway/token" },
},
},
models: {
providers: {
openai: {
apiKey: { source: "exec", provider: "vault", id: "models/openai" },
models: [],
},
},
secrets: {
providers: {
vault: {
source: "exec",
pluginIntegration: {
pluginId: "vault-secrets",
integrationId: "vault",
},
},
secrets: {
providers: {
vault: {
source: "exec",
pluginIntegration: {
pluginId: "vault-secrets",
integrationId: "vault",
},
},
},
},
});
const snapshot = await prepareSecretsRuntimeSnapshot({
config,
assignmentConfig: asConfig({
models: config.models,
secrets: config.secrets,
}),
env: { HOME: rootDir },
includeAuthStoreRefs: false,
pluginMetadataSnapshot,
});
expect(snapshot.config.gateway?.auth?.token).toBe("value:gateway/token");
expect(snapshot.config.gateway).toBeUndefined();
expect(snapshot.config.models?.providers?.openai?.apiKey).toBe("value:models/openai");
expect(manifestMocks.loadPluginMetadataSnapshot).not.toHaveBeenCalled();
expect(manifestMocks.listPluginOriginsFromMetadataSnapshot).toHaveBeenCalledWith(
pluginMetadataSnapshot,
+254 -85
View File
@@ -7,14 +7,20 @@ import {
loadAuthProfileStoreForSecretsRuntime,
loadAuthProfileStoreWithoutExternalProfiles,
} from "../agents/auth-profiles.js";
import { getRuntimeAuthProfileStoreCredentialsRevision } from "../agents/auth-profiles/runtime-snapshots.js";
import type { AuthProfileStore } from "../agents/auth-profiles/types.js";
import {
getRuntimeConfigSnapshot,
type RuntimeConfigSnapshotRefreshParams,
} from "../config/runtime-snapshot.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { coerceSecretRef } from "../config/types.secrets.js";
import { registerSecretValueForRedaction } from "../logging/secret-redaction-registry.js";
import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
import type { PluginOrigin } from "../plugins/plugin-origin.types.js";
import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
import { resolveUserPath } from "../utils.js";
import { isRecord, resolveUserPath } from "../utils.js";
import {
canUseSecretsRuntimeFastPath,
collectCandidateAgentDirs,
@@ -24,13 +30,16 @@ import {
} from "./runtime-fast-path.js";
import {
activateSecretsRuntimeSnapshotState,
activateSecretsRuntimeSnapshotStateIfCurrent,
clearSecretsRuntimeSnapshot as clearSecretsRuntimeSnapshotState,
getActiveSecretsRuntimeEnv as getActiveSecretsRuntimeEnvState,
getActiveSecretsRuntimeRefreshContext,
getActiveSecretsRuntimeSnapshot as getActiveSecretsRuntimeSnapshotState,
getActiveSecretsRuntimeSnapshotRevision as getActiveSecretsRuntimeSnapshotRevisionState,
getLiveSecretsRuntimeAuthStores,
getPreparedSecretsRuntimeSnapshotRefreshContext,
registerSecretsRuntimeStateClearHook,
restoreSecretsRuntimeSnapshotStateIfCurrent,
setPreparedSecretsRuntimeSnapshotRefreshContext,
type PreparedSecretsRuntimeSnapshot,
type SecretsRuntimeRefreshContext,
@@ -116,6 +125,8 @@ function shouldLoadPluginMetadataForSecrets(config: OpenClawConfig): boolean {
/** Prepares a secrets runtime snapshot and records refresh context for later activation. */
export async function prepareSecretsRuntimeSnapshot(params: {
config: OpenClawConfig;
/** Optional assignment projection; resolver/plugin policy still uses the full config. */
assignmentConfig?: OpenClawConfig;
env?: NodeJS.ProcessEnv;
agentDirs?: string[];
includeAuthStoreRefs?: boolean;
@@ -126,8 +137,10 @@ export async function prepareSecretsRuntimeSnapshot(params: {
loadablePluginOrigins?: ReadonlyMap<string, PluginOrigin>;
}): Promise<PreparedSecretsRuntimeSnapshot> {
const runtimeEnv = mergeSecretsRuntimeEnv(params.env);
const authStoreCredentialsRevision = getRuntimeAuthProfileStoreCredentialsRevision();
const sourceConfig = structuredClone(params.config);
const resolvedConfig = structuredClone(params.config);
const assignmentSourceConfig = structuredClone(params.assignmentConfig ?? params.config);
const resolvedConfig = structuredClone(assignmentSourceConfig);
const includeAuthStoreRefs = params.includeAuthStoreRefs ?? true;
let authStores: Array<{ agentDir: string; store: AuthProfileStore }> = [];
const fastPathLoadAuthStore = params.loadAuthStore ?? loadAuthProfileStoreWithoutExternalProfiles;
@@ -142,13 +155,14 @@ export async function prepareSecretsRuntimeSnapshot(params: {
});
}
}
if (canUseSecretsRuntimeFastPath({ sourceConfig, authStores })) {
if (canUseSecretsRuntimeFastPath({ sourceConfig: assignmentSourceConfig, authStores })) {
const manifestRegistry =
params.manifestRegistry ?? params.pluginMetadataSnapshot?.manifestRegistry;
const snapshot = {
sourceConfig,
config: resolvedConfig,
authStores,
authStoreCredentialsRevision,
warnings: [],
webTools: createEmptyRuntimeWebToolsMetadata(),
};
@@ -236,6 +250,7 @@ export async function prepareSecretsRuntimeSnapshot(params: {
sourceConfig,
config: resolvedConfig,
authStores,
authStoreCredentialsRevision,
warnings: context.warnings,
webTools: await resolveRuntimeWebTools({
sourceConfig,
@@ -256,6 +271,188 @@ export async function prepareSecretsRuntimeSnapshot(params: {
/** Activates a prepared secrets runtime snapshot for fast runtime lookup. */
export function activateSecretsRuntimeSnapshot(snapshot: PreparedSecretsRuntimeSnapshot): void {
activateSecretsRuntimeSnapshotState(createSecretsRuntimeSnapshotActivation(snapshot));
}
/** Compare-and-activate boundary for snapshots prepared from process-wide runtime state. */
export function activateSecretsRuntimeSnapshotIfCurrent(
snapshot: PreparedSecretsRuntimeSnapshot,
expectedRevision: number,
options?: { preserveActivationLineage?: boolean },
): boolean {
return activateSecretsRuntimeSnapshotStateIfCurrent({
...createSecretsRuntimeSnapshotActivation(snapshot),
expectedRevision,
preserveActivationLineage: options?.preserveActivationLineage,
});
}
/** Restores an owned predecessor while retaining changes after candidate preparation. */
export function restoreSecretsRuntimeSnapshotIfCurrent(
snapshot: PreparedSecretsRuntimeSnapshot,
expectedRevision: number,
ownedSnapshot: PreparedSecretsRuntimeSnapshot,
): boolean {
return restoreSecretsRuntimeSnapshotStateIfCurrent({
...createSecretsRuntimeSnapshotActivation(snapshot),
expectedRevision,
ownedSnapshot,
});
}
type PreparedSecretsRuntimeRefresh = {
snapshot: PreparedSecretsRuntimeSnapshot;
expectedRevision: number;
};
function coercePreflightRefresh(
value: unknown,
sourceConfig: OpenClawConfig,
): PreparedSecretsRuntimeRefresh | null {
if (!value || typeof value !== "object") {
return null;
}
const candidate = value as Partial<PreparedSecretsRuntimeRefresh>;
return candidate.snapshot &&
typeof candidate.expectedRevision === "number" &&
isDeepStrictEqual(candidate.snapshot.sourceConfig, sourceConfig)
? (candidate as PreparedSecretsRuntimeRefresh)
: null;
}
async function prepareActiveSecretsRuntimeRefresh(
sourceConfig: OpenClawConfig,
includeAuthStoreRefs?: boolean,
snapshotConfig: OpenClawConfig = sourceConfig,
): Promise<PreparedSecretsRuntimeRefresh | null> {
const expectedRevision = getActiveSecretsRuntimeSnapshotRevisionState();
const activeRefreshContext = getActiveSecretsRuntimeRefreshContext();
const activeSnapshot = getActiveSecretsRuntimeSnapshotState();
if (!activeSnapshot || !activeRefreshContext) {
return null;
}
return {
snapshot: await prepareSecretsRuntimeSnapshot({
config: sourceConfig,
assignmentConfig: snapshotConfig,
env: activeRefreshContext.env,
agentDirs: resolveRefreshAgentDirs(sourceConfig, activeRefreshContext),
includeAuthStoreRefs: includeAuthStoreRefs ?? activeRefreshContext.includeAuthStoreRefs,
loadablePluginOrigins: activeRefreshContext.loadablePluginOrigins,
...(activeRefreshContext.manifestRegistry
? { manifestRegistry: activeRefreshContext.manifestRegistry }
: {}),
...(activeRefreshContext.loadAuthStore
? { loadAuthStore: activeRefreshContext.loadAuthStore }
: {}),
}),
expectedRevision,
};
}
/** Prepares a config-write refresh candidate tied to the current runtime revision. */
export async function preflightActiveSecretsRuntimeSnapshotRefresh(
params: RuntimeConfigSnapshotRefreshParams,
): Promise<unknown> {
return await prepareActiveSecretsRuntimeRefresh(params.sourceConfig, params.includeAuthStoreRefs);
}
/** Publishes a config-write refresh after retrying any candidate invalidated while preparing. */
export async function refreshActiveSecretsRuntimeSnapshotForConfig(
params: RuntimeConfigSnapshotRefreshParams,
): Promise<boolean> {
let candidate = coercePreflightRefresh(params.preflightResult, params.sourceConfig);
for (;;) {
candidate ??= await prepareActiveSecretsRuntimeRefresh(
params.sourceConfig,
params.includeAuthStoreRefs,
);
if (!candidate) {
return false;
}
const activeRefreshContext = getActiveSecretsRuntimeRefreshContext();
if (!activeRefreshContext) {
return false;
}
const oneShotSkipAuthStoreRefs =
params.includeAuthStoreRefs === false && activeRefreshContext.includeAuthStoreRefs;
if (oneShotSkipAuthStoreRefs) {
candidate.snapshot.authStores = getLiveSecretsRuntimeAuthStores();
candidate.snapshot.authStoreCredentialsRevision =
getRuntimeAuthProfileStoreCredentialsRevision();
setPreparedSecretsRuntimeSnapshotRefreshContext(candidate.snapshot, activeRefreshContext);
}
if (activateSecretsRuntimeSnapshotIfCurrent(candidate.snapshot, candidate.expectedRevision)) {
return true;
}
candidate = null;
}
}
type ResolvedSecretRefPatch =
| { changed: false; value: unknown }
| { changed: true; value: unknown };
function patchResolvedSecretRefLeaves(params: {
current: unknown;
source: unknown;
resolved: unknown;
defaults: NonNullable<OpenClawConfig["secrets"]>["defaults"];
}): ResolvedSecretRefPatch {
if (coerceSecretRef(params.source, params.defaults)) {
return isDeepStrictEqual(params.source, params.resolved)
? { changed: false, value: params.current }
: { changed: true, value: params.resolved };
}
if (Array.isArray(params.source) && Array.isArray(params.resolved)) {
const next = Array.isArray(params.current)
? [...params.current]
: structuredClone(params.resolved);
let changed = false;
for (const [index, source] of params.source.entries()) {
const patch = patchResolvedSecretRefLeaves({
current: next[index],
source,
resolved: params.resolved[index],
defaults: params.defaults,
});
if (patch.changed) {
next[index] = patch.value;
changed = true;
}
}
return { changed, value: changed ? next : params.current };
}
if (isRecord(params.source) && isRecord(params.resolved)) {
const next = isRecord(params.current)
? { ...params.current }
: structuredClone(params.resolved);
let changed = false;
for (const [key, source] of Object.entries(params.source)) {
const patch = patchResolvedSecretRefLeaves({
current: next[key],
source,
resolved: params.resolved[key],
defaults: params.defaults,
});
if (patch.changed) {
next[key] = patch.value;
changed = true;
}
}
return { changed, value: changed ? next : params.current };
}
return { changed: false, value: params.current };
}
function selectProviderAuthConfig(config: OpenClawConfig): OpenClawConfig {
return {
...(config.secrets === undefined ? {} : { secrets: config.secrets }),
...(config.models === undefined ? {} : { models: config.models }),
};
}
function createSecretsRuntimeSnapshotActivation(snapshot: PreparedSecretsRuntimeSnapshot) {
const refreshContext =
getPreparedSecretsRuntimeSnapshotRefreshContext(snapshot) ??
getActiveSecretsRuntimeRefreshContext() ??
@@ -266,101 +463,73 @@ export function activateSecretsRuntimeSnapshot(snapshot: PreparedSecretsRuntimeS
loadAuthStore: loadAuthProfileStoreForSecretsRuntime,
loadablePluginOrigins: new Map<string, PluginOrigin>(),
} satisfies SecretsRuntimeRefreshContext);
const coercePreflightSnapshot = (
value: unknown,
sourceConfig: OpenClawConfig,
): PreparedSecretsRuntimeSnapshot | null => {
if (!value || typeof value !== "object") {
return null;
}
const candidate = value as PreparedSecretsRuntimeSnapshot;
return isDeepStrictEqual(candidate.sourceConfig, sourceConfig) ? candidate : null;
};
activateSecretsRuntimeSnapshotState({
return {
snapshot,
refreshContext,
refreshHandler: {
preflight: async ({ sourceConfig, includeAuthStoreRefs }) => {
const activeRefreshContext = getActiveSecretsRuntimeRefreshContext();
const activeSnapshot = getActiveSecretsRuntimeSnapshotState();
if (!activeSnapshot || !activeRefreshContext) {
return false;
}
return await prepareSecretsRuntimeSnapshot({
config: sourceConfig,
env: activeRefreshContext.env,
agentDirs: resolveRefreshAgentDirs(sourceConfig, activeRefreshContext),
includeAuthStoreRefs: includeAuthStoreRefs ?? activeRefreshContext.includeAuthStoreRefs,
loadablePluginOrigins: activeRefreshContext.loadablePluginOrigins,
...(activeRefreshContext.manifestRegistry
? { manifestRegistry: activeRefreshContext.manifestRegistry }
: {}),
...(activeRefreshContext.loadAuthStore
? { loadAuthStore: activeRefreshContext.loadAuthStore }
: {}),
});
},
refresh: async ({ sourceConfig, includeAuthStoreRefs, preflightResult }) => {
const activeRefreshContext = getActiveSecretsRuntimeRefreshContext();
const activeSnapshot = getActiveSecretsRuntimeSnapshotState();
if (!activeSnapshot || !activeRefreshContext) {
return false;
}
const oneShotSkipAuthStoreRefs =
includeAuthStoreRefs === false && activeRefreshContext.includeAuthStoreRefs;
const refreshed =
coercePreflightSnapshot(preflightResult, sourceConfig) ??
(await prepareSecretsRuntimeSnapshot({
config: sourceConfig,
env: activeRefreshContext.env,
agentDirs: resolveRefreshAgentDirs(sourceConfig, activeRefreshContext),
includeAuthStoreRefs: includeAuthStoreRefs ?? activeRefreshContext.includeAuthStoreRefs,
loadablePluginOrigins: activeRefreshContext.loadablePluginOrigins,
...(activeRefreshContext.manifestRegistry
? { manifestRegistry: activeRefreshContext.manifestRegistry }
: {}),
...(activeRefreshContext.loadAuthStore
? { loadAuthStore: activeRefreshContext.loadAuthStore }
: {}),
}));
if (oneShotSkipAuthStoreRefs) {
refreshed.authStores = getLiveSecretsRuntimeAuthStores();
setPreparedSecretsRuntimeSnapshotRefreshContext(refreshed, activeRefreshContext);
}
activateSecretsRuntimeSnapshot(refreshed);
return true;
},
preflight: preflightActiveSecretsRuntimeSnapshotRefresh,
refresh: refreshActiveSecretsRuntimeSnapshotForConfig,
},
});
};
}
export async function refreshActiveSecretsRuntimeSnapshot(): Promise<boolean> {
const activeSnapshot = getActiveSecretsRuntimeSnapshotState();
const activeRefreshContext = getActiveSecretsRuntimeRefreshContext();
if (!activeSnapshot || !activeRefreshContext) {
return false;
/** Refresh provider credentials without republishing transport-owned config. */
export async function refreshActiveProviderAuthRuntimeSnapshot(): Promise<boolean> {
for (;;) {
const activeSnapshot = getActiveSecretsRuntimeSnapshotState();
if (!activeSnapshot) {
return false;
}
const providerAuthConfig = selectProviderAuthConfig(activeSnapshot.sourceConfig);
const candidate = await prepareActiveSecretsRuntimeRefresh(
activeSnapshot.sourceConfig,
undefined,
providerAuthConfig,
);
if (!candidate) {
return false;
}
const runtimeConfig = getRuntimeConfigSnapshot();
if (!runtimeConfig) {
return false;
}
const config = { ...runtimeConfig };
const modelsPatch = patchResolvedSecretRefLeaves({
current: runtimeConfig.models,
source: providerAuthConfig.models,
resolved: candidate.snapshot.config.models,
defaults: activeSnapshot.sourceConfig.secrets?.defaults,
});
if (modelsPatch.changed) {
config.models = modelsPatch.value as OpenClawConfig["models"];
}
const refreshedSnapshot: PreparedSecretsRuntimeSnapshot = {
...activeSnapshot,
config,
authStores: candidate.snapshot.authStores,
authStoreCredentialsRevision: candidate.snapshot.authStoreCredentialsRevision,
};
// The pinned config read and revision claim are synchronous: preserve gateway-owned
// runtime mutations while preventing a concurrently prepared secrets snapshot from winning.
if (
activateSecretsRuntimeSnapshotIfCurrent(refreshedSnapshot, candidate.expectedRevision, {
preserveActivationLineage: true,
})
) {
return true;
}
}
const refreshed = await prepareSecretsRuntimeSnapshot({
config: activeSnapshot.sourceConfig,
env: activeRefreshContext.env,
agentDirs: resolveRefreshAgentDirs(activeSnapshot.sourceConfig, activeRefreshContext),
includeAuthStoreRefs: activeRefreshContext.includeAuthStoreRefs,
loadablePluginOrigins: activeRefreshContext.loadablePluginOrigins,
...(activeRefreshContext.manifestRegistry
? { manifestRegistry: activeRefreshContext.manifestRegistry }
: {}),
...(activeRefreshContext.loadAuthStore
? { loadAuthStore: activeRefreshContext.loadAuthStore }
: {}),
});
activateSecretsRuntimeSnapshot(refreshed);
return true;
}
export function getActiveSecretsRuntimeSnapshot(): PreparedSecretsRuntimeSnapshot | null {
return getActiveSecretsRuntimeSnapshotState();
}
export function getActiveSecretsRuntimeSnapshotRevision(): number {
return getActiveSecretsRuntimeSnapshotRevisionState();
}
export function getActiveSecretsRuntimeEnv(): NodeJS.ProcessEnv {
return getActiveSecretsRuntimeEnvState();
}
@@ -0,0 +1,72 @@
// Agent database permission failures must stay inside the SQLite commit boundary.
import { afterEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
const chmodFailHook = vi.hoisted(() => ({
error: undefined as Error | undefined,
}));
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
const chmodSync: typeof actual.chmodSync = ((target: unknown, mode: unknown) => {
if (chmodFailHook.error) {
throw chmodFailHook.error;
}
return (actual.chmodSync as (...args: unknown[]) => unknown)(target, mode);
}) as typeof actual.chmodSync;
return { ...actual, chmodSync, default: { ...actual, chmodSync } };
});
const {
closeOpenClawAgentDatabasesForTest,
openOpenClawAgentDatabase,
runOpenClawAgentWriteTransaction,
} = await import("./openclaw-agent-db.js");
const { closeOpenClawStateDatabaseForTest } = await import("./openclaw-state-db.js");
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
describe("agent database permission repair", () => {
afterEach(() => {
chmodFailHook.error = undefined;
closeOpenClawAgentDatabasesForTest();
closeOpenClawStateDatabaseForTest();
});
it("rolls back an outer write when pre-commit permission repair fails", () => {
const stateDir = tempDirs.make("openclaw-agent-chmod-");
const options = {
agentId: "worker-1",
env: { OPENCLAW_STATE_DIR: stateDir },
};
const database = openOpenClawAgentDatabase(options);
const before = database.db
.prepare("SELECT updated_at FROM schema_meta WHERE meta_key = 'primary'")
.get() as { updated_at: number };
const permissionError = Object.assign(new Error("EACCES: chmod failed"), {
code: "EACCES",
});
chmodFailHook.error = permissionError;
expect(() =>
runOpenClawAgentWriteTransaction((writeDatabase) => {
writeDatabase.db
.prepare("UPDATE schema_meta SET updated_at = ? WHERE meta_key = 'primary'")
.run(before.updated_at + 1);
}, options),
).toThrow(permissionError);
chmodFailHook.error = undefined;
expect(
database.db.prepare("SELECT updated_at FROM schema_meta WHERE meta_key = 'primary'").get(),
).toEqual(before);
runOpenClawAgentWriteTransaction((writeDatabase) => {
writeDatabase.db
.prepare("UPDATE schema_meta SET updated_at = ? WHERE meta_key = 'primary'")
.run(before.updated_at + 2);
}, options);
expect(
database.db.prepare("SELECT updated_at FROM schema_meta WHERE meta_key = 'primary'").get(),
).toEqual({ updated_at: before.updated_at + 2 });
});
});
+1 -1
View File
@@ -1064,7 +1064,7 @@ describe("openclaw agent database", () => {
});
it.runIf(process.platform !== "win32")(
"defers nested permission repair until the outer transaction commits",
"defers nested permission repair to the outer transaction boundary",
() => {
const stateDir = createTempStateDir();
const options = {
+53 -9
View File
@@ -958,6 +958,21 @@ export function openOpenClawAgentDatabase(
}
/** Run a synchronous immediate transaction against an agent database. */
const postCommitPublications = new WeakMap<OpenClawAgentDatabase, Array<() => void>>();
/** Queue a non-throwing runtime publication on the outer database commit edge. */
export function deferOpenClawAgentPostCommitPublication(
database: OpenClawAgentDatabase,
publish: () => void,
): boolean {
const publications = postCommitPublications.get(database);
if (!publications) {
return false;
}
publications.push(publish);
return true;
}
export function runOpenClawAgentWriteTransaction<T>(
operation: (database: OpenClawAgentDatabase) => T,
options: OpenClawAgentDatabaseOptions,
@@ -968,16 +983,45 @@ export function runOpenClawAgentWriteTransaction<T>(
): T {
const database = openOpenClawAgentDatabase(options);
const enteredNestedTransaction = database.db.isTransaction;
const result = runSqliteImmediateTransactionSync(database.db, () => operation(database), {
busyTimeoutMs: OPENCLAW_SQLITE_BUSY_TIMEOUT_MS,
databaseLabel: database.path,
...transactionOptions,
operationLabel: transactionOptions.operationLabel ?? "agent.write",
});
// The outer owner repairs permissions after COMMIT; nested savepoint callers
// must not add filesystem work while that transaction is still open.
const publications: Array<() => void> | undefined = enteredNestedTransaction
? postCommitPublications.get(database)
: [];
const publicationStart = publications?.length ?? 0;
if (!enteredNestedTransaction && publications) {
postCommitPublications.set(database, publications);
}
let result: T;
try {
result = runSqliteImmediateTransactionSync(
database.db,
() => {
const operationResult = operation(database);
if (!enteredNestedTransaction) {
// Permission failure must roll back with the write. Repairing after
// COMMIT could make callers retry a transaction already durable in SQLite.
ensureOpenClawAgentDatabasePermissions(database.path, options);
}
return operationResult;
},
{
busyTimeoutMs: OPENCLAW_SQLITE_BUSY_TIMEOUT_MS,
databaseLabel: database.path,
...transactionOptions,
operationLabel: transactionOptions.operationLabel ?? "agent.write",
},
);
} catch (error) {
publications?.splice(publicationStart);
throw error;
} finally {
if (!enteredNestedTransaction && publications) {
postCommitPublications.delete(database);
}
}
if (!enteredNestedTransaction) {
ensureOpenClawAgentDatabasePermissions(database.path, options);
for (const publish of publications ?? []) {
publish();
}
}
return result;
}
+5
View File
@@ -3,6 +3,7 @@ import { mkdtempSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { getRuntimeAuthProfileStoreCredentialsRevision } from "../agents/auth-profiles/runtime-snapshots.js";
import {
clearRuntimeAuthProfileStoreSnapshots,
replaceRuntimeAuthProfileStoreSnapshots,
@@ -478,6 +479,7 @@ describe("web search runtime", () => {
sourceConfig,
config: resolvedConfig,
authStores: [],
authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(),
warnings: [],
webTools: {
search: {
@@ -654,6 +656,7 @@ describe("web search runtime", () => {
sourceConfig: {},
config: {},
authStores: [],
authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(),
warnings: [],
webTools: {
search: {
@@ -695,6 +698,7 @@ describe("web search runtime", () => {
sourceConfig: {},
config: {},
authStores: [],
authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(),
warnings: [],
webTools: {
search: {
@@ -739,6 +743,7 @@ describe("web search runtime", () => {
sourceConfig: config,
config: structuredClone(config),
authStores: [],
authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(),
warnings: [],
webTools: {
search: {
+12 -8
View File
@@ -10,18 +10,22 @@ export type RegisterTempDirCleanup = (cleanup: () => void) => unknown;
export interface TestTempDirTracker {
readonly dirs: ReadonlySet<string>;
make(prefix: string): string;
make(prefix: string, root?: string): string;
cleanup(): void;
}
export interface AutoCleanupTempDirTracker {
readonly dirs: ReadonlySet<string>;
make(prefix: string): string;
make(prefix: string, root?: string): string;
}
/** Create a temp dir and register it in an array or set for cleanup. */
export function makeTempDir(tempDirs: TempDirCollection, prefix: string): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
export function makeTempDir(
tempDirs: TempDirCollection,
prefix: string,
root = os.tmpdir(),
): string {
const dir = fs.mkdtempSync(path.join(root, prefix));
if (Array.isArray(tempDirs)) {
tempDirs.push(dir);
} else {
@@ -45,8 +49,8 @@ export function createTempDirTracker(): TestTempDirTracker {
const dirs = new Set<string>();
return {
dirs,
make(prefix: string): string {
return makeTempDir(dirs, prefix);
make(prefix: string, root?: string): string {
return makeTempDir(dirs, prefix, root);
},
cleanup(): void {
cleanupTempDirs(dirs);
@@ -64,8 +68,8 @@ export function useAutoCleanupTempDirTracker(
});
return {
dirs: tracker.dirs,
make(prefix: string): string {
return tracker.make(prefix);
make(prefix: string, root?: string): string {
return tracker.make(prefix, root);
},
};
}