Files
openclaw/src/secrets/runtime.fast-path.test.ts
Peter Steinberger 3616fba951 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>
2026-07-12 18:16:15 -07:00

485 lines
16 KiB
TypeScript

/** Tests secrets runtime fast-path decisions and skip conditions. */
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveDefaultAgentDir } from "../agents/agent-scope-config.js";
import type { AuthProfileStore } from "../agents/auth-profiles.js";
import { saveAuthProfileStore } from "../agents/auth-profiles/store.js";
import { clearConfigCache, clearRuntimeConfigSnapshot } from "../config/config.js";
import { resolveOAuthPath } from "../config/paths.js";
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js";
import { clearSecretsRuntimeSnapshot } from "./runtime.js";
import { asConfig } from "./runtime.test-support.js";
const { resolveRuntimeWebToolsMock, runtimePrepareImportMock } = vi.hoisted(() => ({
resolveRuntimeWebToolsMock: vi.fn(async () => ({
search: { providerSource: "none", diagnostics: [] },
fetch: { providerSource: "none", diagnostics: [] },
diagnostics: [],
})),
runtimePrepareImportMock: vi.fn(),
}));
vi.mock("./runtime-prepare.runtime.js", () => {
runtimePrepareImportMock();
return {
createResolverContext: ({ sourceConfig, env }: { sourceConfig: unknown; env: unknown }) => ({
sourceConfig,
env,
cache: {},
warnings: [],
warningKeys: new Set<string>(),
assignments: [],
}),
collectConfigAssignments: () => undefined,
collectAuthStoreAssignments: () => undefined,
resolveSecretRefValues: async () => new Map(),
applyResolvedAssignments: () => undefined,
resolveRuntimeWebTools: resolveRuntimeWebToolsMock,
};
});
function emptyAuthStore(): AuthProfileStore {
return { version: 1, profiles: {} };
}
function requireGatewayAuth(
snapshot: Awaited<ReturnType<typeof import("./runtime.js").prepareSecretsRuntimeSnapshot>>,
) {
const auth = snapshot.config.gateway?.auth;
if (!auth) {
throw new Error("expected gateway auth config");
}
return auth;
}
function writeAuthProfileStore(agentDir: string): void {
mkdirSync(agentDir, { recursive: true });
saveAuthProfileStore(
{
version: 1,
profiles: {
"openai:default": {
type: "api_key",
provider: "openai",
key: "sk-test",
},
},
},
agentDir,
{ filterExternalAuthProfiles: false, syncExternalCli: false },
);
}
describe("secrets runtime fast path", () => {
afterEach(() => {
runtimePrepareImportMock.mockClear();
resolveRuntimeWebToolsMock.mockClear();
setActivePluginRegistry(createEmptyPluginRegistry());
clearSecretsRuntimeSnapshot();
clearRuntimeConfigSnapshot();
clearConfigCache();
closeOpenClawAgentDatabasesForTest();
vi.resetModules();
});
it("skips heavy resolver loading when config and auth stores have no SecretRefs", async () => {
const { prepareSecretsRuntimeSnapshot } = await import("./runtime.js");
const snapshot = await prepareSecretsRuntimeSnapshot({
config: asConfig({
gateway: {
auth: {
mode: "token",
token: "plain-startup-token",
},
},
}),
env: {},
agentDirs: ["/tmp/openclaw-agent-main"],
loadAuthStore: emptyAuthStore,
});
expect(runtimePrepareImportMock).not.toHaveBeenCalled();
expect(requireGatewayAuth(snapshot).token).toBe("plain-startup-token");
expect(snapshot.authStores).toEqual([
{
agentDir: "/tmp/openclaw-agent-main",
store: emptyAuthStore(),
},
]);
});
it("uses the fast path when web fetch only configures runtime limits", async () => {
const { prepareSecretsRuntimeSnapshot } = await import("./runtime.js");
const snapshot = await prepareSecretsRuntimeSnapshot({
config: asConfig({
tools: {
web: {
fetch: {
enabled: true,
maxChars: 200_000,
maxCharsCap: 2_000_000,
},
},
},
plugins: {
enabled: true,
allow: [],
entries: {},
},
}),
env: {},
agentDirs: ["/tmp/openclaw-agent-main"],
loadAuthStore: emptyAuthStore,
});
expect(runtimePrepareImportMock).not.toHaveBeenCalled();
expect(snapshot.webTools.fetch.providerSource).toBe("none");
});
it("uses the fast path when web fetch is explicitly disabled", async () => {
const { prepareSecretsRuntimeSnapshot } = await import("./runtime.js");
await prepareSecretsRuntimeSnapshot({
config: asConfig({
tools: {
web: {
fetch: {
enabled: false,
maxChars: 200_000,
},
},
},
}),
env: {},
agentDirs: ["/tmp/openclaw-agent-main"],
loadAuthStore: emptyAuthStore,
});
expect(runtimePrepareImportMock).not.toHaveBeenCalled();
});
it("uses the resolver path when an auth profile store contains a SecretRef", async () => {
const { prepareSecretsRuntimeSnapshot } = await import("./runtime.js");
await prepareSecretsRuntimeSnapshot({
config: asConfig({}),
env: {},
agentDirs: ["/tmp/openclaw-agent-main"],
loadAuthStore: () => ({
version: 1,
profiles: {
"openai:default": {
type: "api_key",
provider: "openai",
keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
},
},
}),
});
expect(resolveRuntimeWebToolsMock).toHaveBeenCalledTimes(1);
});
it("keeps explicit web fetch provider config on the resolver path", async () => {
const { prepareSecretsRuntimeSnapshot } = await import("./runtime.js");
await prepareSecretsRuntimeSnapshot({
config: asConfig({
tools: {
web: {
fetch: {
provider: "firecrawl",
},
},
},
}),
env: {},
agentDirs: ["/tmp/openclaw-agent-main"],
loadAuthStore: emptyAuthStore,
});
expect(resolveRuntimeWebToolsMock).toHaveBeenCalledTimes(1);
});
it.each([
{
name: "oauth credentials file",
setup: (env: NodeJS.ProcessEnv, _mainAgentDir: string, _agentDir: string) => {
const credentialsPath = resolveOAuthPath(env);
mkdirSync(path.dirname(credentialsPath), { recursive: true });
writeFileSync(
credentialsPath,
`${JSON.stringify({
openai: {
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
},
})}\n`,
);
},
},
{
name: "inherited main auth store",
setup: (_env: NodeJS.ProcessEnv, mainAgentDir: string, _agentDir: string) => {
writeAuthProfileStore(mainAgentDir);
},
},
])("skips the startup-only fast path when $name exists", async ({ setup }) => {
const { prepareSecretsRuntimeFastPathSnapshot } = await import("./runtime-fast-path.js");
const root = mkdtempSync(path.join(tmpdir(), "openclaw-runtime-fast-path-"));
const env: NodeJS.ProcessEnv = {
HOME: root,
OPENCLAW_STATE_DIR: root,
};
const mainAgentDir = resolveDefaultAgentDir({}, env);
const agentDir = path.join(root, "custom-agent");
mkdirSync(agentDir, { recursive: true });
setup(env, mainAgentDir, agentDir);
try {
const snapshot = prepareSecretsRuntimeFastPathSnapshot({
config: asConfig({
agents: {
list: [{ id: "default", agentDir }],
},
}),
env,
});
expect(snapshot).toBeNull();
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it("refreshes startup-only fast-path snapshots from persisted auth stores after startup", async () => {
const { prepareSecretsRuntimeFastPathSnapshot } = await import("./runtime-fast-path.js");
const { activateSecretsRuntimeSnapshotState, getActiveSecretsRuntimeSnapshot } =
await import("./runtime-state.js");
const { refreshActiveProviderAuthRuntimeSnapshot } = await import("./runtime.js");
const root = mkdtempSync(path.join(tmpdir(), "openclaw-runtime-fast-path-refresh-"));
const env: NodeJS.ProcessEnv = {
HOME: root,
OPENCLAW_STATE_DIR: root,
};
const agentDir = path.join(root, "custom-agent");
mkdirSync(agentDir, { recursive: true });
try {
const fastPath = prepareSecretsRuntimeFastPathSnapshot({
config: asConfig({
agents: {
list: [{ id: "default", agentDir }],
},
}),
env,
});
expect(fastPath).not.toBeNull();
activateSecretsRuntimeSnapshotState({
snapshot: fastPath!.snapshot,
refreshContext: fastPath!.refreshContext,
refreshHandler: null,
});
writeAuthProfileStore(agentDir);
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({
type: "api_key",
provider: "openai",
key: "sk-test",
});
} finally {
rmSync(root, { recursive: true, force: true });
}
});
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");
const { prepareSecretsRuntimeFastPathSnapshot } = await import("./runtime-fast-path.js");
const { activateSecretsRuntimeSnapshotState } = await import("./runtime-state.js");
const root = mkdtempSync(path.join(tmpdir(), "openclaw-runtime-fast-path-empty-store-"));
const env: NodeJS.ProcessEnv = {
HOME: root,
OPENCLAW_STATE_DIR: root,
};
const agentDir = path.join(root, "custom-agent");
mkdirSync(agentDir, { recursive: true });
try {
const fastPath = prepareSecretsRuntimeFastPathSnapshot({
config: asConfig({
agents: {
list: [{ id: "default", agentDir }],
},
}),
env,
});
expect(fastPath).not.toBeNull();
expect(fastPath!.snapshot.authStores).toEqual([{ agentDir, store: emptyAuthStore() }]);
activateSecretsRuntimeSnapshotState({
snapshot: fastPath!.snapshot,
refreshContext: fastPath!.refreshContext,
refreshHandler: null,
});
writeAuthProfileStore(agentDir);
expect(
ensureAuthProfileStoreWithoutExternalProfiles(agentDir).profiles["openai:default"],
).toBeUndefined();
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});