Skip to content

Commit 194c26b

Browse files
committed
fix: migrate shipped plugin install config records
1 parent 14e2760 commit 194c26b

10 files changed

Lines changed: 326 additions & 17 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ Docs: https://docs.openclaw.ai
203203
OpenClaw-owned package manifest so Linux updates cannot accidentally write to
204204
a parent `$HOME/node_modules` tree. Fixes #71730.
205205
- Plugins/install: pass onboarding plugin config into plugin index writes so local plugin installs outside default discovery roots keep their install records. Thanks @shakkernerd.
206+
- Plugins/install: migrate shipped `plugins.installs` config records into the plugin index while stripping them from runtime config and future writes. Thanks @shakkernerd.
206207
- Plugins/security: keep plugin audit JSON check ids stable while reporting plugin index install-record findings with updated wording. Thanks @shakkernerd.
207208
- CLI/config: reject direct `plugins.installs` edits with guidance to use `openclaw plugins install`, `openclaw plugins update`, or `openclaw plugins uninstall` instead. Thanks @shakkernerd.
208209
- Live tests/voice: accept common STT variants for OpenClaw and ElevenLabs

src/commands/doctor/shared/plugin-registry-migration.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,63 @@ describe("plugin registry install migration", () => {
238238
});
239239
});
240240

241+
it("seeds first-run install records from shipped plugins.installs config", async () => {
242+
const stateDir = makeTempDir();
243+
const pluginDir = path.join(stateDir, "plugins", "demo");
244+
fs.mkdirSync(pluginDir, { recursive: true });
245+
246+
await expect(
247+
migratePluginRegistryForInstall({
248+
stateDir,
249+
candidates: [createCandidate(pluginDir)],
250+
readConfig: async () => ({
251+
plugins: {
252+
entries: {
253+
demo: {
254+
enabled: true,
255+
},
256+
},
257+
installs: {
258+
demo: {
259+
source: "npm",
260+
spec: "demo@1.0.0",
261+
installPath: pluginDir,
262+
},
263+
},
264+
},
265+
}),
266+
env: hermeticEnv(),
267+
}),
268+
).resolves.toMatchObject({
269+
status: "migrated",
270+
current: {
271+
plugins: [
272+
expect.objectContaining({
273+
pluginId: "demo",
274+
installRecord: {
275+
source: "npm",
276+
spec: "demo@1.0.0",
277+
installPath: pluginDir,
278+
},
279+
}),
280+
],
281+
},
282+
});
283+
284+
await expect(readPersistedInstalledPluginIndex({ stateDir })).resolves.toMatchObject({
285+
plugins: [
286+
expect.objectContaining({
287+
pluginId: "demo",
288+
installRecord: {
289+
source: "npm",
290+
spec: "demo@1.0.0",
291+
installPath: pluginDir,
292+
},
293+
}),
294+
],
295+
});
296+
});
297+
241298
it("marks force migration env as deprecated break-glass", () => {
242299
expect(
243300
preflightPluginRegistryInstallMigration({

src/commands/doctor/shared/plugin-registry-migration.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import fs from "node:fs";
22
import { normalizeProviderId } from "../../../agents/provider-id.js";
3+
import {
4+
extractShippedPluginInstallConfigRecords,
5+
stripShippedPluginInstallConfigRecords,
6+
} from "../../../config/plugin-install-config-migration.js";
37
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
48
import { loadInstalledPluginIndexInstallRecords } from "../../../plugins/installed-plugin-index-records.js";
59
import {
@@ -252,8 +256,12 @@ export async function migratePluginRegistryForInstall(
252256
return { status: "dry-run", migrated: false, preflight };
253257
}
254258

255-
const config = await readMigrationConfig(params);
256-
const installRecords = await loadInstalledPluginIndexInstallRecords(params);
259+
const rawConfig = await readMigrationConfig(params);
260+
const config = stripShippedPluginInstallConfigRecords(rawConfig) as OpenClawConfig;
261+
const installRecords = {
262+
...extractShippedPluginInstallConfigRecords(rawConfig),
263+
...(await loadInstalledPluginIndexInstallRecords(params)),
264+
};
257265
const migrationParams = {
258266
...params,
259267
config,

src/config/io.ts

Lines changed: 64 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ import {
1818
collectRelevantDoctorPluginIds,
1919
listPluginDoctorLegacyConfigRules,
2020
} from "../plugins/doctor-contract-registry.js";
21+
import {
22+
loadInstalledPluginIndexInstallRecordsSync,
23+
writePersistedInstalledPluginIndexInstallRecordsSync,
24+
} from "../plugins/installed-plugin-index-records.js";
2125
import { sanitizeTerminalText } from "../terminal/safe-text.js";
2226
import { isRecord } from "../utils.js";
2327
import { VERSION } from "../version.js";
@@ -60,6 +64,7 @@ import {
6064
projectSourceOntoRuntimeShape,
6165
restoreEnvRefsFromMap,
6266
resolvePersistCandidateForWrite,
67+
resolveManagedUnsetPathsForWrite,
6368
resolveWriteEnvSnapshotForPath,
6469
} from "./io.write-prepare.js";
6570
import { findLegacyConfigIssues } from "./legacy.js";
@@ -70,6 +75,10 @@ import {
7075
} from "./materialize.js";
7176
import { applyMergePatch } from "./merge-patch.js";
7277
import { resolveConfigPath, resolveStateDir } from "./paths.js";
78+
import {
79+
extractShippedPluginInstallConfigRecords,
80+
stripShippedPluginInstallConfigRecords,
81+
} from "./plugin-install-config-migration.js";
7382
import { applyConfigOverrides } from "./runtime-overrides.js";
7483
import {
7584
clearRuntimeConfigSnapshot as clearRuntimeConfigSnapshotState,
@@ -1009,9 +1018,12 @@ async function recoverConfigFromJsonRootSuffixWithDeps(params: {
10091018
readResolution.resolvedConfigRaw,
10101019
suffixRecovery.parsed,
10111020
);
1012-
const validated = validateConfigObjectWithPlugins(legacyResolution.effectiveConfigRaw, {
1013-
env: params.deps.env,
1014-
});
1021+
const validated = validateConfigObjectWithPlugins(
1022+
stripShippedPluginInstallConfigRecords(legacyResolution.effectiveConfigRaw),
1023+
{
1024+
env: params.deps.env,
1025+
},
1026+
);
10151027
if (!validated.ok) {
10161028
return false;
10171029
}
@@ -1198,6 +1210,41 @@ export function createConfigIO(
11981210
return applyConfigOverrides(cfgWithOwnerDisplaySecret);
11991211
}
12001212

1213+
function migrateAndStripShippedPluginInstallConfigRecords(configRaw: unknown): unknown {
1214+
const installRecords = extractShippedPluginInstallConfigRecords(configRaw);
1215+
const stripped = stripShippedPluginInstallConfigRecords(configRaw);
1216+
if (Object.keys(installRecords).length === 0) {
1217+
return stripped;
1218+
}
1219+
1220+
try {
1221+
const stateDir = resolveStateDir(deps.env, deps.homedir);
1222+
const existingRecords = loadInstalledPluginIndexInstallRecordsSync({
1223+
env: deps.env,
1224+
stateDir,
1225+
});
1226+
const nextRecords = {
1227+
...installRecords,
1228+
...existingRecords,
1229+
};
1230+
if (Object.keys(installRecords).some((pluginId) => !(pluginId in existingRecords))) {
1231+
writePersistedInstalledPluginIndexInstallRecordsSync(nextRecords, {
1232+
config: coerceConfig(stripped),
1233+
env: deps.env,
1234+
stateDir,
1235+
});
1236+
}
1237+
} catch (err) {
1238+
deps.logger.warn(
1239+
`Config (${configPath}): could not migrate shipped plugins.installs records into the plugin index: ${formatErrorMessage(
1240+
err,
1241+
)}`,
1242+
);
1243+
}
1244+
1245+
return stripped;
1246+
}
1247+
12011248
function loadConfig(): OpenClawConfig {
12021249
try {
12031250
maybeLoadDotEnvForConfig(deps.env);
@@ -1230,7 +1277,9 @@ export function createConfigIO(
12301277
);
12311278
const resolvedConfig = readResolution.resolvedConfigRaw;
12321279
const legacyResolution = resolveLegacyConfigForRead(resolvedConfig, effectiveParsed);
1233-
const effectiveConfigRaw = legacyResolution.effectiveConfigRaw;
1280+
const effectiveConfigRaw = migrateAndStripShippedPluginInstallConfigRecords(
1281+
legacyResolution.effectiveConfigRaw,
1282+
);
12341283
for (const w of readResolution.envWarnings) {
12351284
deps.logger.warn(
12361285
`Config (${configPath}): missing env var "${w.varName}" at ${w.configPath} - feature using this value will be unavailable`,
@@ -1439,7 +1488,9 @@ export function createConfigIO(
14391488

14401489
const resolvedConfigRaw = readResolution.resolvedConfigRaw;
14411490
const legacyResolution = resolveLegacyConfigForRead(resolvedConfigRaw, effectiveParsed);
1442-
const effectiveConfigRaw = legacyResolution.effectiveConfigRaw;
1491+
const effectiveConfigRaw = migrateAndStripShippedPluginInstallConfigRecords(
1492+
legacyResolution.effectiveConfigRaw,
1493+
);
14431494
fallbackSourceConfig = coerceConfig(effectiveConfigRaw);
14441495
const validated = validateConfigObjectWithPlugins(effectiveConfigRaw, {
14451496
env: deps.env,
@@ -1562,6 +1613,7 @@ export function createConfigIO(
15621613
writeOptions: {
15631614
envSnapshotForRestore: result.envSnapshotForRestore,
15641615
expectedConfigPath: configPath,
1616+
unsetPaths: resolveManagedUnsetPathsForWrite(undefined),
15651617
},
15661618
};
15671619
}
@@ -1609,7 +1661,9 @@ export function createConfigIO(
16091661
readResolution.resolvedConfigRaw,
16101662
recovered.parsed,
16111663
);
1612-
return coerceConfig(legacyResolution.effectiveConfigRaw);
1664+
return coerceConfig(
1665+
stripShippedPluginInstallConfigRecords(legacyResolution.effectiveConfigRaw),
1666+
);
16131667
} catch {
16141668
return {};
16151669
}
@@ -1620,6 +1674,7 @@ export function createConfigIO(
16201674
options: ConfigWriteOptions = {},
16211675
): Promise<{ persistedHash: string; persistedConfig: OpenClawConfig }> {
16221676
clearConfigCache();
1677+
const unsetPaths = resolveManagedUnsetPathsForWrite(options.unsetPaths);
16231678
let persistCandidate: unknown = cfg;
16241679
const snapshot = options.baseSnapshot ?? (await readConfigFileSnapshotInternal()).snapshot;
16251680
let envRefMap: Map<string, string> | null = null;
@@ -1655,10 +1710,7 @@ export function createConfigIO(
16551710
}
16561711
}
16571712

1658-
persistCandidate = applyUnsetPathsForWrite(
1659-
persistCandidate as OpenClawConfig,
1660-
options.unsetPaths,
1661-
);
1713+
persistCandidate = applyUnsetPathsForWrite(persistCandidate as OpenClawConfig, unsetPaths);
16621714

16631715
const validated = validateConfigObjectRawWithPlugins(persistCandidate, { env: deps.env });
16641716
if (!validated.ok) {
@@ -1720,7 +1772,7 @@ export function createConfigIO(
17201772
envRefMap && changedPaths
17211773
? (restoreEnvRefsFromMap(cfgToWrite, "", envRefMap, changedPaths) as OpenClawConfig)
17221774
: cfgToWrite;
1723-
const outputConfig = applyUnsetPathsForWrite(outputConfigBase, options.unsetPaths);
1775+
const outputConfig = applyUnsetPathsForWrite(outputConfigBase, unsetPaths);
17241776
// Do NOT apply runtime defaults when writing - user config should only contain
17251777
// explicitly set values. Runtime defaults are applied when loading (issue #6070).
17261778
const stampedOutputConfig = stampConfigVersion(outputConfig);
@@ -2054,7 +2106,7 @@ export async function writeConfigFile(
20542106
expectedConfigPath: options.expectedConfigPath,
20552107
envSnapshotForRestore: options.envSnapshotForRestore,
20562108
}),
2057-
unsetPaths: options.unsetPaths,
2109+
unsetPaths: resolveManagedUnsetPathsForWrite(options.unsetPaths),
20582110
allowDestructiveWrite: options.allowDestructiveWrite,
20592111
skipRuntimeSnapshotRefresh: options.skipRuntimeSnapshotRefresh,
20602112
skipOutputLogs: options.skipOutputLogs,

src/config/io.write-config.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import fs from "node:fs/promises";
22
import path from "node:path";
33
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
4+
import { readPersistedInstalledPluginIndex } from "../plugins/installed-plugin-index-store.js";
45
import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
56
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
67
import {
@@ -99,6 +100,91 @@ describe("config io write", () => {
99100
logger: silentLogger,
100101
});
101102

103+
it("migrates shipped plugin install config records into the plugin index", async () => {
104+
await withSuiteHome(async (home) => {
105+
const configPath = path.join(home, ".openclaw", "openclaw.json");
106+
const pluginDir = path.join(home, ".openclaw", "plugins", "demo");
107+
const manifestPath = path.join(pluginDir, "openclaw.plugin.json");
108+
const source = path.join(pluginDir, "index.ts");
109+
await fs.mkdir(pluginDir, { recursive: true });
110+
await fs.writeFile(source, "export function register() {}\n", "utf-8");
111+
await fs.writeFile(
112+
manifestPath,
113+
`${JSON.stringify({ id: "demo", configSchema: { type: "object" } }, null, 2)}\n`,
114+
"utf-8",
115+
);
116+
await fs.mkdir(path.dirname(configPath), { recursive: true });
117+
await fs.writeFile(
118+
configPath,
119+
`${JSON.stringify(
120+
{
121+
plugins: {
122+
entries: { demo: { enabled: true } },
123+
installs: {
124+
demo: {
125+
source: "npm",
126+
spec: "demo@1.0.0",
127+
installPath: pluginDir,
128+
},
129+
},
130+
},
131+
},
132+
null,
133+
2,
134+
)}\n`,
135+
"utf-8",
136+
);
137+
mockLoadPluginManifestRegistry.mockReturnValue({
138+
diagnostics: [],
139+
plugins: [
140+
{
141+
id: "demo",
142+
origin: "global",
143+
channels: [],
144+
providers: [],
145+
cliBackends: [],
146+
skills: [],
147+
hooks: [],
148+
rootDir: pluginDir,
149+
source,
150+
manifestPath,
151+
configSchema: {
152+
type: "object",
153+
},
154+
},
155+
],
156+
} satisfies PluginManifestRegistry);
157+
158+
const io = createFastConfigIO(home);
159+
try {
160+
const cfg = io.loadConfig();
161+
162+
expect(cfg.plugins?.installs).toBeUndefined();
163+
await expect(
164+
readPersistedInstalledPluginIndex({
165+
stateDir: path.join(home, ".openclaw"),
166+
}),
167+
).resolves.toMatchObject({
168+
plugins: [
169+
expect.objectContaining({
170+
pluginId: "demo",
171+
installRecord: {
172+
source: "npm",
173+
spec: "demo@1.0.0",
174+
installPath: pluginDir,
175+
},
176+
}),
177+
],
178+
});
179+
} finally {
180+
mockLoadPluginManifestRegistry.mockReturnValue({
181+
diagnostics: [],
182+
plugins: [],
183+
} satisfies PluginManifestRegistry);
184+
}
185+
});
186+
});
187+
102188
const writeGatewayPortAndReadConfig = async (home: string, configPath: string) => {
103189
const io = createFastConfigIO(home);
104190

src/config/io.write-prepare.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import type { OpenClawConfig } from "./types.js";
77
const OPEN_DM_POLICY_ALLOW_FROM_RE =
88
/^(?<policyPath>[a-z0-9_.-]+)\s*=\s*"open"\s+requires\s+(?<allowPath>[a-z0-9_.-]+)(?:\s+\(or\s+[a-z0-9_.-]+\))?\s+to include "\*"$/i;
99

10+
const MANAGED_CONFIG_UNSET_PATHS = [["plugins", "installs"]] as const;
11+
1012
function cloneUnknown<T>(value: T): T {
1113
return structuredClone(value);
1214
}
@@ -337,6 +339,25 @@ export function applyUnsetPathsForWrite(
337339
return next;
338340
}
339341

342+
export function resolveManagedUnsetPathsForWrite(
343+
unsetPaths: readonly string[][] | undefined,
344+
): string[][] {
345+
const next: string[][] = [];
346+
for (const managedPath of MANAGED_CONFIG_UNSET_PATHS) {
347+
next.push(Array.from(managedPath));
348+
}
349+
for (const unsetPath of unsetPaths ?? []) {
350+
if (!Array.isArray(unsetPath) || unsetPath.length === 0) {
351+
continue;
352+
}
353+
if (next.some((existing) => isDeepStrictEqual(existing, unsetPath))) {
354+
continue;
355+
}
356+
next.push([...unsetPath]);
357+
}
358+
return next;
359+
}
360+
340361
export function collectChangedPaths(
341362
base: unknown,
342363
target: unknown,

0 commit comments

Comments
 (0)