import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { resolveDefaultAgentDir } from "../agents/agent-scope.js"; import { AUTH_PROFILE_FILENAME } from "../agents/auth-profiles/constants.js"; import { testing as controlPlaneRateLimitTesting } from "./control-plane-rate-limit.js"; import { connectOk, installGatewayTestHooks, rpcReq, startServerWithClient, testState, writeSessionStore, } from "./test-helpers.js"; installGatewayTestHooks({ scope: "suite" }); const CONFIG_SECRETREF_RPC_TIMEOUT_MS = 20_000; let startedServer: Awaited> | null = null; let sharedTempRoot: string; function requireWs(): Awaited>["ws"] { if (!startedServer) { throw new Error("gateway test server not started"); } return startedServer.ws; } function requireConfigObject( value: Record | undefined, label: string, ): Record { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error(`expected ${label}`); } return value; } beforeAll(async () => { sharedTempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sessions-config-")); startedServer = await startServerWithClient(undefined, { controlUiEnabled: true }); await connectOk(requireWs()); }); afterAll(async () => { if (!startedServer) { return; } startedServer.ws.close(); await startedServer.server.close(); startedServer = null; await fs.rm(sharedTempRoot, { recursive: true, force: true }); }); async function resetTempDir(name: string): Promise { const dir = path.join(sharedTempRoot, name); await fs.rm(dir, { recursive: true, force: true }); await fs.mkdir(dir, { recursive: true }); return dir; } async function getConfigHash() { const current = await rpcReq<{ hash?: string; }>(requireWs(), "config.get", {}); expect(current.ok).toBe(true); expect(typeof current.payload?.hash).toBe("string"); return String(current.payload?.hash); } async function sendConfigApply(params: { raw: unknown; baseHash?: string }, timeoutMs?: number) { return await rpcReq(requireWs(), "config.apply", params, timeoutMs); } async function expectSchemaLookupInvalid(pathValue: unknown) { const res = await rpcReq<{ ok?: boolean }>(requireWs(), "config.schema.lookup", { pathValue }); expect(res.ok).toBe(false); expect(res.error?.message ?? "").toContain("invalid config.schema.lookup params"); } async function writeUnresolvedAuthProfileTokenRef(missingEnvVar: string) { delete process.env[missingEnvVar]; const authStorePath = path.join(resolveDefaultAgentDir({}), AUTH_PROFILE_FILENAME); await fs.mkdir(path.dirname(authStorePath), { recursive: true }); await fs.writeFile( authStorePath, `${JSON.stringify( { version: 1, profiles: { "custom:token": { type: "token", provider: "custom", tokenRef: { source: "env", provider: "default", id: missingEnvVar }, }, }, }, null, 2, )}\n`, "utf-8", ); } beforeEach(() => { controlPlaneRateLimitTesting.resetControlPlaneRateLimitState(); }); describe("gateway config methods", () => { it("rejects config.set when SecretRef resolution fails", async () => { const missingEnvVar = `OPENCLAW_MISSING_SECRETREF_${Date.now()}`; delete process.env[missingEnvVar]; const current = await rpcReq<{ hash?: string; config?: Record; }>(requireWs(), "config.get", {}); expect(current.ok).toBe(true); expect(typeof current.payload?.hash).toBe("string"); const currentConfig = requireConfigObject(current.payload?.config, "current config"); const nextConfig = structuredClone(currentConfig); const gateway = (nextConfig.gateway ??= {}) as Record; gateway.auth = { mode: "token", token: { source: "env", provider: "default", id: missingEnvVar }, }; const res = await rpcReq<{ ok?: boolean; error?: { message?: string } }>( requireWs(), "config.set", { raw: JSON.stringify(nextConfig, null, 2), baseHash: current.payload?.hash, }, CONFIG_SECRETREF_RPC_TIMEOUT_MS, ); expect(res.ok).toBe(false); expect(res.error?.message ?? "").toContain("active SecretRef resolution failed"); const afterHash = await getConfigHash(); expect(afterHash).toBe(current.payload?.hash); }); it("round-trips config.set and returns the live config path", async () => { const { createConfigIO } = await import("../config/config.js"); const current = await rpcReq<{ raw?: unknown; hash?: string; config?: Record; }>(requireWs(), "config.get", {}); expect(current.ok).toBe(true); expect(typeof current.payload?.hash).toBe("string"); const currentConfig = requireConfigObject(current.payload?.config, "current config"); const res = await rpcReq<{ ok?: boolean; path?: string; config?: Record; }>(requireWs(), "config.set", { raw: JSON.stringify(currentConfig, null, 2), baseHash: current.payload?.hash, }); expect(res.ok).toBe(true); expect(res.payload?.path).toBe(createConfigIO().configPath); requireConfigObject(res.payload?.config, "updated config"); }); it("returns the persisted config from config.set responses", async () => { const current = await rpcReq<{ hash?: string; config?: Record; }>(requireWs(), "config.get", {}); expect(current.ok).toBe(true); expect(typeof current.payload?.hash).toBe("string"); const nextConfig = structuredClone( requireConfigObject(current.payload?.config, "current config"), ); delete nextConfig.meta; const gateway = (nextConfig.gateway ??= {}) as Record; gateway.port = 19001; const res = await rpcReq<{ ok?: boolean; config?: Record; }>(requireWs(), "config.set", { raw: JSON.stringify(nextConfig, null, 2), baseHash: current.payload?.hash, }); expect(res.error).toBeUndefined(); expect(res.ok).toBe(true); const after = await rpcReq<{ config?: Record; }>(requireWs(), "config.get", {}); expect(after.ok).toBe(true); expect(res.payload?.config).toEqual(after.payload?.config); requireConfigObject(res.payload?.config, "response config"); }); it("accepts runtime-shaped config.set when bundled provider baseUrl was only defaulted", async () => { const { createConfigIO, resetConfigRuntimeState } = await import("../config/config.js"); const configPath = createConfigIO().configPath; await fs.mkdir(path.dirname(configPath), { recursive: true }); try { await fs.writeFile( configPath, `${JSON.stringify( { models: { providers: { openai: { agentRuntime: { id: "openclaw" }, }, }, }, }, null, 2, )}\n`, "utf-8", ); resetConfigRuntimeState(); const current = await rpcReq<{ hash?: string; config?: Record; }>(requireWs(), "config.get", {}); expect(current.ok).toBe(true); expect(typeof current.payload?.hash).toBe("string"); const nextConfig = structuredClone( requireConfigObject(current.payload?.config, "current config"), ); const providers = ((nextConfig.models as Record).providers ?? {}) as Record< string, Record >; providers.openai ??= {}; providers.openai.baseUrl = ""; providers.openai.models = []; const gateway = (nextConfig.gateway ??= {}) as Record; gateway.port = 19002; const res = await rpcReq<{ ok?: boolean; error?: { message?: string }; }>(requireWs(), "config.set", { raw: JSON.stringify(nextConfig, null, 2), baseHash: current.payload?.hash, }); expect(res.error).toBeUndefined(); expect(res.ok).toBe(true); const persisted = await fs.readFile(configPath, "utf-8"); expect(persisted).toContain('"port": 19002'); expect(persisted).not.toContain('"baseUrl"'); } finally { await fs.rm(configPath, { force: true }); resetConfigRuntimeState(); } }); it("redacts browser cdpUrl credentials from config.get responses", async () => { const { createConfigIO, resetConfigRuntimeState } = await import("../config/config.js"); const configPath = createConfigIO().configPath; await fs.mkdir(path.dirname(configPath), { recursive: true }); try { await fs.writeFile( configPath, `${JSON.stringify( { browser: { cdpUrl: "https://user:pass@chrome.browserless.io?token=supersecret123", profiles: { remote: { cdpUrl: "https://alice:secret@chrome.remote.example.com?token=profile-secret", }, local: { cdpUrl: "ws://127.0.0.1:9222", }, }, }, }, null, 2, )}\n`, "utf-8", ); resetConfigRuntimeState(); const after = await rpcReq<{ raw?: string | null; config?: { browser?: { cdpUrl?: string; profiles?: Record; }; }; }>(requireWs(), "config.get", {}); expect(after.ok).toBe(true); expect(after.payload?.config?.browser?.cdpUrl).toBe("__OPENCLAW_REDACTED__"); expect(after.payload?.config?.browser?.profiles?.remote?.cdpUrl).toBe( "__OPENCLAW_REDACTED__", ); expect(after.payload?.config?.browser?.profiles?.local?.cdpUrl).toBe("ws://127.0.0.1:9222"); if (typeof after.payload?.raw === "string") { expect(after.payload.raw).toContain("__OPENCLAW_REDACTED__"); expect(after.payload.raw).not.toContain("supersecret123"); expect(after.payload.raw).not.toContain("user:pass@"); expect(after.payload.raw).not.toContain("profile-secret"); expect(after.payload.raw).not.toContain("alice:secret@"); } } finally { await fs.rm(configPath, { force: true }); resetConfigRuntimeState(); } }); it("does not reject config.set for unresolved auth-profile refs outside submitted config", async () => { const missingEnvVar = `OPENCLAW_MISSING_AUTH_PROFILE_REF_${Date.now()}`; await writeUnresolvedAuthProfileTokenRef(missingEnvVar); const current = await rpcReq<{ hash?: string; config?: Record; }>(requireWs(), "config.get", {}); expect(current.ok).toBe(true); expect(typeof current.payload?.hash).toBe("string"); const currentConfig = requireConfigObject(current.payload?.config, "current config"); const res = await rpcReq<{ ok?: boolean; error?: { message?: string } }>( requireWs(), "config.set", { raw: JSON.stringify(currentConfig, null, 2), baseHash: current.payload?.hash, }, ); expect(res.ok).toBe(true); expect(res.error).toBeUndefined(); }); it("returns config.set validation details in the top-level error message", async () => { const res = await rpcReq<{ ok?: boolean; error?: { message?: string; }; }>(requireWs(), "config.set", { raw: JSON.stringify({ gateway: { bind: 123 } }), baseHash: await getConfigHash(), }); const error = res.error as | { message?: string; details?: { issues?: Array<{ path?: string; message?: string }>; }; } | undefined; expect(res.ok).toBe(false); expect(error?.message ?? "").toContain("invalid config:"); expect(error?.message ?? "").toContain("gateway.bind"); expect(error?.message ?? "").toContain("allowed:"); expect(error?.details?.issues?.[0]?.path).toBe("gateway.bind"); }); it("returns a path-scoped config schema lookup", async () => { const res = await rpcReq<{ path: string; hintPath?: string; children?: Array<{ key: string; path: string; required: boolean; hintPath?: string }>; schema?: { properties?: unknown }; }>(requireWs(), "config.schema.lookup", { path: "gateway.auth", }); expect(res.ok).toBe(true); expect(res.payload?.path).toBe("gateway.auth"); expect(res.payload?.hintPath).toBe("gateway.auth"); const tokenChild = res.payload?.children?.find((child) => child.key === "token"); expect(tokenChild?.key).toBe("token"); expect(tokenChild?.path).toBe("gateway.auth.token"); expect(tokenChild?.hintPath).toBe("gateway.auth.token"); expect(res.payload?.schema?.properties).toBeUndefined(); }); it("rejects config.schema.lookup when the path is missing", async () => { const res = await rpcReq<{ ok?: boolean }>(requireWs(), "config.schema.lookup", { path: "gateway.notReal.path", }); expect(res.ok).toBe(false); expect(res.error?.message).toBe("config schema path not found"); }); it.each([ { name: "rejects config.schema.lookup when the path is only whitespace", pathLocal: " " }, { name: "rejects config.schema.lookup when the path exceeds the protocol limit", pathLocal: `gateway.${"a".repeat(1020)}`, }, { name: "rejects config.schema.lookup when the path contains invalid characters", pathLocal: "gateway.auth\nspoof", }, { name: "rejects config.schema.lookup when the path is not a string", pathLocal: 42, }, ])("$name", async ({ pathLocal }) => { await expectSchemaLookupInvalid(pathLocal); }); it("rejects prototype-chain config.schema.lookup paths without reflecting them", async () => { const res = await rpcReq<{ ok?: boolean }>(requireWs(), "config.schema.lookup", { path: "constructor", }); expect(res.ok).toBe(false); expect(res.error?.message).toBe("config schema path not found"); }); it("returns noop for config.patch when config is unchanged", async () => { const current = await rpcReq<{ config?: Record; hash?: string; }>(requireWs(), "config.get", {}); expect(current.ok).toBe(true); // Patch with the same config — no actual changes const res = await rpcReq<{ ok?: boolean; noop?: boolean; config?: Record; }>(requireWs(), "config.patch", { raw: JSON.stringify(current.payload?.config ?? {}), baseHash: current.payload?.hash, }); expect(res.ok).toBe(true); expect(res.payload?.noop).toBe(true); // Config hash should not change (no file write) const after = await rpcReq<{ hash?: string }>(requireWs(), "config.get", {}); expect(after.payload?.hash).toBe(current.payload?.hash); }); it("rejects config.patch when raw is null", async () => { const res = await rpcReq<{ ok?: boolean }>(requireWs(), "config.patch", { raw: "null", baseHash: await getConfigHash(), }); expect(res.ok).toBe(false); expect(res.error?.message ?? "").toContain("raw must be an object"); }); it("rejects config.patch when merged SecretRefs cannot resolve", async () => { const missingEnvVar = `OPENCLAW_MISSING_SECRETREF_PATCH_${Date.now()}`; delete process.env[missingEnvVar]; const beforeHash = await getConfigHash(); const res = await rpcReq<{ ok?: boolean; error?: { message?: string } }>( requireWs(), "config.patch", { raw: JSON.stringify({ gateway: { auth: { mode: "token", token: { source: "env", provider: "default", id: missingEnvVar, }, }, }, }), baseHash: beforeHash, }, CONFIG_SECRETREF_RPC_TIMEOUT_MS, ); expect(res.ok).toBe(false); expect(res.error?.message ?? "").toContain("active SecretRef resolution failed"); const afterHash = await getConfigHash(); expect(afterHash).toBe(beforeHash); }); }); describe("gateway config.apply", () => { it("rejects config.apply when SecretRef resolution fails", async () => { const missingEnvVar = `OPENCLAW_MISSING_SECRETREF_APPLY_${Date.now()}`; delete process.env[missingEnvVar]; const current = await rpcReq<{ hash?: string; raw?: string | null; config?: Record; }>(requireWs(), "config.get", {}); expect(current.ok).toBe(true); expect(typeof current.payload?.hash).toBe("string"); const nextConfig = structuredClone(current.payload?.config ?? {}); const gateway = (nextConfig.gateway ??= {}) as Record; gateway.auth = { mode: "token", token: { source: "env", provider: "default", id: missingEnvVar }, }; const res = await sendConfigApply( { raw: JSON.stringify(nextConfig, null, 2), baseHash: current.payload?.hash, }, CONFIG_SECRETREF_RPC_TIMEOUT_MS, ); expect(res.ok).toBe(false); expect(res.error?.message ?? "").toContain("active SecretRef resolution failed"); const after = await rpcReq<{ hash?: string; raw?: string | null; }>(requireWs(), "config.get", {}); expect(after.ok).toBe(true); expect(after.payload?.hash).toBe(current.payload?.hash); expect(after.payload?.raw).toBe(current.payload?.raw); }); it("does not reject config.apply for unresolved auth-profile refs outside submitted config", async () => { const missingEnvVar = `OPENCLAW_MISSING_AUTH_PROFILE_REF_APPLY_${Date.now()}`; await writeUnresolvedAuthProfileTokenRef(missingEnvVar); const current = await rpcReq<{ config?: Record; hash?: string; }>(requireWs(), "config.get", {}); expect(current.ok).toBe(true); const currentConfig = requireConfigObject(current.payload?.config, "current config"); const res = await sendConfigApply({ raw: JSON.stringify(currentConfig, null, 2), baseHash: current.payload?.hash, }); expect(res.ok).toBe(true); expect(res.error).toBeUndefined(); }); it("rejects invalid raw config", async () => { const currentHash = await getConfigHash(); const res = await sendConfigApply({ raw: "{", baseHash: currentHash }); expect(res.ok).toBe(false); expect(res.error?.message ?? "").toMatch(/invalid|SyntaxError/i); }); it("requires raw to be a string", async () => { const currentHash = await getConfigHash(); const res = await sendConfigApply({ raw: { gateway: { mode: "local" } }, baseHash: currentHash, }); expect(res.ok).toBe(false); expect(res.error?.message ?? "").toContain("raw"); }); }); describe("gateway server sessions", () => { it("filters sessions by agentId", async () => { const dir = await resetTempDir("agents"); testState.sessionConfig = { store: path.join(dir, "{agentId}", "sessions.json"), }; testState.agentsConfig = { list: [{ id: "home", default: true }, { id: "work" }], }; const homeDir = path.join(dir, "home"); const workDir = path.join(dir, "work"); await fs.mkdir(homeDir, { recursive: true }); await fs.mkdir(workDir, { recursive: true }); await writeSessionStore({ storePath: path.join(homeDir, "sessions.json"), agentId: "home", entries: { main: { sessionId: "sess-home-main", updatedAt: Date.now(), }, "discord:group:dev": { sessionId: "sess-home-group", updatedAt: Date.now() - 1000, }, }, }); await writeSessionStore({ storePath: path.join(workDir, "sessions.json"), agentId: "work", entries: { main: { sessionId: "sess-work-main", updatedAt: Date.now(), }, }, }); const homeSessions = await rpcReq<{ sessions: Array<{ key: string }>; }>(requireWs(), "sessions.list", { includeGlobal: false, includeUnknown: false, agentId: "home", }); expect(homeSessions.ok).toBe(true); expect(homeSessions.payload?.sessions.map((s) => s.key).toSorted()).toEqual([ "agent:home:discord:group:dev", "agent:home:main", ]); const workSessions = await rpcReq<{ sessions: Array<{ key: string }>; }>(requireWs(), "sessions.list", { includeGlobal: false, includeUnknown: false, agentId: "work", }); expect(workSessions.ok).toBe(true); expect(workSessions.payload?.sessions.map((s) => s.key)).toEqual(["agent:work:main"]); }); it("resolves and patches main alias to default agent main key", async () => { const dir = await resetTempDir("main-alias"); const storePath = path.join(dir, "sessions.json"); testState.sessionStorePath = storePath; testState.agentsConfig = { list: [{ id: "ops", default: true }] }; testState.sessionConfig = { mainKey: "work" }; await writeSessionStore({ storePath, agentId: "ops", mainKey: "work", entries: { main: { sessionId: "sess-ops-main", updatedAt: Date.now(), }, }, }); const resolved = await rpcReq<{ ok: true; key: string }>(requireWs(), "sessions.resolve", { key: "main", }); expect(resolved.ok).toBe(true); expect(resolved.payload?.key).toBe("agent:ops:work"); const patched = await rpcReq<{ ok: true; key: string }>(requireWs(), "sessions.patch", { key: "main", thinkingLevel: "medium", }); expect(patched.ok).toBe(true); expect(patched.payload?.key).toBe("agent:ops:work"); const stored = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record< string, { thinkingLevel?: string } >; expect(stored["agent:ops:work"]?.thinkingLevel).toBe("medium"); expect(stored.main).toBeUndefined(); }); });