diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ffadd966..46cfb1f8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ Docs: https://docs.openclaw.ai - Web UI: apply button styling to the new-messages indicator. - Onboarding: infer auth choice from non-interactive API key flags. (#8484) Thanks @f-trycua. - Security: keep untrusted channel metadata out of system prompts (Slack/Discord). Thanks @KonstantinMirin. +- Security: redact channel credentials (tokens, passwords, API keys, secrets) from gateway config APIs and preserve secrets during Control UI round-trips. (#9858) Thanks @abdelsfane. - Discord: treat allowlisted senders as owner for system-prompt identity hints while keeping channel topics untrusted. - Slack: strip `<@...>` mention tokens before command matching so `/new` and `/reset` work when prefixed with a mention. (#9971) Thanks @ironbyte-rgb. - Security: enforce sandboxed media paths for message tool attachments. (#9182) Thanks @victormier. diff --git a/src/config/redact-snapshot.test.ts b/src/config/redact-snapshot.test.ts new file mode 100644 index 000000000..1bdc968a4 --- /dev/null +++ b/src/config/redact-snapshot.test.ts @@ -0,0 +1,335 @@ +import { describe, expect, it } from "vitest"; +import type { ConfigFileSnapshot } from "./types.openclaw.js"; +import { + REDACTED_SENTINEL, + redactConfigSnapshot, + restoreRedactedValues, +} from "./redact-snapshot.js"; + +function makeSnapshot(config: Record, raw?: string): ConfigFileSnapshot { + return { + path: "/home/user/.openclaw/config.json5", + exists: true, + raw: raw ?? JSON.stringify(config), + parsed: config, + valid: true, + config: config as ConfigFileSnapshot["config"], + hash: "abc123", + issues: [], + warnings: [], + legacyIssues: [], + }; +} + +describe("redactConfigSnapshot", () => { + it("redacts top-level token fields", () => { + const snapshot = makeSnapshot({ + gateway: { auth: { token: "my-super-secret-gateway-token-value" } }, + }); + const result = redactConfigSnapshot(snapshot); + expect(result.config).toEqual({ + gateway: { auth: { token: REDACTED_SENTINEL } }, + }); + }); + + it("redacts botToken in channel configs", () => { + const snapshot = makeSnapshot({ + channels: { + telegram: { botToken: "123456:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef" }, + slack: { botToken: "fake-slack-bot-token-placeholder-value" }, + }, + }); + const result = redactConfigSnapshot(snapshot); + const channels = result.config.channels as Record>; + expect(channels.telegram.botToken).toBe(REDACTED_SENTINEL); + expect(channels.slack.botToken).toBe(REDACTED_SENTINEL); + }); + + it("redacts apiKey in model providers", () => { + const snapshot = makeSnapshot({ + models: { + providers: { + openai: { apiKey: "sk-proj-abcdef1234567890ghij", baseUrl: "https://api.openai.com" }, + }, + }, + }); + const result = redactConfigSnapshot(snapshot); + const models = result.config.models as Record>>; + expect(models.providers.openai.apiKey).toBe(REDACTED_SENTINEL); + expect(models.providers.openai.baseUrl).toBe("https://api.openai.com"); + }); + + it("redacts password fields", () => { + const snapshot = makeSnapshot({ + gateway: { auth: { password: "super-secret-password-value-here" } }, + }); + const result = redactConfigSnapshot(snapshot); + const gw = result.config.gateway as Record>; + expect(gw.auth.password).toBe(REDACTED_SENTINEL); + }); + + it("redacts appSecret fields", () => { + const snapshot = makeSnapshot({ + channels: { + feishu: { appSecret: "feishu-app-secret-value-here-1234" }, + }, + }); + const result = redactConfigSnapshot(snapshot); + const channels = result.config.channels as Record>; + expect(channels.feishu.appSecret).toBe(REDACTED_SENTINEL); + }); + + it("redacts signingSecret fields", () => { + const snapshot = makeSnapshot({ + channels: { + slack: { signingSecret: "slack-signing-secret-value-1234" }, + }, + }); + const result = redactConfigSnapshot(snapshot); + const channels = result.config.channels as Record>; + expect(channels.slack.signingSecret).toBe(REDACTED_SENTINEL); + }); + + it("redacts short secrets with same sentinel", () => { + const snapshot = makeSnapshot({ + gateway: { auth: { token: "short" } }, + }); + const result = redactConfigSnapshot(snapshot); + const gw = result.config.gateway as Record>; + expect(gw.auth.token).toBe(REDACTED_SENTINEL); + }); + + it("preserves non-sensitive fields", () => { + const snapshot = makeSnapshot({ + ui: { seamColor: "#0088cc" }, + gateway: { port: 18789 }, + models: { providers: { openai: { baseUrl: "https://api.openai.com" } } }, + }); + const result = redactConfigSnapshot(snapshot); + expect(result.config).toEqual(snapshot.config); + }); + + it("preserves hash unchanged", () => { + const snapshot = makeSnapshot({ gateway: { auth: { token: "secret-token-value-here" } } }); + const result = redactConfigSnapshot(snapshot); + expect(result.hash).toBe("abc123"); + }); + + it("redacts secrets in raw field via text-based redaction", () => { + const config = { token: "abcdef1234567890ghij" }; + const raw = '{ "token": "abcdef1234567890ghij" }'; + const snapshot = makeSnapshot(config, raw); + const result = redactConfigSnapshot(snapshot); + expect(result.raw).not.toContain("abcdef1234567890ghij"); + expect(result.raw).toContain(REDACTED_SENTINEL); + }); + + it("redacts parsed object as well", () => { + const config = { + channels: { discord: { token: "MTIzNDU2Nzg5MDEyMzQ1Njc4.GaBcDe.FgH" } }, + }; + const snapshot = makeSnapshot(config); + const result = redactConfigSnapshot(snapshot); + const parsed = result.parsed as Record>>; + expect(parsed.channels.discord.token).toBe(REDACTED_SENTINEL); + }); + + it("handles null raw gracefully", () => { + const snapshot: ConfigFileSnapshot = { + path: "/test", + exists: false, + raw: null, + parsed: null, + valid: false, + config: {} as ConfigFileSnapshot["config"], + issues: [], + warnings: [], + legacyIssues: [], + }; + const result = redactConfigSnapshot(snapshot); + expect(result.raw).toBeNull(); + expect(result.parsed).toBeNull(); + }); + + it("handles deeply nested tokens in accounts", () => { + const snapshot = makeSnapshot({ + channels: { + slack: { + accounts: { + workspace1: { botToken: "fake-workspace1-token-abcdefghij" }, + workspace2: { appToken: "fake-workspace2-token-abcdefghij" }, + }, + }, + }, + }); + const result = redactConfigSnapshot(snapshot); + const channels = result.config.channels as Record< + string, + Record>> + >; + expect(channels.slack.accounts.workspace1.botToken).toBe(REDACTED_SENTINEL); + expect(channels.slack.accounts.workspace2.appToken).toBe(REDACTED_SENTINEL); + }); + + it("handles webhookSecret field", () => { + const snapshot = makeSnapshot({ + channels: { + telegram: { webhookSecret: "telegram-webhook-secret-value-1234" }, + }, + }); + const result = redactConfigSnapshot(snapshot); + const channels = result.config.channels as Record>; + expect(channels.telegram.webhookSecret).toBe(REDACTED_SENTINEL); + }); + + it("redacts env vars that look like secrets", () => { + const snapshot = makeSnapshot({ + env: { + vars: { + OPENAI_API_KEY: "sk-proj-1234567890abcdefghij", + NODE_ENV: "production", + }, + }, + }); + const result = redactConfigSnapshot(snapshot); + const env = result.config.env as Record>; + expect(env.vars.OPENAI_API_KEY).toBe(REDACTED_SENTINEL); + // NODE_ENV is not sensitive, should be preserved + expect(env.vars.NODE_ENV).toBe("production"); + }); + + it("redacts raw by key pattern even when parsed config is empty", () => { + const snapshot: ConfigFileSnapshot = { + path: "/test", + exists: true, + raw: '{ token: "raw-secret-1234567890" }', + parsed: {}, + valid: false, + config: {} as ConfigFileSnapshot["config"], + issues: [], + warnings: [], + legacyIssues: [], + }; + const result = redactConfigSnapshot(snapshot); + expect(result.raw).not.toContain("raw-secret-1234567890"); + expect(result.raw).toContain(REDACTED_SENTINEL); + }); + + it("redacts sensitive fields even when the value is not a string", () => { + const snapshot = makeSnapshot({ + gateway: { auth: { token: 1234 } }, + }); + const result = redactConfigSnapshot(snapshot); + const gw = result.config.gateway as Record>; + expect(gw.auth.token).toBe(REDACTED_SENTINEL); + }); +}); + +describe("restoreRedactedValues", () => { + it("restores sentinel values from original config", () => { + const incoming = { + gateway: { auth: { token: REDACTED_SENTINEL } }, + }; + const original = { + gateway: { auth: { token: "real-secret-token-value" } }, + }; + const result = restoreRedactedValues(incoming, original) as typeof incoming; + expect(result.gateway.auth.token).toBe("real-secret-token-value"); + }); + + it("preserves explicitly changed sensitive values", () => { + const incoming = { + gateway: { auth: { token: "new-token-value-from-user" } }, + }; + const original = { + gateway: { auth: { token: "old-token-value" } }, + }; + const result = restoreRedactedValues(incoming, original) as typeof incoming; + expect(result.gateway.auth.token).toBe("new-token-value-from-user"); + }); + + it("preserves non-sensitive fields unchanged", () => { + const incoming = { + ui: { seamColor: "#ff0000" }, + gateway: { port: 9999, auth: { token: REDACTED_SENTINEL } }, + }; + const original = { + ui: { seamColor: "#0088cc" }, + gateway: { port: 18789, auth: { token: "real-secret" } }, + }; + const result = restoreRedactedValues(incoming, original) as typeof incoming; + expect(result.ui.seamColor).toBe("#ff0000"); + expect(result.gateway.port).toBe(9999); + expect(result.gateway.auth.token).toBe("real-secret"); + }); + + it("handles deeply nested sentinel restoration", () => { + const incoming = { + channels: { + slack: { + accounts: { + ws1: { botToken: REDACTED_SENTINEL }, + ws2: { botToken: "user-typed-new-token-value" }, + }, + }, + }, + }; + const original = { + channels: { + slack: { + accounts: { + ws1: { botToken: "original-ws1-token-value" }, + ws2: { botToken: "original-ws2-token-value" }, + }, + }, + }, + }; + const result = restoreRedactedValues(incoming, original) as typeof incoming; + expect(result.channels.slack.accounts.ws1.botToken).toBe("original-ws1-token-value"); + expect(result.channels.slack.accounts.ws2.botToken).toBe("user-typed-new-token-value"); + }); + + it("handles missing original gracefully", () => { + const incoming = { + channels: { newChannel: { token: REDACTED_SENTINEL } }, + }; + const original = {}; + expect(() => restoreRedactedValues(incoming, original)).toThrow(/redacted/i); + }); + + it("handles null and undefined inputs", () => { + expect(restoreRedactedValues(null, { token: "x" })).toBeNull(); + expect(restoreRedactedValues(undefined, { token: "x" })).toBeUndefined(); + }); + + it("round-trips config through redact → restore", () => { + const originalConfig = { + gateway: { auth: { token: "gateway-auth-secret-token-value" }, port: 18789 }, + channels: { + slack: { botToken: "fake-slack-token-placeholder-value" }, + telegram: { + botToken: "fake-telegram-token-placeholder-value", + webhookSecret: "fake-tg-secret-placeholder-value", + }, + }, + models: { + providers: { + openai: { + apiKey: "sk-proj-fake-openai-api-key-value", + baseUrl: "https://api.openai.com", + }, + }, + }, + ui: { seamColor: "#0088cc" }, + }; + const snapshot = makeSnapshot(originalConfig); + + // Redact (simulates config.get response) + const redacted = redactConfigSnapshot(snapshot); + + // Restore (simulates config.set before write) + const restored = restoreRedactedValues(redacted.config, snapshot.config); + + expect(restored).toEqual(originalConfig); + }); +}); diff --git a/src/config/redact-snapshot.ts b/src/config/redact-snapshot.ts new file mode 100644 index 000000000..2bbff9c59 --- /dev/null +++ b/src/config/redact-snapshot.ts @@ -0,0 +1,168 @@ +import type { ConfigFileSnapshot } from "./types.openclaw.js"; + +/** + * Sentinel value used to replace sensitive config fields in gateway responses. + * Write-side handlers (config.set, config.apply, config.patch) detect this + * sentinel and restore the original value from the on-disk config, so a + * round-trip through the Web UI does not corrupt credentials. + */ +export const REDACTED_SENTINEL = "__OPENCLAW_REDACTED__"; + +/** + * Patterns that identify sensitive config field names. + * Aligned with the UI-hint logic in schema.ts. + */ +const SENSITIVE_KEY_PATTERNS = [/token/i, /password/i, /secret/i, /api.?key/i]; + +function isSensitiveKey(key: string): boolean { + return SENSITIVE_KEY_PATTERNS.some((pattern) => pattern.test(key)); +} + +/** + * Deep-walk an object and replace values whose key matches a sensitive pattern + * with the redaction sentinel. + */ +function redactObject(obj: unknown): unknown { + if (obj === null || obj === undefined) { + return obj; + } + if (typeof obj !== "object") { + return obj; + } + if (Array.isArray(obj)) { + return obj.map(redactObject); + } + const result: Record = {}; + for (const [key, value] of Object.entries(obj as Record)) { + if (isSensitiveKey(key) && value !== null && value !== undefined) { + result[key] = REDACTED_SENTINEL; + } else if (typeof value === "object" && value !== null) { + result[key] = redactObject(value); + } else { + result[key] = value; + } + } + return result; +} + +export function redactConfigObject(value: T): T { + return redactObject(value) as T; +} + +/** + * Collect all sensitive string values from a config object. + * Used for text-based redaction of the raw JSON5 source. + */ +function collectSensitiveValues(obj: unknown): string[] { + const values: string[] = []; + if (obj === null || obj === undefined || typeof obj !== "object") { + return values; + } + if (Array.isArray(obj)) { + for (const item of obj) { + values.push(...collectSensitiveValues(item)); + } + return values; + } + for (const [key, value] of Object.entries(obj as Record)) { + if (isSensitiveKey(key) && typeof value === "string" && value.length > 0) { + values.push(value); + } else if (typeof value === "object" && value !== null) { + values.push(...collectSensitiveValues(value)); + } + } + return values; +} + +/** + * Replace known sensitive values in a raw JSON5 string with the sentinel. + * Values are replaced longest-first to avoid partial matches. + */ +function redactRawText(raw: string, config: unknown): string { + const sensitiveValues = collectSensitiveValues(config); + sensitiveValues.sort((a, b) => b.length - a.length); + let result = raw; + for (const value of sensitiveValues) { + const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + result = result.replace(new RegExp(escaped, "g"), REDACTED_SENTINEL); + } + + const keyValuePattern = + /(^|[{\s,])((["'])([^"']+)\3|([A-Za-z0-9_$.-]+))(\s*:\s*)(["'])([^"']*)\7/g; + result = result.replace( + keyValuePattern, + (match, prefix, keyExpr, _keyQuote, keyQuoted, keyBare, sep, valQuote, val) => { + const key = (keyQuoted ?? keyBare) as string | undefined; + if (!key || !isSensitiveKey(key)) { + return match; + } + if (val === REDACTED_SENTINEL) { + return match; + } + return `${prefix}${keyExpr}${sep}${valQuote}${REDACTED_SENTINEL}${valQuote}`; + }, + ); + + return result; +} + +/** + * Returns a copy of the config snapshot with all sensitive fields + * replaced by {@link REDACTED_SENTINEL}. The `hash` is preserved + * (it tracks config identity, not content). + * + * Both `config` (the parsed object) and `raw` (the JSON5 source) are scrubbed + * so no credential can leak through either path. + */ +export function redactConfigSnapshot(snapshot: ConfigFileSnapshot): ConfigFileSnapshot { + const redactedConfig = redactConfigObject(snapshot.config); + const redactedRaw = snapshot.raw ? redactRawText(snapshot.raw, snapshot.config) : null; + const redactedParsed = snapshot.parsed ? redactConfigObject(snapshot.parsed) : snapshot.parsed; + + return { + ...snapshot, + config: redactedConfig, + raw: redactedRaw, + parsed: redactedParsed, + }; +} + +/** + * Deep-walk `incoming` and replace any {@link REDACTED_SENTINEL} values + * (on sensitive keys) with the corresponding value from `original`. + * + * This is called by config.set / config.apply / config.patch before writing, + * so that credentials survive a Web UI round-trip unmodified. + */ +export function restoreRedactedValues(incoming: unknown, original: unknown): unknown { + if (incoming === null || incoming === undefined) { + return incoming; + } + if (typeof incoming !== "object") { + return incoming; + } + if (Array.isArray(incoming)) { + const origArr = Array.isArray(original) ? original : []; + return incoming.map((item, i) => restoreRedactedValues(item, origArr[i])); + } + const orig = + original && typeof original === "object" && !Array.isArray(original) + ? (original as Record) + : {}; + const result: Record = {}; + for (const [key, value] of Object.entries(incoming as Record)) { + if (isSensitiveKey(key) && value === REDACTED_SENTINEL) { + if (!(key in orig)) { + throw new Error( + `config write rejected: "${key}" is redacted; set an explicit value instead of ${REDACTED_SENTINEL}`, + ); + } + result[key] = orig[key]; + } else if (typeof value === "object" && value !== null) { + result[key] = restoreRedactedValues(value, orig[key]); + } else { + result[key] = value; + } + } + return result; +} diff --git a/src/gateway/server-methods/config.ts b/src/gateway/server-methods/config.ts index 0ac9bf9ee..05a534454 100644 --- a/src/gateway/server-methods/config.ts +++ b/src/gateway/server-methods/config.ts @@ -12,6 +12,11 @@ import { } from "../../config/config.js"; import { applyLegacyMigrations } from "../../config/legacy.js"; import { applyMergePatch } from "../../config/merge-patch.js"; +import { + redactConfigObject, + redactConfigSnapshot, + restoreRedactedValues, +} from "../../config/redact-snapshot.js"; import { buildConfigSchema } from "../../config/schema.js"; import { formatDoctorNonInteractiveHint, @@ -100,7 +105,7 @@ export const configHandlers: GatewayRequestHandlers = { return; } const snapshot = await readConfigFileSnapshot(); - respond(true, snapshot, undefined); + respond(true, redactConfigSnapshot(snapshot), undefined); }, "config.schema": ({ params, respond }) => { if (!validateConfigSchemaParams(params)) { @@ -185,13 +190,27 @@ export const configHandlers: GatewayRequestHandlers = { ); return; } - await writeConfigFile(validated.config); + let restored: typeof validated.config; + try { + restored = restoreRedactedValues( + validated.config, + snapshot.config, + ) as typeof validated.config; + } catch (err) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, String(err instanceof Error ? err.message : err)), + ); + return; + } + await writeConfigFile(restored); respond( true, { ok: true, path: CONFIG_PATH, - config: validated.config, + config: redactConfigObject(restored), }, undefined, ); @@ -250,8 +269,19 @@ export const configHandlers: GatewayRequestHandlers = { return; } const merged = applyMergePatch(snapshot.config, parsedRes.parsed); - const migrated = applyLegacyMigrations(merged); - const resolved = migrated.next ?? merged; + let restoredMerge: unknown; + try { + restoredMerge = restoreRedactedValues(merged, snapshot.config); + } catch (err) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, String(err instanceof Error ? err.message : err)), + ); + return; + } + const migrated = applyLegacyMigrations(restoredMerge); + const resolved = migrated.next ?? restoredMerge; const validated = validateConfigObjectWithPlugins(resolved); if (!validated.ok) { respond( @@ -306,7 +336,7 @@ export const configHandlers: GatewayRequestHandlers = { { ok: true, path: CONFIG_PATH, - config: validated.config, + config: redactConfigObject(validated.config), restart, sentinel: { path: sentinelPath, @@ -360,7 +390,21 @@ export const configHandlers: GatewayRequestHandlers = { ); return; } - await writeConfigFile(validated.config); + let restoredApply: typeof validated.config; + try { + restoredApply = restoreRedactedValues( + validated.config, + snapshot.config, + ) as typeof validated.config; + } catch (err) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, String(err instanceof Error ? err.message : err)), + ); + return; + } + await writeConfigFile(restoredApply); const sessionKey = typeof (params as { sessionKey?: unknown }).sessionKey === "string" @@ -403,7 +447,7 @@ export const configHandlers: GatewayRequestHandlers = { { ok: true, path: CONFIG_PATH, - config: validated.config, + config: redactConfigObject(restoredApply), restart, sentinel: { path: sentinelPath, diff --git a/src/gateway/server.config-patch.e2e.test.ts b/src/gateway/server.config-patch.e2e.test.ts index 0ce19ebe3..194112abb 100644 --- a/src/gateway/server.config-patch.e2e.test.ts +++ b/src/gateway/server.config-patch.e2e.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { resolveConfigSnapshotHash } from "../config/config.js"; +import { CONFIG_PATH, resolveConfigSnapshotHash } from "../config/config.js"; import { connectOk, installGatewayTestHooks, @@ -115,7 +115,82 @@ describe("gateway config.patch", () => { }>(ws, (o) => o.type === "res" && o.id === get2Id); expect(get2Res.ok).toBe(true); expect(get2Res.payload?.config?.gateway?.mode).toBe("local"); - expect(get2Res.payload?.config?.channels?.telegram?.botToken).toBe("token-1"); + expect(get2Res.payload?.config?.channels?.telegram?.botToken).toBe("__OPENCLAW_REDACTED__"); + + const storedRaw = await fs.readFile(CONFIG_PATH, "utf-8"); + const stored = JSON.parse(storedRaw) as { + channels?: { telegram?: { botToken?: string } }; + }; + expect(stored.channels?.telegram?.botToken).toBe("token-1"); + }); + + it("preserves credentials on config.set when raw contains redacted sentinels", async () => { + const setId = "req-set-sentinel-1"; + ws.send( + JSON.stringify({ + type: "req", + id: setId, + method: "config.set", + params: { + raw: JSON.stringify({ + gateway: { mode: "local" }, + channels: { telegram: { botToken: "token-1" } }, + }), + }, + }), + ); + const setRes = await onceMessage<{ ok: boolean }>( + ws, + (o) => o.type === "res" && o.id === setId, + ); + expect(setRes.ok).toBe(true); + + const getId = "req-get-sentinel-1"; + ws.send( + JSON.stringify({ + type: "req", + id: getId, + method: "config.get", + params: {}, + }), + ); + const getRes = await onceMessage<{ ok: boolean; payload?: { hash?: string; raw?: string } }>( + ws, + (o) => o.type === "res" && o.id === getId, + ); + expect(getRes.ok).toBe(true); + const baseHash = resolveConfigSnapshotHash({ + hash: getRes.payload?.hash, + raw: getRes.payload?.raw, + }); + expect(typeof baseHash).toBe("string"); + const rawRedacted = getRes.payload?.raw; + expect(typeof rawRedacted).toBe("string"); + expect(rawRedacted).toContain("__OPENCLAW_REDACTED__"); + + const set2Id = "req-set-sentinel-2"; + ws.send( + JSON.stringify({ + type: "req", + id: set2Id, + method: "config.set", + params: { + raw: rawRedacted, + baseHash, + }, + }), + ); + const set2Res = await onceMessage<{ ok: boolean }>( + ws, + (o) => o.type === "res" && o.id === set2Id, + ); + expect(set2Res.ok).toBe(true); + + const storedRaw = await fs.readFile(CONFIG_PATH, "utf-8"); + const stored = JSON.parse(storedRaw) as { + channels?: { telegram?: { botToken?: string } }; + }; + expect(stored.channels?.telegram?.botToken).toBe("token-1"); }); it("writes config, stores sentinel, and schedules restart", async () => { diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index 41e6fcdd5..970be85ec 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -590,6 +590,15 @@ vi.mock("../cli/deps.js", async () => { }; }); +vi.mock("../plugins/loader.js", async () => { + const actual = + await vi.importActual("../plugins/loader.js"); + return { + ...actual, + loadOpenClawPlugins: () => pluginRegistryState.registry, + }; +}); + process.env.OPENCLAW_SKIP_CHANNELS = "1"; process.env.OPENCLAW_SKIP_CRON = "1"; process.env.OPENCLAW_SKIP_CHANNELS = "1"; diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index db0212b59..6fb436bb9 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -285,7 +285,9 @@ export function onceMessage( export async function startGatewayServer(port: number, opts?: GatewayServerOptions) { const mod = await serverModulePromise; - return await mod.startGatewayServer(port, opts); + const resolvedOpts = + opts?.controlUiEnabled === undefined ? { ...opts, controlUiEnabled: false } : opts; + return await mod.startGatewayServer(port, resolvedOpts); } export async function startServerWithClient(token?: string, opts?: GatewayServerOptions) { @@ -323,7 +325,30 @@ export async function startServerWithClient(token?: string, opts?: GatewayServer } const ws = new WebSocket(`ws://127.0.0.1:${port}`); - await new Promise((resolve) => ws.once("open", resolve)); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("timeout waiting for ws open")), 10_000); + const cleanup = () => { + clearTimeout(timer); + ws.off("open", onOpen); + ws.off("error", onError); + ws.off("close", onClose); + }; + const onOpen = () => { + cleanup(); + resolve(); + }; + const onError = (err: unknown) => { + cleanup(); + reject(err instanceof Error ? err : new Error(String(err))); + }; + const onClose = (code: number, reason: Buffer) => { + cleanup(); + reject(new Error(`closed ${code}: ${reason.toString()}`)); + }; + ws.once("open", onOpen); + ws.once("error", onError); + ws.once("close", onClose); + }); return { server, ws, port, prevToken: prev }; }