mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-24 15:41:40 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>",
|
||||
|
||||
Reference in New Issue
Block a user