mirror of https://github.com/openclaw/openclaw.git
refactor: split doctor runtime migrations and talk runtime tests
This commit is contained in:
parent
0ef9383487
commit
2ff29a33d0
|
|
@ -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<string, unknown>): {
|
||||
agentHeartbeat: Record<string, unknown> | null;
|
||||
channelHeartbeat: Record<string, unknown> | null;
|
||||
} {
|
||||
const agentHeartbeat: Record<string, unknown> = {};
|
||||
const channelHeartbeat: Record<string, unknown> = {};
|
||||
|
||||
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<string, unknown>;
|
||||
rootKey: "agents" | "channels";
|
||||
fieldKey: string;
|
||||
legacyValue: Record<string, unknown>;
|
||||
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<string, unknown>,
|
||||
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;
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
|
@ -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}".`);
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
|
@ -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);
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
|
@ -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<string, unknown>): {
|
||||
agentHeartbeat: Record<string, unknown> | null;
|
||||
channelHeartbeat: Record<string, unknown> | null;
|
||||
} {
|
||||
const agentHeartbeat: Record<string, unknown> = {};
|
||||
const channelHeartbeat: Record<string, unknown> = {};
|
||||
|
||||
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<string, unknown>;
|
||||
rootKey: "agents" | "channels";
|
||||
fieldKey: string;
|
||||
legacyValue: Record<string, unknown>;
|
||||
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<string, unknown>,
|
||||
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;
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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<NonNullable<TalkConfigPayload["config"]>["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<TalkConfigPayload>(ws, "talk.config", params ?? {});
|
||||
}
|
||||
|
||||
async function fetchTalkSpeak(
|
||||
ws: GatewaySocket,
|
||||
params: Record<string, unknown>,
|
||||
timeoutMs?: number,
|
||||
) {
|
||||
return rpcReq(ws, "talk.speak", params, timeoutMs);
|
||||
}
|
||||
|
||||
async function invokeTalkSpeakDirect(params: Record<string, unknown>) {
|
||||
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<T>(
|
||||
speechProviders: NonNullable<ReturnType<typeof createEmptyPluginRegistry>["speechProviders"]>,
|
||||
run: () => Promise<T>,
|
||||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>) {
|
||||
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<T>(
|
||||
speechProviders: NonNullable<ReturnType<typeof createEmptyPluginRegistry>["speechProviders"]>,
|
||||
run: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
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,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue