Skip to content

Commit 921ffad

Browse files
committed
fix: commit pending plugin install records in config flows
1 parent 87142b5 commit 921ffad

5 files changed

Lines changed: 229 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ Docs: https://docs.openclaw.ai
113113
- Plugins/install: restore the previous plugin index records if a concurrent config write conflict interrupts install, update, or uninstall metadata commits. Thanks @shakkernerd.
114114
- Plugins/update: restore previous plugin index records if core update or channel setup hits a concurrent config write conflict after plugin metadata changes. Thanks @shakkernerd.
115115
- Plugins/onboarding: defer channel/provider plugin install records until the owning config write commits, keeping setup failures from advancing the plugin index ahead of `openclaw.json`. Thanks @shakkernerd.
116+
- Plugins/config: route configure and agent setup writes with pending plugin install records through the plugin index commit helper so provider onboarding metadata is not stripped by plain config writes. Thanks @shakkernerd.
116117
- Sessions: keep embedded runtime context out of the visible user prompt by
117118
sending it as a hidden next-turn custom message, and teach doctor to repair
118119
affected 2026.4.24 transcripts with duplicated prompt-rewrite branches.
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import type { OpenClawConfig } from "../config/types.openclaw.js";
3+
import type { PluginInstallRecord } from "../config/types.plugins.js";
4+
5+
const mocks = vi.hoisted(() => ({
6+
loadInstalledPluginIndexInstallRecords: vi.fn(),
7+
replaceConfigFile: vi.fn(),
8+
writePersistedInstalledPluginIndexInstallRecords: vi.fn(),
9+
}));
10+
11+
vi.mock("../config/config.js", () => ({
12+
replaceConfigFile: mocks.replaceConfigFile,
13+
}));
14+
15+
vi.mock("../plugins/installed-plugin-index-records.js", async (importOriginal) => {
16+
const actual =
17+
await importOriginal<typeof import("../plugins/installed-plugin-index-records.js")>();
18+
return {
19+
...actual,
20+
loadInstalledPluginIndexInstallRecords: mocks.loadInstalledPluginIndexInstallRecords,
21+
writePersistedInstalledPluginIndexInstallRecords:
22+
mocks.writePersistedInstalledPluginIndexInstallRecords,
23+
};
24+
});
25+
26+
import { commitConfigWithPendingPluginInstalls } from "./plugins-install-record-commit.js";
27+
28+
describe("commitConfigWithPendingPluginInstalls", () => {
29+
beforeEach(() => {
30+
vi.clearAllMocks();
31+
mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue({});
32+
mocks.replaceConfigFile.mockResolvedValue(undefined);
33+
mocks.writePersistedInstalledPluginIndexInstallRecords.mockResolvedValue(undefined);
34+
});
35+
36+
it("moves pending plugin install records into the plugin index before writing stripped config", async () => {
37+
const existingRecords: Record<string, PluginInstallRecord> = {
38+
existing: {
39+
source: "npm",
40+
spec: "existing@1.0.0",
41+
},
42+
};
43+
const pendingRecords: Record<string, PluginInstallRecord> = {
44+
demo: {
45+
source: "npm",
46+
spec: "demo@1.0.0",
47+
},
48+
};
49+
mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue(existingRecords);
50+
const nextConfig: OpenClawConfig = {
51+
plugins: {
52+
entries: {
53+
demo: { enabled: true },
54+
},
55+
installs: pendingRecords,
56+
},
57+
};
58+
59+
const result = await commitConfigWithPendingPluginInstalls({
60+
nextConfig,
61+
baseHash: "config-1",
62+
});
63+
64+
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({
65+
...existingRecords,
66+
...pendingRecords,
67+
});
68+
expect(mocks.replaceConfigFile).toHaveBeenCalledWith({
69+
nextConfig: {
70+
plugins: {
71+
entries: {
72+
demo: { enabled: true },
73+
},
74+
},
75+
},
76+
baseHash: "config-1",
77+
writeOptions: {
78+
unsetPaths: [["plugins", "installs"]],
79+
},
80+
});
81+
expect(result).toEqual({
82+
config: {
83+
plugins: {
84+
entries: {
85+
demo: { enabled: true },
86+
},
87+
},
88+
},
89+
installRecords: {
90+
...existingRecords,
91+
...pendingRecords,
92+
},
93+
movedInstallRecords: true,
94+
});
95+
});
96+
97+
it("rolls back plugin index writes when the config write fails", async () => {
98+
const existingRecords: Record<string, PluginInstallRecord> = {
99+
existing: {
100+
source: "npm",
101+
spec: "existing@1.0.0",
102+
},
103+
};
104+
mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue(existingRecords);
105+
mocks.replaceConfigFile.mockRejectedValue(new Error("config changed"));
106+
107+
await expect(
108+
commitConfigWithPendingPluginInstalls({
109+
nextConfig: {
110+
plugins: {
111+
installs: {
112+
demo: {
113+
source: "npm",
114+
spec: "demo@1.0.0",
115+
},
116+
},
117+
},
118+
},
119+
}),
120+
).rejects.toThrow("config changed");
121+
122+
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).toHaveBeenNthCalledWith(1, {
123+
existing: {
124+
source: "npm",
125+
spec: "existing@1.0.0",
126+
},
127+
demo: {
128+
source: "npm",
129+
spec: "demo@1.0.0",
130+
},
131+
});
132+
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).toHaveBeenNthCalledWith(
133+
2,
134+
existingRecords,
135+
);
136+
});
137+
138+
it("uses a plain config write when no pending plugin install records exist", async () => {
139+
const nextConfig: OpenClawConfig = {
140+
gateway: {
141+
mode: "local",
142+
},
143+
};
144+
145+
const result = await commitConfigWithPendingPluginInstalls({ nextConfig });
146+
147+
expect(mocks.loadInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
148+
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
149+
expect(mocks.replaceConfigFile).toHaveBeenCalledWith({
150+
nextConfig,
151+
});
152+
expect(result).toEqual({
153+
config: nextConfig,
154+
installRecords: {},
155+
movedInstallRecords: false,
156+
});
157+
});
158+
});

src/cli/plugins-install-record-commit.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,28 @@
11
import { replaceConfigFile } from "../config/config.js";
2+
import type { ConfigWriteOptions } from "../config/io.js";
23
import type { OpenClawConfig } from "../config/types.openclaw.js";
34
import type { PluginInstallRecord } from "../config/types.plugins.js";
45
import {
56
loadInstalledPluginIndexInstallRecords,
67
PLUGIN_INSTALLS_CONFIG_PATH,
8+
withoutPluginInstallRecords,
79
writePersistedInstalledPluginIndexInstallRecords,
810
} from "../plugins/installed-plugin-index-records.js";
911

12+
function mergeUnsetPaths(
13+
left?: ConfigWriteOptions["unsetPaths"],
14+
right?: ConfigWriteOptions["unsetPaths"],
15+
): ConfigWriteOptions["unsetPaths"] | undefined {
16+
const merged = [...(left ?? []), ...(right ?? [])];
17+
return merged.length > 0 ? merged : undefined;
18+
}
19+
1020
export async function commitPluginInstallRecordsWithConfig(params: {
1121
previousInstallRecords?: Record<string, PluginInstallRecord>;
1222
nextInstallRecords: Record<string, PluginInstallRecord>;
1323
nextConfig: OpenClawConfig;
1424
baseHash?: string;
25+
writeOptions?: ConfigWriteOptions;
1526
}): Promise<void> {
1627
const previousInstallRecords =
1728
params.previousInstallRecords ?? (await loadInstalledPluginIndexInstallRecords());
@@ -20,7 +31,12 @@ export async function commitPluginInstallRecordsWithConfig(params: {
2031
await replaceConfigFile({
2132
nextConfig: params.nextConfig,
2233
...(params.baseHash !== undefined ? { baseHash: params.baseHash } : {}),
23-
writeOptions: { unsetPaths: [Array.from(PLUGIN_INSTALLS_CONFIG_PATH)] },
34+
writeOptions: {
35+
...params.writeOptions,
36+
unsetPaths: mergeUnsetPaths(params.writeOptions?.unsetPaths, [
37+
Array.from(PLUGIN_INSTALLS_CONFIG_PATH),
38+
]),
39+
},
2440
});
2541
} catch (error) {
2642
try {
@@ -34,3 +50,46 @@ export async function commitPluginInstallRecordsWithConfig(params: {
3450
throw error;
3551
}
3652
}
53+
54+
export async function commitConfigWithPendingPluginInstalls(params: {
55+
nextConfig: OpenClawConfig;
56+
baseHash?: string;
57+
writeOptions?: ConfigWriteOptions;
58+
}): Promise<{
59+
config: OpenClawConfig;
60+
installRecords: Record<string, PluginInstallRecord>;
61+
movedInstallRecords: boolean;
62+
}> {
63+
const pendingInstallRecords = params.nextConfig.plugins?.installs ?? {};
64+
if (Object.keys(pendingInstallRecords).length === 0) {
65+
await replaceConfigFile({
66+
nextConfig: params.nextConfig,
67+
...(params.baseHash !== undefined ? { baseHash: params.baseHash } : {}),
68+
...(params.writeOptions ? { writeOptions: params.writeOptions } : {}),
69+
});
70+
return {
71+
config: params.nextConfig,
72+
installRecords: {},
73+
movedInstallRecords: false,
74+
};
75+
}
76+
77+
const previousInstallRecords = await loadInstalledPluginIndexInstallRecords();
78+
const nextInstallRecords = {
79+
...previousInstallRecords,
80+
...pendingInstallRecords,
81+
};
82+
const strippedConfig = withoutPluginInstallRecords(params.nextConfig);
83+
await commitPluginInstallRecordsWithConfig({
84+
previousInstallRecords,
85+
nextInstallRecords,
86+
nextConfig: strippedConfig,
87+
...(params.baseHash !== undefined ? { baseHash: params.baseHash } : {}),
88+
...(params.writeOptions ? { writeOptions: params.writeOptions } : {}),
89+
});
90+
return {
91+
config: strippedConfig,
92+
installRecords: nextInstallRecords,
93+
movedInstallRecords: true,
94+
};
95+
}

src/commands/agents.commands.add.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
} from "../agents/agent-scope.js";
88
import { ensureAuthProfileStore } from "../agents/auth-profiles.js";
99
import { resolveAuthStorePath } from "../agents/auth-profiles/paths.js";
10-
import { replaceConfigFile } from "../config/config.js";
10+
import { commitConfigWithPendingPluginInstalls } from "../cli/plugins-install-record-commit.js";
1111
import { logConfigUpdated } from "../config/logging.js";
1212
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js";
1313
import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js";
@@ -133,7 +133,7 @@ export async function agentsAddCommand(
133133
? applyAgentBindings(nextConfig, bindingParse.bindings)
134134
: { config: nextConfig, added: [], updated: [], skipped: [], conflicts: [] };
135135

136-
await replaceConfigFile({
136+
await commitConfigWithPendingPluginInstalls({
137137
nextConfig: bindingResult.config,
138138
...(baseHash !== undefined ? { baseHash } : {}),
139139
});
@@ -360,10 +360,11 @@ export async function agentsAddCommand(
360360
}
361361
}
362362

363-
await replaceConfigFile({
363+
const committed = await commitConfigWithPendingPluginInstalls({
364364
nextConfig,
365365
...(baseHash !== undefined ? { baseHash } : {}),
366366
});
367+
nextConfig = committed.config;
367368
logConfigUpdated(runtime);
368369
await ensureWorkspaceAndSessions(workspaceDir, runtime, {
369370
skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap),

src/commands/configure.wizard.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import nodePath from "node:path";
33
import { isDeepStrictEqual } from "node:util";
44
import { describeCodexNativeWebSearch } from "../agents/codex-native-web-search.shared.js";
55
import { formatCliCommand } from "../cli/command-format.js";
6-
import { readConfigFileSnapshot, replaceConfigFile, resolveGatewayPort } from "../config/config.js";
6+
import { commitConfigWithPendingPluginInstalls } from "../cli/plugins-install-record-commit.js";
7+
import { readConfigFileSnapshot, resolveGatewayPort } from "../config/config.js";
78
import { logConfigUpdated } from "../config/logging.js";
89
import { ConfigMutationConflictError } from "../config/mutate.js";
910
import type { OpenClawConfig } from "../config/types.openclaw.js";
@@ -457,10 +458,11 @@ export async function runConfigureWizard(
457458
command: opts.command,
458459
mode,
459460
});
460-
await replaceConfigFile({
461+
const committed = await commitConfigWithPendingPluginInstalls({
461462
nextConfig: remoteConfig,
462463
...(currentBaseHash !== undefined ? { baseHash: currentBaseHash } : {}),
463464
});
465+
remoteConfig = committed.config;
464466
currentBaseHash = undefined;
465467
logConfigUpdated(runtime);
466468
outro("Remote gateway configured.");
@@ -496,10 +498,11 @@ export async function runConfigureWizard(
496498
const maxRetries = 3;
497499
for (let attempt = 0; attempt < maxRetries; attempt++) {
498500
try {
499-
await replaceConfigFile({
501+
const committed = await commitConfigWithPendingPluginInstalls({
500502
nextConfig,
501503
...(currentBaseHash !== undefined ? { baseHash: currentBaseHash } : {}),
502504
});
505+
nextConfig = committed.config;
503506

504507
// After successful write, re-read the snapshot to get the new hash
505508
const freshSnapshot = await readConfigFileSnapshot();

0 commit comments

Comments
 (0)