Skip to content

Commit bbdff39

Browse files
yetvalsteipete
andauthored
fix(onboard): preserve agents.list and bindings on rerun
Preserve existing `agents.list` and top-level `bindings` during ordinary onboarding reruns so rerunning `openclaw onboard` cannot silently wipe configured agents or routing bindings. Keep config size-drop allowances scoped to explicit reset/import/plugin-install migration flows, validate binding agent ids with normalized agent ids, and add doctor repair coverage for dangling bindings that is still best-effort around malformed agent lists. Closes #84692. Co-authored-by: yetval <[email protected]> Co-authored-by: Peter Steinberger <[email protected]>
1 parent aa0a290 commit bbdff39

11 files changed

Lines changed: 443 additions & 10 deletions

src/commands/doctor-legacy-config.migrations.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,35 @@ describe("normalizeCompatibilityConfigValues", () => {
178178
);
179179
});
180180

181+
it("removes bindings for missing configured agents", () => {
182+
const res = normalizeCompatibilityConfigValues({
183+
agents: {
184+
list: [{ id: "Team Ops" }],
185+
},
186+
bindings: [
187+
{
188+
type: "route",
189+
agentId: "team-ops",
190+
match: { channel: "discord", peer: { kind: "direct", id: "user-1" } },
191+
},
192+
{
193+
type: "route",
194+
agentId: "ghost",
195+
match: { channel: "discord", peer: { kind: "direct", id: "user-2" } },
196+
},
197+
],
198+
});
199+
200+
expect(res.config.bindings).toEqual([
201+
{
202+
type: "route",
203+
agentId: "team-ops",
204+
match: { channel: "discord", peer: { kind: "direct", id: "user-1" } },
205+
},
206+
]);
207+
expect(res.changes).toContain("Removed 1 binding that referenced missing agents.list ids.");
208+
});
209+
181210
it("does not set group visible replies without channels or when already explicit", () => {
182211
expect(
183212
normalizeCompatibilityConfigValues({

src/commands/doctor/shared/legacy-config-core-migrate.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
22
import { runPluginSetupConfigMigrations } from "../../../plugins/setup-registry.js";
3+
import { normalizeAgentId } from "../../../routing/session-key.js";
34
import { migrateLegacySecretRefEnvMarkers } from "../../../secrets/legacy-secretref-env-marker.js";
45
import { applyChannelDoctorCompatibilityMigrations } from "./channel-legacy-config-migrate.js";
56
import { normalizeBaseCompatibilityConfigValues } from "./legacy-config-compatibility-base.js";
@@ -8,6 +9,39 @@ import {
89
normalizeLegacyOpenAICodexModelsAddMetadata,
910
} from "./legacy-config-core-normalizers.js";
1011

12+
function pruneBindingsForMissingAgents(cfg: OpenClawConfig, changes: string[]): OpenClawConfig {
13+
const agents = cfg.agents?.list;
14+
const bindings = cfg.bindings;
15+
if (!Array.isArray(agents) || agents.length === 0 || !Array.isArray(bindings)) {
16+
return cfg;
17+
}
18+
19+
const validAgents = agents.filter((agent): agent is { id: string } => {
20+
return agent !== null && typeof agent === "object" && typeof agent.id === "string";
21+
});
22+
if (validAgents.length !== agents.length) {
23+
return cfg;
24+
}
25+
26+
const agentIds = new Set(validAgents.map((agent) => normalizeAgentId(agent.id)));
27+
const nextBindings = bindings.filter((binding) => {
28+
const agentId = binding && typeof binding === "object" ? binding.agentId : undefined;
29+
return typeof agentId !== "string" || agentIds.has(normalizeAgentId(agentId));
30+
});
31+
const removed = bindings.length - nextBindings.length;
32+
if (removed === 0) {
33+
return cfg;
34+
}
35+
36+
changes.push(
37+
`Removed ${removed} binding${removed === 1 ? "" : "s"} that referenced missing agents.list ids.`,
38+
);
39+
return {
40+
...cfg,
41+
...(nextBindings.length > 0 ? { bindings: nextBindings } : { bindings: undefined }),
42+
};
43+
}
44+
1145
export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): {
1246
config: OpenClawConfig;
1347
changes: string[];
@@ -35,6 +69,7 @@ export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): {
3569
}
3670
next = normalizeLegacyCommandsConfig(next, changes);
3771
next = normalizeLegacyOpenAICodexModelsAddMetadata(next, changes);
72+
next = pruneBindingsForMissingAgents(next, changes);
3873

3974
return { config: next, changes };
4075
}

src/commands/doctor/shared/legacy-config-migrate.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, it } from "vitest";
22
import { findLegacyConfigIssues } from "../../../config/legacy.js";
33
import type { OpenClawConfig } from "../../../config/types.js";
4+
import { normalizeCompatibilityConfigValues } from "./legacy-config-core-migrate.js";
45
import { LEGACY_CONFIG_MIGRATIONS } from "./legacy-config-migrations.js";
56

67
function migrateLegacyConfigForTest(raw: unknown): {
@@ -27,6 +28,40 @@ function expectMigrationChangesToIncludeFragments(changes: string[], fragments:
2728
expect(unmatchedFragments).toStrictEqual([]);
2829
}
2930

31+
describe("compatibility binding repair migrate", () => {
32+
it("prunes bindings for missing agents when agents.list is valid", () => {
33+
const res = normalizeCompatibilityConfigValues({
34+
agents: {
35+
list: [{ id: "alpha" }],
36+
},
37+
bindings: [
38+
{ agentId: "alpha", match: { channel: "discord" } },
39+
{ agentId: "ghost", match: { channel: "discord" } },
40+
],
41+
} as OpenClawConfig);
42+
43+
expect(res.config.bindings).toEqual([{ agentId: "alpha", match: { channel: "discord" } }]);
44+
expect(res.changes).toContain("Removed 1 binding that referenced missing agents.list ids.");
45+
});
46+
47+
it("leaves bindings untouched when agents.list has malformed entries", () => {
48+
const cfg = {
49+
agents: {
50+
list: [null, { id: 1 }, { id: "alpha" }],
51+
},
52+
bindings: [
53+
{ agentId: "ghost", match: { channel: "discord" } },
54+
{ agentId: "alpha", match: { channel: "discord" } },
55+
],
56+
} as unknown as OpenClawConfig;
57+
58+
const res = normalizeCompatibilityConfigValues(cfg);
59+
60+
expect(res.config.bindings).toEqual(cfg.bindings);
61+
expect(res.changes).not.toContain("Removed 1 binding that referenced missing agents.list ids.");
62+
});
63+
});
64+
3065
describe("legacy silent reply config migrate", () => {
3166
it("removes silent reply rewrite and direct-chat silent reply config", () => {
3267
const res = migrateLegacyConfigForTest({

src/commands/onboard-config.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,28 @@ describe("applyLocalSetupWorkspaceConfig", () => {
5353

5454
expect(result.tools?.profile).toBe("full");
5555
});
56+
57+
it("preserves agents.list and bindings on onboard rerun (openclaw#84692)", () => {
58+
const baseConfig: OpenClawConfig = {
59+
agents: {
60+
list: [
61+
{ id: "alpha", model: "anthropic/claude-3-5-sonnet" },
62+
{ id: "beta", model: "openai/gpt-4o" },
63+
],
64+
},
65+
bindings: [
66+
{
67+
type: "route",
68+
agentId: "alpha",
69+
match: { channel: "discord", peer: { kind: "direct", id: "user-1" } },
70+
},
71+
],
72+
} as OpenClawConfig;
73+
74+
const result = applyLocalSetupWorkspaceConfig(baseConfig, "/tmp/workspace");
75+
76+
expect(result.agents?.list).toHaveLength(2);
77+
expect(result.agents?.list?.map((a) => a.id)).toEqual(["alpha", "beta"]);
78+
expect(result.bindings).toEqual(baseConfig.bindings);
79+
});
5680
});

src/commands/onboard-non-interactive.gateway.test.ts

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,20 @@ vi.mock("../config/io.js", () => ({
9797
},
9898
}));
9999

100+
const capturedReplaceConfigFileCalls: Array<{
101+
nextConfig: OpenClawConfig;
102+
writeOptions?: { allowConfigSizeDrop?: boolean };
103+
}> = [];
104+
100105
vi.mock("../config/config.js", () => ({
101-
replaceConfigFile: async ({ nextConfig }: { nextConfig: OpenClawConfig }) => {
106+
replaceConfigFile: async ({
107+
nextConfig,
108+
writeOptions,
109+
}: {
110+
nextConfig: OpenClawConfig;
111+
writeOptions?: { allowConfigSizeDrop?: boolean };
112+
}) => {
113+
capturedReplaceConfigFileCalls.push({ nextConfig, ...(writeOptions ? { writeOptions } : {}) });
102114
testConfigStore.set(resolveTestConfigPath(), nextConfig);
103115
},
104116
resolveGatewayPort: (cfg: OpenClawConfig) => cfg.gateway?.port ?? 18789,
@@ -375,6 +387,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
375387
afterEach(() => {
376388
waitForGatewayReachableMock = undefined;
377389
testConfigStore.clear();
390+
capturedReplaceConfigFileCalls.length = 0;
378391
ensureWorkspaceAndSessionsMock.mockClear();
379392
installGatewayDaemonNonInteractiveMock.mockClear();
380393
createPreMigrationBackupMock.mockClear();
@@ -386,6 +399,62 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
386399
readLastGatewayErrorLineMock.mockClear();
387400
});
388401

402+
it("preserves existing agents.list and bindings on onboard rerun (openclaw#84692)", async () => {
403+
await withStateDir("state-preserve-agents-", async (stateDir) => {
404+
const workspace = path.join(stateDir, "openclaw");
405+
const seededAgents = [
406+
{ id: "alpha", model: "anthropic/claude-3-5-sonnet" },
407+
{ id: "beta", model: "openai/gpt-4o" },
408+
];
409+
const seededBindings = [
410+
{
411+
type: "route" as const,
412+
agentId: "alpha",
413+
match: {
414+
channel: "discord",
415+
peer: { kind: "direct" as const, id: "user-1" },
416+
},
417+
},
418+
{
419+
type: "route" as const,
420+
agentId: "beta",
421+
match: {
422+
channel: "discord",
423+
peer: { kind: "direct" as const, id: "user-2" },
424+
},
425+
},
426+
];
427+
testConfigStore.set(resolveTestConfigPath(), {
428+
agents: { list: seededAgents, defaults: { workspace } },
429+
bindings: seededBindings,
430+
gateway: { mode: "local", port: 18789, auth: { mode: "token", token: "seed_tok" } },
431+
} as OpenClawConfig);
432+
433+
await runNonInteractiveSetup(
434+
{
435+
nonInteractive: true,
436+
mode: "local",
437+
workspace,
438+
authChoice: "skip",
439+
skipSkills: true,
440+
skipHealth: true,
441+
installDaemon: false,
442+
gatewayBind: "loopback",
443+
gatewayAuth: "token",
444+
gatewayToken: "seed_tok",
445+
},
446+
runtime,
447+
);
448+
449+
const cfg = readTestConfig();
450+
expect(cfg.agents?.list?.map((a) => a.id)).toEqual(["alpha", "beta"]);
451+
expect(cfg.bindings).toEqual(seededBindings);
452+
453+
const onboardWrite = capturedReplaceConfigFileCalls.at(-1);
454+
expect(onboardWrite?.writeOptions?.allowConfigSizeDrop).toBe(false);
455+
});
456+
}, 60_000);
457+
389458
it("writes gateway token auth into config", async () => {
390459
await withStateDir("state-noninteractive-", async (stateDir) => {
391460
const token = "tok_test_123";
@@ -558,6 +627,54 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
558627
});
559628
}, 60_000);
560629

630+
it("preserves existing agents.list and bindings on remote onboard rerun (openclaw#84692)", async () => {
631+
await withStateDir("state-remote-preserve-agents-", async (_stateDir) => {
632+
const port = getPseudoPort(30_000);
633+
const token = "tok_remote_seed";
634+
const seededAgents = [
635+
{ id: "alpha", model: "anthropic/claude-3-5-sonnet" },
636+
{ id: "beta", model: "openai/gpt-4o" },
637+
];
638+
const seededBindings = [
639+
{
640+
type: "route" as const,
641+
agentId: "alpha",
642+
match: {
643+
channel: "discord",
644+
peer: { kind: "direct" as const, id: "user-1" },
645+
},
646+
},
647+
];
648+
testConfigStore.set(resolveTestConfigPath(), {
649+
agents: { list: seededAgents },
650+
bindings: seededBindings,
651+
gateway: {
652+
mode: "remote",
653+
remote: { url: `ws://127.0.0.1:${port}`, token },
654+
},
655+
} as OpenClawConfig);
656+
657+
await runNonInteractiveSetup(
658+
{
659+
nonInteractive: true,
660+
mode: "remote",
661+
remoteUrl: `ws://127.0.0.1:${port}`,
662+
remoteToken: token,
663+
authChoice: "skip",
664+
json: true,
665+
},
666+
runtime,
667+
);
668+
669+
const cfg = readTestConfig();
670+
expect(cfg.agents?.list?.map((a) => a.id)).toEqual(["alpha", "beta"]);
671+
expect(cfg.bindings).toEqual(seededBindings);
672+
673+
const remoteWrite = capturedReplaceConfigFileCalls.at(-1);
674+
expect(remoteWrite?.writeOptions?.allowConfigSizeDrop).toBe(false);
675+
});
676+
}, 60_000);
677+
561678
it("explains local health failure when no daemon was requested", async () => {
562679
await withStateDir("state-local-health-hint-", async (stateDir) => {
563680
waitForGatewayReachableMock = vi.fn(async () => ({

src/commands/onboard-non-interactive/local.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,10 +204,13 @@ export async function runNonInteractiveLocalSetup(params: {
204204
nextConfig = applyNonInteractiveSkillsConfig({ nextConfig, opts, runtime });
205205

206206
nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode });
207+
// Ordinary onboard reruns must preserve existing agents.list / bindings.
208+
// Only explicit --reset is allowed to shrink the config — see openclaw#84692.
209+
const allowConfigSizeDrop = opts.reset === true;
207210
await replaceConfigFile({
208211
nextConfig,
209212
...(baseHash !== undefined ? { baseHash } : {}),
210-
writeOptions: { allowConfigSizeDrop: true },
213+
writeOptions: { allowConfigSizeDrop },
211214
});
212215
logConfigUpdated(runtime);
213216

src/commands/onboard-non-interactive/remote.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,13 @@ export async function runNonInteractiveRemoteSetup(params: {
4141
nextConfig = applySkipBootstrapConfig(nextConfig);
4242
}
4343
nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode });
44+
// Ordinary remote onboard reruns must preserve existing agents.list /
45+
// bindings the same way the local writer does — see openclaw#84692.
46+
const allowConfigSizeDrop = opts.reset === true;
4447
await replaceConfigFile({
4548
nextConfig,
4649
...(baseHash !== undefined ? { baseHash } : {}),
47-
writeOptions: { allowConfigSizeDrop: true },
50+
writeOptions: { allowConfigSizeDrop },
4851
});
4952
logConfigUpdated(runtime);
5053

0 commit comments

Comments
 (0)