fix(config): migrate legacy sandbox perSession alias (#60346)

* fix(config): migrate legacy sandbox perSession alias

* fix(config): preserve invalid sandbox persession values
This commit is contained in:
Vincent Koc
2026-04-03 23:55:47 +09:00
committed by GitHub
parent b6dd7ac232
commit 35cf7d0340
4 changed files with 236 additions and 0 deletions

View File

@@ -1222,6 +1222,42 @@ describe("doctor config flow", () => {
}
});
it("warns clearly about legacy sandbox perSession config and points to doctor --fix", async () => {
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
try {
await runDoctorConfigWithInput({
config: {
agents: {
defaults: {
sandbox: {
perSession: true,
},
},
},
},
run: loadAndMaybeMigrateDoctorConfig,
});
expect(
noteSpy.mock.calls.some(
([message, title]) =>
title === "Legacy config keys detected" &&
String(message).includes("agents.defaults.sandbox:") &&
String(message).includes("agents.defaults.sandbox.perSession is legacy"),
),
).toBe(true);
expect(
noteSpy.mock.calls.some(
([message, title]) =>
title === "Doctor" &&
String(message).includes('Run "openclaw doctor --fix" to migrate legacy config keys.'),
),
).toBe(true);
} finally {
noteSpy.mockRestore();
}
});
it("migrates top-level heartbeat visibility into channels.defaults.heartbeat on repair", async () => {
const result = await runDoctorConfigWithInput({
repair: true,

View File

@@ -555,6 +555,42 @@ describe("config strict validation", () => {
});
});
it("accepts legacy sandbox perSession via auto-migration and reports legacyIssues", async () => {
await withTempHome(async (home) => {
await writeOpenClawConfig(home, {
agents: {
defaults: {
sandbox: {
perSession: true,
},
},
list: [
{
id: "pi",
sandbox: {
perSession: false,
},
},
],
},
});
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "agents.defaults.sandbox")).toBe(
true,
);
expect(snap.legacyIssues.some((issue) => issue.path === "agents.list")).toBe(true);
expect(snap.sourceConfig.agents?.defaults?.sandbox).toEqual({
scope: "session",
});
expect(snap.sourceConfig.agents?.list?.[0]?.sandbox).toEqual({
scope: "shared",
});
});
});
it("accepts legacy plugins.entries.*.config.tts provider keys via auto-migration", async () => {
await withTempHome(async (home) => {
await writeOpenClawConfig(home, {

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { migrateLegacyConfig } from "./legacy-migrate.js";
import { validateConfigObjectWithPlugins } from "./validation.js";
describe("legacy migrate audio transcription", () => {
it("does not rewrite removed routing.transcribeAudio migrations", () => {
@@ -400,6 +401,87 @@ describe("legacy migrate talk provider shape", () => {
});
});
describe("legacy migrate sandbox scope aliases", () => {
it("moves agents.defaults.sandbox.perSession into scope", () => {
const res = migrateLegacyConfig({
agents: {
defaults: {
sandbox: {
perSession: true,
},
},
},
});
expect(res.changes).toContain(
"Moved agents.defaults.sandbox.perSession → agents.defaults.sandbox.scope (session).",
);
expect(res.config?.agents?.defaults?.sandbox).toEqual({
scope: "session",
});
});
it("moves agents.list[].sandbox.perSession into scope", () => {
const res = migrateLegacyConfig({
agents: {
list: [
{
id: "pi",
sandbox: {
perSession: false,
},
},
],
},
});
expect(res.changes).toContain(
"Moved agents.list.0.sandbox.perSession → agents.list.0.sandbox.scope (shared).",
);
expect(res.config?.agents?.list?.[0]?.sandbox).toEqual({
scope: "shared",
});
});
it("drops legacy sandbox perSession when scope is already set", () => {
const res = migrateLegacyConfig({
agents: {
defaults: {
sandbox: {
scope: "agent",
perSession: true,
},
},
},
});
expect(res.changes).toContain(
"Removed agents.defaults.sandbox.perSession (agents.defaults.sandbox.scope already set).",
);
expect(res.config?.agents?.defaults?.sandbox).toEqual({
scope: "agent",
});
});
it("does not migrate invalid sandbox perSession values", () => {
const raw = {
agents: {
defaults: {
sandbox: {
perSession: "yes",
},
},
},
};
const res = migrateLegacyConfig(raw);
expect(res.changes).toEqual([]);
expect(res.config).toBeNull();
expect(validateConfigObjectWithPlugins(raw).ok).toBe(false);
});
});
describe("legacy migrate x_search auth", () => {
it("moves only legacy x_search auth into plugin-owned xai config", () => {
const res = migrateLegacyConfig({

View File

@@ -45,6 +45,10 @@ const LEGACY_TALK_FIELD_KEYS = [
"apiKey",
] as const;
function sandboxScopeFromPerSession(perSession: boolean): "session" | "shared" {
return perSession ? "session" : "shared";
}
function isLegacyGatewayBindHostAlias(value: unknown): boolean {
if (typeof value !== "string") {
return false;
@@ -151,6 +155,18 @@ function hasLegacyTalkFields(value: unknown): boolean {
return LEGACY_TALK_FIELD_KEYS.some((key) => Object.prototype.hasOwnProperty.call(talk, key));
}
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 resolveTalkMigrationTargetProviderId(talk: Record<string, unknown>): string | null {
const explicitProvider =
typeof talk.provider === "string" && talk.provider.trim() ? talk.provider.trim() : null;
@@ -380,7 +396,73 @@ const TALK_RULE: LegacyConfigRule = {
match: (value) => hasLegacyTalkFields(value),
};
const LEGACY_SANDBOX_SCOPE_RULES: LegacyConfigRule[] = [
{
path: ["agents", "defaults", "sandbox"],
message:
"agents.defaults.sandbox.perSession is legacy; use agents.defaults.sandbox.scope instead (auto-migrated on load).",
match: (value) => hasLegacySandboxPerSession(value),
},
{
path: ["agents", "list"],
message:
"agents.list[].sandbox.perSession is legacy; use agents.list[].sandbox.scope instead (auto-migrated on load).",
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: "talk.legacy-fields->talk.providers",
describe: "Move legacy Talk flat fields into talk.providers.<provider>",