From 2ff29a33d0e9947e6e2806d0f8e450963ee7ad69 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 5 Apr 2026 19:44:26 +0100 Subject: [PATCH] refactor: split doctor runtime migrations and talk runtime tests --- ...legacy-config-migrations.runtime.agents.ts | 240 +++++++++++ ...egacy-config-migrations.runtime.gateway.ts | 135 ++++++ ...acy-config-migrations.runtime.providers.ts | 31 ++ .../legacy-config-migrations.runtime.ts | 406 +----------------- .../legacy-config-write-ownership.test.ts | 50 +++ ...nfig.legacy-config-provider-shapes.test.ts | 6 +- .../config.legacy-config-snapshot.test.ts | 18 +- src/config/config.web-search-provider.test.ts | 2 +- src/gateway/server.talk-config.test.ts | 177 -------- src/gateway/server.talk-runtime.test.ts | 191 ++++++++ src/secrets/apply.test.ts | 18 +- 11 files changed, 670 insertions(+), 604 deletions(-) create mode 100644 src/commands/doctor/shared/legacy-config-migrations.runtime.agents.ts create mode 100644 src/commands/doctor/shared/legacy-config-migrations.runtime.gateway.ts create mode 100644 src/commands/doctor/shared/legacy-config-migrations.runtime.providers.ts create mode 100644 src/commands/doctor/shared/legacy-config-write-ownership.test.ts create mode 100644 src/gateway/server.talk-runtime.test.ts diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.agents.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.agents.ts new file mode 100644 index 00000000000..92903b13334 --- /dev/null +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.agents.ts @@ -0,0 +1,240 @@ +import { + defineLegacyConfigMigration, + ensureRecord, + getRecord, + mergeMissing, + type LegacyConfigMigrationSpec, + type LegacyConfigRule, +} from "../../../config/legacy.shared.js"; +import { isBlockedObjectKey } from "../../../config/prototype-keys.js"; + +const AGENT_HEARTBEAT_KEYS = new Set([ + "every", + "activeHours", + "model", + "session", + "includeReasoning", + "target", + "directPolicy", + "to", + "accountId", + "prompt", + "ackMaxChars", + "suppressToolErrorWarnings", + "lightContext", + "isolatedSession", +]); + +const CHANNEL_HEARTBEAT_KEYS = new Set(["showOk", "showAlerts", "useIndicator"]); + +const MEMORY_SEARCH_RULE: LegacyConfigRule = { + path: ["memorySearch"], + message: + 'top-level memorySearch was moved; use agents.defaults.memorySearch instead. Run "openclaw doctor --fix".', +}; + +const HEARTBEAT_RULE: LegacyConfigRule = { + path: ["heartbeat"], + message: + "top-level heartbeat is not a valid config path; use agents.defaults.heartbeat (cadence/target/model settings) or channels.defaults.heartbeat (showOk/showAlerts/useIndicator).", +}; + +const LEGACY_SANDBOX_SCOPE_RULES: LegacyConfigRule[] = [ + { + path: ["agents", "defaults", "sandbox"], + message: + 'agents.defaults.sandbox.perSession is legacy; use agents.defaults.sandbox.scope instead. Run "openclaw doctor --fix".', + match: (value) => hasLegacySandboxPerSession(value), + }, + { + path: ["agents", "list"], + message: + 'agents.list[].sandbox.perSession is legacy; use agents.list[].sandbox.scope instead. Run "openclaw doctor --fix".', + match: (value) => hasLegacyAgentListSandboxPerSession(value), + }, +]; + +function sandboxScopeFromPerSession(perSession: boolean): "session" | "shared" { + return perSession ? "session" : "shared"; +} + +function splitLegacyHeartbeat(legacyHeartbeat: Record): { + agentHeartbeat: Record | null; + channelHeartbeat: Record | null; +} { + const agentHeartbeat: Record = {}; + const channelHeartbeat: Record = {}; + + for (const [key, value] of Object.entries(legacyHeartbeat)) { + if (isBlockedObjectKey(key)) { + continue; + } + if (CHANNEL_HEARTBEAT_KEYS.has(key)) { + channelHeartbeat[key] = value; + continue; + } + if (AGENT_HEARTBEAT_KEYS.has(key)) { + agentHeartbeat[key] = value; + continue; + } + agentHeartbeat[key] = value; + } + + return { + agentHeartbeat: Object.keys(agentHeartbeat).length > 0 ? agentHeartbeat : null, + channelHeartbeat: Object.keys(channelHeartbeat).length > 0 ? channelHeartbeat : null, + }; +} + +function mergeLegacyIntoDefaults(params: { + raw: Record; + rootKey: "agents" | "channels"; + fieldKey: string; + legacyValue: Record; + changes: string[]; + movedMessage: string; + mergedMessage: string; +}) { + const root = ensureRecord(params.raw, params.rootKey); + const defaults = ensureRecord(root, "defaults"); + const existing = getRecord(defaults[params.fieldKey]); + if (!existing) { + defaults[params.fieldKey] = params.legacyValue; + params.changes.push(params.movedMessage); + } else { + const merged = structuredClone(existing); + mergeMissing(merged, params.legacyValue); + defaults[params.fieldKey] = merged; + params.changes.push(params.mergedMessage); + } + + root.defaults = defaults; + params.raw[params.rootKey] = root; +} + +function hasLegacySandboxPerSession(value: unknown): boolean { + const sandbox = getRecord(value); + return Boolean(sandbox && Object.prototype.hasOwnProperty.call(sandbox, "perSession")); +} + +function hasLegacyAgentListSandboxPerSession(value: unknown): boolean { + if (!Array.isArray(value)) { + return false; + } + return value.some((agent) => hasLegacySandboxPerSession(getRecord(agent)?.sandbox)); +} + +function migrateLegacySandboxPerSession( + sandbox: Record, + pathLabel: string, + changes: string[], +): void { + if (!Object.prototype.hasOwnProperty.call(sandbox, "perSession")) { + return; + } + const rawPerSession = sandbox.perSession; + if (typeof rawPerSession !== "boolean") { + return; + } + if (sandbox.scope === undefined) { + sandbox.scope = sandboxScopeFromPerSession(rawPerSession); + changes.push(`Moved ${pathLabel}.perSession → ${pathLabel}.scope (${String(sandbox.scope)}).`); + } else { + changes.push(`Removed ${pathLabel}.perSession (${pathLabel}.scope already set).`); + } + delete sandbox.perSession; +} + +export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_AGENTS: LegacyConfigMigrationSpec[] = [ + defineLegacyConfigMigration({ + id: "agents.sandbox.perSession->scope", + describe: "Move legacy agent sandbox perSession aliases to sandbox.scope", + legacyRules: LEGACY_SANDBOX_SCOPE_RULES, + apply: (raw, changes) => { + const agents = getRecord(raw.agents); + const defaults = getRecord(agents?.defaults); + const defaultSandbox = getRecord(defaults?.sandbox); + if (defaultSandbox) { + migrateLegacySandboxPerSession(defaultSandbox, "agents.defaults.sandbox", changes); + } + + if (!Array.isArray(agents?.list)) { + return; + } + for (const [index, agent] of agents.list.entries()) { + const sandbox = getRecord(getRecord(agent)?.sandbox); + if (!sandbox) { + continue; + } + migrateLegacySandboxPerSession(sandbox, `agents.list.${index}.sandbox`, changes); + } + }, + }), + defineLegacyConfigMigration({ + id: "memorySearch->agents.defaults.memorySearch", + describe: "Move top-level memorySearch to agents.defaults.memorySearch", + legacyRules: [MEMORY_SEARCH_RULE], + apply: (raw, changes) => { + const legacyMemorySearch = getRecord(raw.memorySearch); + if (!legacyMemorySearch) { + return; + } + + mergeLegacyIntoDefaults({ + raw, + rootKey: "agents", + fieldKey: "memorySearch", + legacyValue: legacyMemorySearch, + changes, + movedMessage: "Moved memorySearch → agents.defaults.memorySearch.", + mergedMessage: + "Merged memorySearch → agents.defaults.memorySearch (filled missing fields from legacy; kept explicit agents.defaults values).", + }); + delete raw.memorySearch; + }, + }), + defineLegacyConfigMigration({ + id: "heartbeat->agents.defaults.heartbeat", + describe: "Move top-level heartbeat to agents.defaults.heartbeat/channels.defaults.heartbeat", + legacyRules: [HEARTBEAT_RULE], + apply: (raw, changes) => { + const legacyHeartbeat = getRecord(raw.heartbeat); + if (!legacyHeartbeat) { + return; + } + + const { agentHeartbeat, channelHeartbeat } = splitLegacyHeartbeat(legacyHeartbeat); + + if (agentHeartbeat) { + mergeLegacyIntoDefaults({ + raw, + rootKey: "agents", + fieldKey: "heartbeat", + legacyValue: agentHeartbeat, + changes, + movedMessage: "Moved heartbeat → agents.defaults.heartbeat.", + mergedMessage: + "Merged heartbeat → agents.defaults.heartbeat (filled missing fields from legacy; kept explicit agents.defaults values).", + }); + } + + if (channelHeartbeat) { + mergeLegacyIntoDefaults({ + raw, + rootKey: "channels", + fieldKey: "heartbeat", + legacyValue: channelHeartbeat, + changes, + movedMessage: "Moved heartbeat visibility → channels.defaults.heartbeat.", + mergedMessage: + "Merged heartbeat visibility → channels.defaults.heartbeat (filled missing fields from legacy; kept explicit channels.defaults values).", + }); + } + + if (!agentHeartbeat && !channelHeartbeat) { + changes.push("Removed empty top-level heartbeat."); + } + delete raw.heartbeat; + }, + }), +]; diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.gateway.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.gateway.ts new file mode 100644 index 00000000000..52564489fbd --- /dev/null +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.gateway.ts @@ -0,0 +1,135 @@ +import { + buildDefaultControlUiAllowedOrigins, + hasConfiguredControlUiAllowedOrigins, + isGatewayNonLoopbackBindMode, + resolveGatewayPortWithDefault, +} from "../../../config/gateway-control-ui-origins.js"; +import { + defineLegacyConfigMigration, + getRecord, + type LegacyConfigMigrationSpec, + type LegacyConfigRule, +} from "../../../config/legacy.shared.js"; +import { DEFAULT_GATEWAY_PORT } from "../../../config/paths.js"; + +const GATEWAY_BIND_RULE: LegacyConfigRule = { + path: ["gateway", "bind"], + message: + 'gateway.bind host aliases (for example 0.0.0.0/localhost) are legacy; use bind modes (lan/loopback/custom/tailnet/auto) instead. Run "openclaw doctor --fix".', + match: (value) => isLegacyGatewayBindHostAlias(value), + requireSourceLiteral: true, +}; + +function isLegacyGatewayBindHostAlias(value: unknown): boolean { + if (typeof value !== "string") { + return false; + } + const normalized = value.trim().toLowerCase(); + if (!normalized) { + return false; + } + if ( + normalized === "auto" || + normalized === "loopback" || + normalized === "lan" || + normalized === "tailnet" || + normalized === "custom" + ) { + return false; + } + return ( + normalized === "0.0.0.0" || + normalized === "::" || + normalized === "[::]" || + normalized === "*" || + normalized === "127.0.0.1" || + normalized === "localhost" || + normalized === "::1" || + normalized === "[::1]" + ); +} + +function escapeControlForLog(value: string): string { + return value.replace(/\r/g, "\\r").replace(/\n/g, "\\n").replace(/\t/g, "\\t"); +} + +export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_GATEWAY: LegacyConfigMigrationSpec[] = [ + defineLegacyConfigMigration({ + id: "gateway.controlUi.allowedOrigins-seed-for-non-loopback", + describe: "Seed gateway.controlUi.allowedOrigins for existing non-loopback gateway installs", + apply: (raw, changes) => { + const gateway = getRecord(raw.gateway); + if (!gateway) { + return; + } + const bind = gateway.bind; + if (!isGatewayNonLoopbackBindMode(bind)) { + return; + } + const controlUi = getRecord(gateway.controlUi) ?? {}; + if ( + hasConfiguredControlUiAllowedOrigins({ + allowedOrigins: controlUi.allowedOrigins, + dangerouslyAllowHostHeaderOriginFallback: + controlUi.dangerouslyAllowHostHeaderOriginFallback, + }) + ) { + return; + } + const port = resolveGatewayPortWithDefault(gateway.port, DEFAULT_GATEWAY_PORT); + const origins = buildDefaultControlUiAllowedOrigins({ + port, + bind, + customBindHost: + typeof gateway.customBindHost === "string" ? gateway.customBindHost : undefined, + }); + gateway.controlUi = { ...controlUi, allowedOrigins: origins }; + raw.gateway = gateway; + changes.push( + `Seeded gateway.controlUi.allowedOrigins ${JSON.stringify(origins)} for bind=${String(bind)}. ` + + "Required since v2026.2.26. Add other machine origins to gateway.controlUi.allowedOrigins if needed.", + ); + }, + }), + defineLegacyConfigMigration({ + id: "gateway.bind.host-alias->bind-mode", + describe: "Normalize gateway.bind host aliases to supported bind modes", + legacyRules: [GATEWAY_BIND_RULE], + apply: (raw, changes) => { + const gateway = getRecord(raw.gateway); + if (!gateway) { + return; + } + const bindRaw = gateway.bind; + if (typeof bindRaw !== "string") { + return; + } + + const normalized = bindRaw.trim().toLowerCase(); + let mapped: "lan" | "loopback" | undefined; + if ( + normalized === "0.0.0.0" || + normalized === "::" || + normalized === "[::]" || + normalized === "*" + ) { + mapped = "lan"; + } else if ( + normalized === "127.0.0.1" || + normalized === "localhost" || + normalized === "::1" || + normalized === "[::1]" + ) { + mapped = "loopback"; + } + + if (!mapped || normalized === mapped) { + return; + } + + gateway.bind = mapped; + raw.gateway = gateway; + changes.push(`Normalized gateway.bind "${escapeControlForLog(bindRaw)}" → "${mapped}".`); + }, + }), +]; diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.providers.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.providers.ts new file mode 100644 index 00000000000..89e26d797e0 --- /dev/null +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.providers.ts @@ -0,0 +1,31 @@ +import { + defineLegacyConfigMigration, + type LegacyConfigMigrationSpec, + type LegacyConfigRule, +} from "../../../config/legacy.shared.js"; +import { migrateLegacyXSearchConfig } from "./legacy-x-search-migrate.js"; + +const X_SEARCH_RULE: LegacyConfigRule = { + path: ["tools", "web", "x_search", "apiKey"], + message: + 'tools.web.x_search.apiKey moved to the xAI plugin; use plugins.entries.xai.config.webSearch.apiKey instead. Run "openclaw doctor --fix".', +}; + +export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_PROVIDERS: LegacyConfigMigrationSpec[] = [ + defineLegacyConfigMigration({ + id: "tools.web.x_search.apiKey->plugins.entries.xai.config.webSearch.apiKey", + describe: "Move legacy x_search auth into the xAI plugin webSearch config", + legacyRules: [X_SEARCH_RULE], + apply: (raw, changes) => { + const migrated = migrateLegacyXSearchConfig(raw); + if (!migrated.changes.length) { + return; + } + for (const key of Object.keys(raw)) { + delete raw[key]; + } + Object.assign(raw, migrated.config); + changes.push(...migrated.changes); + }, + }), +]; diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.ts index bb9ee2e63d5..4240ce981bd 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.runtime.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.ts @@ -1,404 +1,12 @@ -import { - buildDefaultControlUiAllowedOrigins, - hasConfiguredControlUiAllowedOrigins, - isGatewayNonLoopbackBindMode, - resolveGatewayPortWithDefault, -} from "../../../config/gateway-control-ui-origins.js"; -import { - defineLegacyConfigMigration, - ensureRecord, - getRecord, - mergeMissing, - type LegacyConfigMigrationSpec, - type LegacyConfigRule, -} from "../../../config/legacy.shared.js"; -import { DEFAULT_GATEWAY_PORT } from "../../../config/paths.js"; -import { isBlockedObjectKey } from "../../../config/prototype-keys.js"; +import type { LegacyConfigMigrationSpec } from "../../../config/legacy.shared.js"; +import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_AGENTS } from "./legacy-config-migrations.runtime.agents.js"; +import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_GATEWAY } from "./legacy-config-migrations.runtime.gateway.js"; +import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_PROVIDERS } from "./legacy-config-migrations.runtime.providers.js"; import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_TTS } from "./legacy-config-migrations.runtime.tts.js"; -import { migrateLegacyXSearchConfig } from "./legacy-x-search-migrate.js"; - -const AGENT_HEARTBEAT_KEYS = new Set([ - "every", - "activeHours", - "model", - "session", - "includeReasoning", - "target", - "directPolicy", - "to", - "accountId", - "prompt", - "ackMaxChars", - "suppressToolErrorWarnings", - "lightContext", - "isolatedSession", -]); - -const CHANNEL_HEARTBEAT_KEYS = new Set(["showOk", "showAlerts", "useIndicator"]); -function sandboxScopeFromPerSession(perSession: boolean): "session" | "shared" { - return perSession ? "session" : "shared"; -} - -function isLegacyGatewayBindHostAlias(value: unknown): boolean { - if (typeof value !== "string") { - return false; - } - const normalized = value.trim().toLowerCase(); - if (!normalized) { - return false; - } - if ( - normalized === "auto" || - normalized === "loopback" || - normalized === "lan" || - normalized === "tailnet" || - normalized === "custom" - ) { - return false; - } - return ( - normalized === "0.0.0.0" || - normalized === "::" || - normalized === "[::]" || - normalized === "*" || - normalized === "127.0.0.1" || - normalized === "localhost" || - normalized === "::1" || - normalized === "[::1]" - ); -} - -function escapeControlForLog(value: string): string { - return value.replace(/\r/g, "\\r").replace(/\n/g, "\\n").replace(/\t/g, "\\t"); -} - -function splitLegacyHeartbeat(legacyHeartbeat: Record): { - agentHeartbeat: Record | null; - channelHeartbeat: Record | null; -} { - const agentHeartbeat: Record = {}; - const channelHeartbeat: Record = {}; - - for (const [key, value] of Object.entries(legacyHeartbeat)) { - if (isBlockedObjectKey(key)) { - continue; - } - if (CHANNEL_HEARTBEAT_KEYS.has(key)) { - channelHeartbeat[key] = value; - continue; - } - if (AGENT_HEARTBEAT_KEYS.has(key)) { - agentHeartbeat[key] = value; - continue; - } - // Preserve unknown fields under the agent heartbeat namespace so validation - // still surfaces unsupported keys instead of silently dropping user input. - agentHeartbeat[key] = value; - } - - return { - agentHeartbeat: Object.keys(agentHeartbeat).length > 0 ? agentHeartbeat : null, - channelHeartbeat: Object.keys(channelHeartbeat).length > 0 ? channelHeartbeat : null, - }; -} - -function mergeLegacyIntoDefaults(params: { - raw: Record; - rootKey: "agents" | "channels"; - fieldKey: string; - legacyValue: Record; - changes: string[]; - movedMessage: string; - mergedMessage: string; -}) { - const root = ensureRecord(params.raw, params.rootKey); - const defaults = ensureRecord(root, "defaults"); - const existing = getRecord(defaults[params.fieldKey]); - if (!existing) { - defaults[params.fieldKey] = params.legacyValue; - params.changes.push(params.movedMessage); - } else { - // defaults stays authoritative; legacy top-level config only fills gaps. - const merged = structuredClone(existing); - mergeMissing(merged, params.legacyValue); - defaults[params.fieldKey] = merged; - params.changes.push(params.mergedMessage); - } - - root.defaults = defaults; - params.raw[params.rootKey] = root; -} - -function hasLegacySandboxPerSession(value: unknown): boolean { - const sandbox = getRecord(value); - return Boolean(sandbox && Object.prototype.hasOwnProperty.call(sandbox, "perSession")); -} - -function hasLegacyAgentListSandboxPerSession(value: unknown): boolean { - if (!Array.isArray(value)) { - return false; - } - return value.some((agent) => hasLegacySandboxPerSession(getRecord(agent)?.sandbox)); -} -const MEMORY_SEARCH_RULE: LegacyConfigRule = { - path: ["memorySearch"], - message: - 'top-level memorySearch was moved; use agents.defaults.memorySearch instead. Run "openclaw doctor --fix".', -}; - -const GATEWAY_BIND_RULE: LegacyConfigRule = { - path: ["gateway", "bind"], - message: - 'gateway.bind host aliases (for example 0.0.0.0/localhost) are legacy; use bind modes (lan/loopback/custom/tailnet/auto) instead. Run "openclaw doctor --fix".', - match: (value) => isLegacyGatewayBindHostAlias(value), - requireSourceLiteral: true, -}; - -const HEARTBEAT_RULE: LegacyConfigRule = { - path: ["heartbeat"], - message: - "top-level heartbeat is not a valid config path; use agents.defaults.heartbeat (cadence/target/model settings) or channels.defaults.heartbeat (showOk/showAlerts/useIndicator).", -}; - -const X_SEARCH_RULE: LegacyConfigRule = { - path: ["tools", "web", "x_search", "apiKey"], - message: - 'tools.web.x_search.apiKey moved to the xAI plugin; use plugins.entries.xai.config.webSearch.apiKey instead. Run "openclaw doctor --fix".', -}; - -const LEGACY_SANDBOX_SCOPE_RULES: LegacyConfigRule[] = [ - { - path: ["agents", "defaults", "sandbox"], - message: - 'agents.defaults.sandbox.perSession is legacy; use agents.defaults.sandbox.scope instead. Run "openclaw doctor --fix".', - match: (value) => hasLegacySandboxPerSession(value), - }, - { - path: ["agents", "list"], - message: - 'agents.list[].sandbox.perSession is legacy; use agents.list[].sandbox.scope instead. Run "openclaw doctor --fix".', - match: (value) => hasLegacyAgentListSandboxPerSession(value), - }, -]; - -function migrateLegacySandboxPerSession( - sandbox: Record, - pathLabel: string, - changes: string[], -): void { - if (!Object.prototype.hasOwnProperty.call(sandbox, "perSession")) { - return; - } - const rawPerSession = sandbox.perSession; - if (typeof rawPerSession === "boolean") { - if (sandbox.scope === undefined) { - sandbox.scope = sandboxScopeFromPerSession(rawPerSession); - changes.push( - `Moved ${pathLabel}.perSession → ${pathLabel}.scope (${String(sandbox.scope)}).`, - ); - } else { - changes.push(`Removed ${pathLabel}.perSession (${pathLabel}.scope already set).`); - } - delete sandbox.perSession; - } else { - // Preserve invalid values so normal schema validation still surfaces the - // type error instead of silently falling back to the default sandbox scope. - return; - } -} export const LEGACY_CONFIG_MIGRATIONS_RUNTIME: LegacyConfigMigrationSpec[] = [ - defineLegacyConfigMigration({ - id: "agents.sandbox.perSession->scope", - describe: "Move legacy agent sandbox perSession aliases to sandbox.scope", - legacyRules: LEGACY_SANDBOX_SCOPE_RULES, - apply: (raw, changes) => { - const agents = getRecord(raw.agents); - const defaults = getRecord(agents?.defaults); - const defaultSandbox = getRecord(defaults?.sandbox); - if (defaultSandbox) { - migrateLegacySandboxPerSession(defaultSandbox, "agents.defaults.sandbox", changes); - } - - if (!Array.isArray(agents?.list)) { - return; - } - for (const [index, agent] of agents.list.entries()) { - const agentRecord = getRecord(agent); - const sandbox = getRecord(agentRecord?.sandbox); - if (!sandbox) { - continue; - } - migrateLegacySandboxPerSession(sandbox, `agents.list.${index}.sandbox`, changes); - } - }, - }), - defineLegacyConfigMigration({ - id: "tools.web.x_search.apiKey->plugins.entries.xai.config.webSearch.apiKey", - describe: "Move legacy x_search auth into the xAI plugin webSearch config", - legacyRules: [X_SEARCH_RULE], - apply: (raw, changes) => { - const migrated = migrateLegacyXSearchConfig(raw); - if (!migrated.changes.length) { - return; - } - for (const key of Object.keys(raw)) { - delete raw[key]; - } - Object.assign(raw, migrated.config); - changes.push(...migrated.changes); - }, - }), - defineLegacyConfigMigration({ - // v2026.2.26 added a startup guard requiring gateway.controlUi.allowedOrigins (or the - // host-header fallback flag) for any non-loopback bind. The setup wizard was updated - // to seed this for new installs, but existing bind=lan/bind=custom installs that upgrade - // crash-loop immediately on next startup with no recovery path (issue #29385). - // - // Doctor-only migration path. Runtime now stops and points users to doctor before startup. - id: "gateway.controlUi.allowedOrigins-seed-for-non-loopback", - describe: "Seed gateway.controlUi.allowedOrigins for existing non-loopback gateway installs", - apply: (raw, changes) => { - const gateway = getRecord(raw.gateway); - if (!gateway) { - return; - } - const bind = gateway.bind; - if (!isGatewayNonLoopbackBindMode(bind)) { - return; - } - const controlUi = getRecord(gateway.controlUi) ?? {}; - if ( - hasConfiguredControlUiAllowedOrigins({ - allowedOrigins: controlUi.allowedOrigins, - dangerouslyAllowHostHeaderOriginFallback: - controlUi.dangerouslyAllowHostHeaderOriginFallback, - }) - ) { - return; - } - const port = resolveGatewayPortWithDefault(gateway.port, DEFAULT_GATEWAY_PORT); - const origins = buildDefaultControlUiAllowedOrigins({ - port, - bind, - customBindHost: - typeof gateway.customBindHost === "string" ? gateway.customBindHost : undefined, - }); - gateway.controlUi = { ...controlUi, allowedOrigins: origins }; - raw.gateway = gateway; - changes.push( - `Seeded gateway.controlUi.allowedOrigins ${JSON.stringify(origins)} for bind=${String(bind)}. ` + - "Required since v2026.2.26. Add other machine origins to gateway.controlUi.allowedOrigins if needed.", - ); - }, - }), - defineLegacyConfigMigration({ - id: "memorySearch->agents.defaults.memorySearch", - describe: "Move top-level memorySearch to agents.defaults.memorySearch", - legacyRules: [MEMORY_SEARCH_RULE], - apply: (raw, changes) => { - const legacyMemorySearch = getRecord(raw.memorySearch); - if (!legacyMemorySearch) { - return; - } - - mergeLegacyIntoDefaults({ - raw, - rootKey: "agents", - fieldKey: "memorySearch", - legacyValue: legacyMemorySearch, - changes, - movedMessage: "Moved memorySearch → agents.defaults.memorySearch.", - mergedMessage: - "Merged memorySearch → agents.defaults.memorySearch (filled missing fields from legacy; kept explicit agents.defaults values).", - }); - delete raw.memorySearch; - }, - }), - defineLegacyConfigMigration({ - id: "gateway.bind.host-alias->bind-mode", - describe: "Normalize gateway.bind host aliases to supported bind modes", - legacyRules: [GATEWAY_BIND_RULE], - apply: (raw, changes) => { - const gateway = getRecord(raw.gateway); - if (!gateway) { - return; - } - const bindRaw = gateway.bind; - if (typeof bindRaw !== "string") { - return; - } - - const normalized = bindRaw.trim().toLowerCase(); - let mapped: "lan" | "loopback" | undefined; - if ( - normalized === "0.0.0.0" || - normalized === "::" || - normalized === "[::]" || - normalized === "*" - ) { - mapped = "lan"; - } else if ( - normalized === "127.0.0.1" || - normalized === "localhost" || - normalized === "::1" || - normalized === "[::1]" - ) { - mapped = "loopback"; - } - - if (!mapped || normalized === mapped) { - return; - } - - gateway.bind = mapped; - raw.gateway = gateway; - changes.push(`Normalized gateway.bind "${escapeControlForLog(bindRaw)}" → "${mapped}".`); - }, - }), + ...LEGACY_CONFIG_MIGRATIONS_RUNTIME_AGENTS, + ...LEGACY_CONFIG_MIGRATIONS_RUNTIME_GATEWAY, + ...LEGACY_CONFIG_MIGRATIONS_RUNTIME_PROVIDERS, ...LEGACY_CONFIG_MIGRATIONS_RUNTIME_TTS, - defineLegacyConfigMigration({ - id: "heartbeat->agents.defaults.heartbeat", - describe: "Move top-level heartbeat to agents.defaults.heartbeat/channels.defaults.heartbeat", - legacyRules: [HEARTBEAT_RULE], - apply: (raw, changes) => { - const legacyHeartbeat = getRecord(raw.heartbeat); - if (!legacyHeartbeat) { - return; - } - - const { agentHeartbeat, channelHeartbeat } = splitLegacyHeartbeat(legacyHeartbeat); - - if (agentHeartbeat) { - mergeLegacyIntoDefaults({ - raw, - rootKey: "agents", - fieldKey: "heartbeat", - legacyValue: agentHeartbeat, - changes, - movedMessage: "Moved heartbeat → agents.defaults.heartbeat.", - mergedMessage: - "Merged heartbeat → agents.defaults.heartbeat (filled missing fields from legacy; kept explicit agents.defaults values).", - }); - } - - if (channelHeartbeat) { - mergeLegacyIntoDefaults({ - raw, - rootKey: "channels", - fieldKey: "heartbeat", - legacyValue: channelHeartbeat, - changes, - movedMessage: "Moved heartbeat visibility → channels.defaults.heartbeat.", - mergedMessage: - "Merged heartbeat visibility → channels.defaults.heartbeat (filled missing fields from legacy; kept explicit channels.defaults values).", - }); - } - - if (!agentHeartbeat && !channelHeartbeat) { - changes.push("Removed empty top-level heartbeat."); - } - delete raw.heartbeat; - }, - }), ]; diff --git a/src/commands/doctor/shared/legacy-config-write-ownership.test.ts b/src/commands/doctor/shared/legacy-config-write-ownership.test.ts new file mode 100644 index 00000000000..0d719cbdcd7 --- /dev/null +++ b/src/commands/doctor/shared/legacy-config-write-ownership.test.ts @@ -0,0 +1,50 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const REPO_ROOT = path.resolve(import.meta.dirname, "../../../.."); +const SRC_ROOT = path.join(REPO_ROOT, "src"); + +function collectSourceFiles(dir: string, acc: string[] = []): string[] { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.name === "dist" || entry.name === "node_modules") { + continue; + } + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + collectSourceFiles(fullPath, acc); + continue; + } + if (!entry.isFile() || !entry.name.endsWith(".ts") || entry.name.endsWith(".test.ts")) { + continue; + } + acc.push(fullPath); + } + return acc; +} + +describe("legacy config write ownership", () => { + it("keeps legacy config repair flags and migration modules under doctor", () => { + const files = collectSourceFiles(SRC_ROOT); + const violations: string[] = []; + + for (const file of files) { + const rel = path.relative(REPO_ROOT, file).replaceAll(path.sep, "/"); + const source = fs.readFileSync(file, "utf8"); + const isDoctorFile = rel.startsWith("src/commands/doctor/"); + + if (!isDoctorFile && /migrateLegacyConfig\s*:\s*true/.test(source)) { + violations.push(`${rel}: migrateLegacyConfig:true outside doctor`); + } + + if ( + !isDoctorFile && + /legacy-config-migrate(?:\.js)?|legacy-config-migrations(?:\.[\w-]+)?(?:\.js)?/.test(source) + ) { + violations.push(`${rel}: doctor legacy migration module referenced outside doctor`); + } + } + + expect(violations).toEqual([]); + }); +}); diff --git a/src/config/config.legacy-config-provider-shapes.test.ts b/src/config/config.legacy-config-provider-shapes.test.ts index 2eb03030211..8e046d7dae8 100644 --- a/src/config/config.legacy-config-provider-shapes.test.ts +++ b/src/config/config.legacy-config-provider-shapes.test.ts @@ -36,7 +36,7 @@ describe("legacy provider-shaped config snapshots", () => { expect(res.ok).toBe(false); }); - it("rejects legacy messages.tts provider keys until doctor repairs them and reports legacyIssues", async () => { + it("detects legacy messages.tts provider keys and reports legacyIssues", async () => { await withTempHome(async (home) => { await writeOpenClawConfig(home, { messages: { @@ -86,7 +86,7 @@ describe("legacy provider-shaped config snapshots", () => { }); }); - it("rejects legacy plugins.entries.*.config.tts provider keys until doctor repairs them", async () => { + it("detects legacy plugins.entries.*.config.tts provider keys", async () => { await withTempHome(async (home) => { await writeOpenClawConfig(home, { plugins: { @@ -135,7 +135,7 @@ describe("legacy provider-shaped config snapshots", () => { }); }); - it("rejects legacy discord voice tts provider keys until doctor repairs them and reports legacyIssues", async () => { + it("detects legacy discord voice tts provider keys and reports legacyIssues", async () => { await withTempHome(async (home) => { await writeOpenClawConfig(home, { channels: { diff --git a/src/config/config.legacy-config-snapshot.test.ts b/src/config/config.legacy-config-snapshot.test.ts index 35d328e1c15..326af0fa982 100644 --- a/src/config/config.legacy-config-snapshot.test.ts +++ b/src/config/config.legacy-config-snapshot.test.ts @@ -38,7 +38,7 @@ describe("config strict validation", () => { } }); - it("rejects top-level memorySearch until doctor repairs it and reports legacyIssues", async () => { + it("detects top-level memorySearch and reports legacyIssues", async () => { await withTempHome(async (home) => { await writeOpenClawConfig(home, { memorySearch: { @@ -60,7 +60,7 @@ describe("config strict validation", () => { }); }); - it("rejects top-level heartbeat agent settings until doctor repairs them and reports legacyIssues", async () => { + it("detects top-level heartbeat agent settings and reports legacyIssues", async () => { await withTempHome(async (home) => { await writeOpenClawConfig(home, { heartbeat: { @@ -80,7 +80,7 @@ describe("config strict validation", () => { }); }); - it("rejects top-level heartbeat visibility until doctor repairs them and reports legacyIssues", async () => { + it("detects top-level heartbeat visibility and reports legacyIssues", async () => { await withTempHome(async (home) => { await writeOpenClawConfig(home, { heartbeat: { @@ -102,7 +102,7 @@ describe("config strict validation", () => { }); }); - it("rejects legacy sandbox perSession until doctor repairs it and reports legacyIssues", async () => { + it("detects legacy sandbox perSession and reports legacyIssues", async () => { await withTempHome(async (home) => { await writeOpenClawConfig(home, { agents: { @@ -134,7 +134,7 @@ describe("config strict validation", () => { }); }); - it("rejects legacy x_search auth until doctor repairs it and reports legacyIssues", async () => { + it("detects legacy x_search auth and reports legacyIssues", async () => { await withTempHome(async (home) => { await writeOpenClawConfig(home, { tools: { @@ -158,7 +158,7 @@ describe("config strict validation", () => { }); }); - it("rejects legacy thread binding ttlHours until doctor repairs it and reports legacyIssues", async () => { + it("detects legacy thread binding ttlHours and reports legacyIssues", async () => { await withTempHome(async (home) => { await writeOpenClawConfig(home, { session: { @@ -195,7 +195,7 @@ describe("config strict validation", () => { }); }); - it("rejects legacy channel streaming aliases until doctor repairs them and reports legacyIssues", async () => { + it("detects legacy channel streaming aliases and reports legacyIssues", async () => { await withTempHome(async (home) => { await writeOpenClawConfig(home, { channels: { @@ -250,7 +250,7 @@ describe("config strict validation", () => { }); }); - it("rejects legacy nested channel allow aliases until doctor repairs them and reports legacyIssues", async () => { + it("detects legacy nested channel allow aliases and reports legacyIssues", async () => { await withTempHome(async (home) => { await writeOpenClawConfig(home, { channels: { @@ -338,7 +338,7 @@ describe("config strict validation", () => { }); }); - it("rejects telegram groupMentionsOnly until doctor repairs it and reports legacyIssues", async () => { + it("detects telegram groupMentionsOnly and reports legacyIssues", async () => { await withTempHome(async (home) => { await writeOpenClawConfig(home, { channels: { diff --git a/src/config/config.web-search-provider.test.ts b/src/config/config.web-search-provider.test.ts index 50cf6b1ab6d..927e71a1bb6 100644 --- a/src/config/config.web-search-provider.test.ts +++ b/src/config/config.web-search-provider.test.ts @@ -387,7 +387,7 @@ describe("web search provider config", () => { expect(res.ok).toBe(false); }); - it("rejects legacy scoped provider config for bundled providers until doctor repairs it", () => { + it("detects legacy scoped provider config for bundled providers", () => { const res = validateConfigObjectWithPlugins({ tools: { web: { diff --git a/src/gateway/server.talk-config.test.ts b/src/gateway/server.talk-config.test.ts index fe329f3bc3b..4dccde89bce 100644 --- a/src/gateway/server.talk-config.test.ts +++ b/src/gateway/server.talk-config.test.ts @@ -11,7 +11,6 @@ import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/run import { withEnvAsync } from "../test-utils/env.js"; import { buildDeviceAuthPayload } from "./device-auth.js"; import { validateTalkConfigResult } from "./protocol/index.js"; -import { talkHandlers } from "./server-methods/talk.js"; import { connectOk, installGatewayTestHooks, @@ -42,10 +41,6 @@ type TalkConfigPayload = { }; }; type TalkConfig = NonNullable["talk"]>; -type TalkSpeakPayload = { - audioBase64?: string; - provider?: string; -}; const TALK_CONFIG_DEVICE_PATH = path.join( os.tmpdir(), `openclaw-talk-config-device-${process.pid}.json`, @@ -118,35 +113,6 @@ async function fetchTalkConfig( return rpcReq(ws, "talk.config", params ?? {}); } -async function fetchTalkSpeak( - ws: GatewaySocket, - params: Record, - timeoutMs?: number, -) { - return rpcReq(ws, "talk.speak", params, timeoutMs); -} - -async function invokeTalkSpeakDirect(params: Record) { - let response: - | { - ok: boolean; - payload?: unknown; - error?: { code?: string; message?: string; details?: unknown }; - } - | undefined; - await talkHandlers["talk.speak"]({ - req: { type: "req", id: "test", method: "talk.speak", params }, - params, - client: null, - isWebchatConnect: () => false, - respond: (ok, payload, error) => { - response = { ok, payload, error }; - }, - context: {} as never, - }); - return response; -} - async function withSpeechProviders( speechProviders: NonNullable["speechProviders"]>, run: () => Promise, @@ -354,147 +320,4 @@ describe("gateway talk.config", () => { }); }); }); - - it("allows extension speech providers through talk.speak", async () => { - const { writeConfigFile } = await import("../config/config.js"); - await writeConfigFile({ - talk: { - provider: "acme", - providers: { - acme: { - voiceId: "plugin-voice", - }, - }, - }, - }); - - await withServer(async () => { - await withSpeechProviders( - [ - { - pluginId: "acme-plugin", - source: "test", - provider: { - id: "acme", - label: "Acme Speech", - isConfigured: () => true, - synthesize: async () => ({ - audioBuffer: Buffer.from([7, 8, 9]), - outputFormat: "mp3", - fileExtension: ".mp3", - voiceCompatible: false, - }), - }, - }, - ], - async () => { - const res = await invokeTalkSpeakDirect({ - text: "Hello from plugin talk mode.", - }); - expect(res?.ok, JSON.stringify(res?.error)).toBe(true); - expect((res?.payload as TalkSpeakPayload | undefined)?.provider).toBe("acme"); - expect((res?.payload as TalkSpeakPayload | undefined)?.audioBase64).toBe( - Buffer.from([7, 8, 9]).toString("base64"), - ); - }, - ); - }); - }); - - it("returns fallback-eligible details when talk provider is not configured", async () => { - const { writeConfigFile } = await import("../config/config.js"); - await writeConfigFile({ talk: {} }); - - await withServer(async (ws) => { - await connectOperator(ws, ["operator.read", "operator.write"]); - const res = await fetchTalkSpeak(ws, { text: "Hello from talk mode." }); - expect(res.ok).toBe(false); - expect(res.error?.message).toContain("talk provider not configured"); - expect((res.error as { details?: unknown } | undefined)?.details).toEqual({ - reason: "talk_unconfigured", - fallbackEligible: true, - }); - }); - }); - - it("returns synthesis_failed details when the provider rejects synthesis", async () => { - const { writeConfigFile } = await import("../config/config.js"); - await writeConfigFile({ - talk: { - provider: "acme", - providers: { - acme: { - voiceId: "plugin-voice", - }, - }, - }, - }); - - await withSpeechProviders( - [ - { - pluginId: "acme-plugin", - source: "test", - provider: { - id: "acme", - label: "Acme Speech", - isConfigured: () => true, - synthesize: async () => { - throw new Error("provider failed"); - }, - }, - }, - ], - async () => { - const res = await invokeTalkSpeakDirect({ text: "Hello from talk mode." }); - expect(res?.ok).toBe(false); - expect(res?.error?.details).toEqual({ - reason: "synthesis_failed", - fallbackEligible: false, - }); - }, - ); - }); - - it("rejects empty audio results as invalid_audio_result", async () => { - const { writeConfigFile } = await import("../config/config.js"); - await writeConfigFile({ - talk: { - provider: "acme", - providers: { - acme: { - voiceId: "plugin-voice", - }, - }, - }, - }); - - await withSpeechProviders( - [ - { - pluginId: "acme-plugin", - source: "test", - provider: { - id: "acme", - label: "Acme Speech", - isConfigured: () => true, - synthesize: async () => ({ - audioBuffer: Buffer.alloc(0), - outputFormat: "mp3", - fileExtension: ".mp3", - voiceCompatible: false, - }), - }, - }, - ], - async () => { - const res = await invokeTalkSpeakDirect({ text: "Hello from talk mode." }); - expect(res?.ok).toBe(false); - expect(res?.error?.details).toEqual({ - reason: "invalid_audio_result", - fallbackEligible: false, - }); - }, - ); - }); }); diff --git a/src/gateway/server.talk-runtime.test.ts b/src/gateway/server.talk-runtime.test.ts new file mode 100644 index 00000000000..49926ead73d --- /dev/null +++ b/src/gateway/server.talk-runtime.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from "vitest"; +import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; +import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js"; +import { talkHandlers } from "./server-methods/talk.js"; +import { withServer } from "./test-with-server.js"; + +type TalkSpeakPayload = { + audioBase64?: string; + provider?: string; +}; + +async function invokeTalkSpeakDirect(params: Record) { + let response: + | { + ok: boolean; + payload?: unknown; + error?: { code?: string; message?: string; details?: unknown }; + } + | undefined; + await talkHandlers["talk.speak"]({ + req: { type: "req", id: "test", method: "talk.speak", params }, + params, + client: null, + isWebchatConnect: () => false, + respond: (ok, payload, error) => { + response = { ok, payload, error }; + }, + context: {} as never, + }); + return response; +} + +async function withSpeechProviders( + speechProviders: NonNullable["speechProviders"]>, + run: () => Promise, +): Promise { + const previousRegistry = getActivePluginRegistry() ?? createEmptyPluginRegistry(); + setActivePluginRegistry({ + ...createEmptyPluginRegistry(), + speechProviders, + }); + try { + return await run(); + } finally { + setActivePluginRegistry(previousRegistry); + } +} + +describe("gateway talk runtime", () => { + it("allows extension speech providers through talk.speak", async () => { + const { writeConfigFile } = await import("../config/config.js"); + await writeConfigFile({ + talk: { + provider: "acme", + providers: { + acme: { + voiceId: "plugin-voice", + }, + }, + }, + }); + + await withServer(async () => { + await withSpeechProviders( + [ + { + pluginId: "acme-plugin", + source: "test", + provider: { + id: "acme", + label: "Acme Speech", + isConfigured: () => true, + synthesize: async () => ({ + audioBuffer: Buffer.from([7, 8, 9]), + outputFormat: "mp3", + fileExtension: ".mp3", + voiceCompatible: false, + }), + }, + }, + ], + async () => { + const res = await invokeTalkSpeakDirect({ + text: "Hello from talk mode.", + }); + expect(res?.ok, JSON.stringify(res?.error)).toBe(true); + expect((res?.payload as TalkSpeakPayload | undefined)?.provider).toBe("acme"); + expect((res?.payload as TalkSpeakPayload | undefined)?.audioBase64).toBe( + Buffer.from([7, 8, 9]).toString("base64"), + ); + }, + ); + }); + }); + + it("returns fallback-eligible details when talk provider is not configured", async () => { + const { writeConfigFile } = await import("../config/config.js"); + await writeConfigFile({ talk: {} }); + + await withServer(async () => { + const res = await invokeTalkSpeakDirect({ text: "Hello from talk mode." }); + expect(res?.ok).toBe(false); + expect(res?.error?.message).toContain("talk provider not configured"); + expect((res?.error as { details?: unknown } | undefined)?.details).toEqual({ + reason: "talk_unconfigured", + fallbackEligible: true, + }); + }); + }); + + it("returns synthesis_failed details when the provider rejects synthesis", async () => { + const { writeConfigFile } = await import("../config/config.js"); + await writeConfigFile({ + talk: { + provider: "acme", + providers: { + acme: { + voiceId: "plugin-voice", + }, + }, + }, + }); + + await withSpeechProviders( + [ + { + pluginId: "acme-plugin", + source: "test", + provider: { + id: "acme", + label: "Acme Speech", + isConfigured: () => true, + synthesize: async () => { + throw new Error("provider failed"); + }, + }, + }, + ], + async () => { + const res = await invokeTalkSpeakDirect({ text: "Hello from talk mode." }); + expect(res?.ok).toBe(false); + expect(res?.error?.details).toEqual({ + reason: "synthesis_failed", + fallbackEligible: false, + }); + }, + ); + }); + + it("rejects empty audio results as invalid_audio_result", async () => { + const { writeConfigFile } = await import("../config/config.js"); + await writeConfigFile({ + talk: { + provider: "acme", + providers: { + acme: { + voiceId: "plugin-voice", + }, + }, + }, + }); + + await withSpeechProviders( + [ + { + pluginId: "acme-plugin", + source: "test", + provider: { + id: "acme", + label: "Acme Speech", + isConfigured: () => true, + synthesize: async () => ({ + audioBuffer: Buffer.alloc(0), + outputFormat: "mp3", + fileExtension: ".mp3", + voiceCompatible: false, + }), + }, + }, + ], + async () => { + const res = await invokeTalkSpeakDirect({ text: "Hello from talk mode." }); + expect(res?.ok).toBe(false); + expect(res?.error?.details).toEqual({ + reason: "invalid_audio_result", + fallbackEligible: false, + }); + }, + ); + }); +}); diff --git a/src/secrets/apply.test.ts b/src/secrets/apply.test.ts index b78ed0e24e4..8b40ec4ce87 100644 --- a/src/secrets/apply.test.ts +++ b/src/secrets/apply.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { + buildTalkTestProviderConfig, TALK_TEST_PROVIDER_API_KEY_PATH, TALK_TEST_PROVIDER_API_KEY_PATH_SEGMENTS, TALK_TEST_PROVIDER_ID, @@ -525,22 +526,9 @@ describe("secrets apply", () => { }); it("applies talk provider target types", async () => { - await fs.writeFile( + await writeJsonFile( fixture.configPath, - `${JSON.stringify( - { - talk: { - providers: { - [TALK_TEST_PROVIDER_ID]: { - apiKey: "sk-talk-plaintext", // pragma: allowlist secret - }, - }, - }, - }, - null, - 2, - )}\n`, - "utf8", + buildTalkTestProviderConfig("sk-talk-plaintext"), // pragma: allowlist secret ); const plan: SecretsApplyPlan = {