Skip to content

Commit 00ca654

Browse files
authored
fix(plugins): persist resolved npm install specs
Preserve npm install selectors while recording resolved npm provenance for plugin and hook install/update records. Active `record.spec` stays the requested selector unless explicitly pinned, while resolved npm fields remain available for audit and diagnostics. Adds focused coverage for hook-pack npm fallback provenance after the maintainer review found that path worth pinning down. Co-authored-by: Phil <99397913+GitHoubi@users.noreply.github.com>
1 parent 8201e85 commit 00ca654

6 files changed

Lines changed: 159 additions & 12 deletions

File tree

src/cli/npm-resolution.test.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const CLI_STATE_ROOT = "/tmp/openclaw";
1313
const ALPHA_INSTALL_PATH = installedPluginRoot(CLI_STATE_ROOT, "alpha");
1414

1515
describe("npm-resolution helpers", () => {
16-
it("keeps original spec when pin is disabled", () => {
16+
it("keeps the requested selector when pin is disabled", () => {
1717
const result = resolvePinnedNpmSpec({
1818
rawSpec: "@openclaw/plugin-alpha@latest",
1919
pin: false,
@@ -24,6 +24,16 @@ describe("npm-resolution helpers", () => {
2424
});
2525
});
2626

27+
it("keeps original spec when resolution is missing and pin is disabled", () => {
28+
const result = resolvePinnedNpmSpec({
29+
rawSpec: "@openclaw/plugin-alpha@latest",
30+
pin: false,
31+
});
32+
expect(result).toEqual({
33+
recordSpec: "@openclaw/plugin-alpha@latest",
34+
});
35+
});
36+
2737
it("warns when pin is enabled but resolved spec is missing", () => {
2838
const result = resolvePinnedNpmSpec({
2939
rawSpec: "@openclaw/plugin-alpha@latest",
@@ -70,7 +80,7 @@ describe("npm-resolution helpers", () => {
7080
it("builds common npm install record fields", () => {
7181
expect(
7282
buildNpmInstallRecordFields({
73-
spec: "@openclaw/plugin-alpha@1.2.3",
83+
spec: "@openclaw/plugin-alpha@latest",
7484
installPath: ALPHA_INSTALL_PATH,
7585
version: "1.2.3",
7686
resolution: {
@@ -82,7 +92,7 @@ describe("npm-resolution helpers", () => {
8292
}),
8393
).toEqual({
8494
source: "npm",
85-
spec: "@openclaw/plugin-alpha@1.2.3",
95+
spec: "@openclaw/plugin-alpha@latest",
8696
installPath: ALPHA_INSTALL_PATH,
8797
version: "1.2.3",
8898
resolvedName: "@openclaw/plugin-alpha",
@@ -171,4 +181,24 @@ describe("npm-resolution helpers", () => {
171181
"[warn] Could not resolve exact npm version for --pin; storing original npm spec.",
172182
]);
173183
});
184+
185+
it("keeps install record selector for CLI unless --pin is requested", () => {
186+
const logs: string[] = [];
187+
const record = resolvePinnedNpmInstallRecordForCli(
188+
"@openclaw/plugin-alpha",
189+
false,
190+
ALPHA_INSTALL_PATH,
191+
"1.2.3",
192+
{
193+
name: "@openclaw/plugin-alpha",
194+
version: "1.2.3",
195+
resolvedSpec: "@openclaw/plugin-alpha@1.2.3",
196+
},
197+
(message) => logs.push(message),
198+
(message) => `[warn] ${message}`,
199+
);
200+
201+
expect(record.spec).toBe("@openclaw/plugin-alpha");
202+
expect(logs).toEqual([]);
203+
});
174204
});

src/cli/plugins-cli.install.test.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,8 @@ function primeHookPackNpmFallback() {
255255
...createHookPackInstallResult("/tmp/hooks/demo-hooks"),
256256
npmResolution: {
257257
name: "@acme/demo-hooks",
258-
spec: "@acme/demo-hooks@1.2.3",
258+
version: "1.2.3",
259+
resolvedSpec: "@acme/demo-hooks@1.2.3",
259260
integrity: "sha256-demo",
260261
},
261262
});
@@ -932,6 +933,36 @@ describe("plugins cli install", () => {
932933
expect(writeConfigFile).toHaveBeenCalledWith(enabledCfg);
933934
});
934935

936+
it("stores npm resolution metadata without changing the active plugin install selector", async () => {
937+
const cfg = createEmptyPluginConfig();
938+
const enabledCfg = createEnabledPluginConfig("demo");
939+
loadConfig.mockReturnValue(cfg);
940+
installPluginFromNpmSpec.mockResolvedValue({
941+
ok: true,
942+
pluginId: "demo",
943+
targetDir: cliInstallPath("demo"),
944+
version: "1.2.3",
945+
npmResolution: {
946+
name: "demo",
947+
version: "1.2.3",
948+
resolvedSpec: "demo@1.2.3",
949+
integrity: "sha512-demo",
950+
},
951+
});
952+
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
953+
applyExclusiveSlotSelection.mockReturnValue({
954+
config: enabledCfg,
955+
warnings: [],
956+
});
957+
958+
await runPluginsCommand(["plugins", "install", "demo"]);
959+
960+
const record = persistedInstallRecord("demo");
961+
expect(record.spec).toBe("demo");
962+
expect(record.resolvedSpec).toBe("demo@1.2.3");
963+
expect(record.integrity).toBe("sha512-demo");
964+
});
965+
935966
it("passes bare npm selectors through npm without ClawHub lookup", async () => {
936967
const cfg = createEmptyPluginConfig();
937968
const enabledCfg = createEnabledPluginConfig("demo");
@@ -1826,7 +1857,8 @@ describe("plugins cli install", () => {
18261857
version: "1.2.3",
18271858
npmResolution: {
18281859
name: "@acme/demo-hooks",
1829-
spec: "@acme/demo-hooks@1.2.3",
1860+
version: "1.2.3",
1861+
resolvedSpec: "@acme/demo-hooks@1.2.3",
18301862
integrity: "sha256-demo",
18311863
},
18321864
});
@@ -1865,8 +1897,13 @@ describe("plugins cli install", () => {
18651897
await runPluginsCommand(["plugins", "install", "@acme/demo-hooks"]);
18661898

18671899
expect(hookNpmInstallCall().spec).toBe("@acme/demo-hooks");
1868-
expect(recordHookInstallCall().hookId).toBe("demo-hooks");
1869-
expect(recordHookInstallCall().hooks).toEqual(["command-audit"]);
1900+
const record = recordHookInstallCall();
1901+
expect(record.hookId).toBe("demo-hooks");
1902+
expect(record.spec).toBe("@acme/demo-hooks");
1903+
expect(record.resolvedVersion).toBe("1.2.3");
1904+
expect(record.resolvedSpec).toBe("@acme/demo-hooks@1.2.3");
1905+
expect(record.integrity).toBe("sha256-demo");
1906+
expect(record.hooks).toEqual(["command-audit"]);
18701907
expect(writeConfigFile).toHaveBeenCalledWith(installedCfg);
18711908
expect(runtimeLogsContain("Installed hook pack: demo-hooks")).toBe(true);
18721909
});

src/hooks/update.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,4 +95,46 @@ describe("updateNpmInstalledHookPacks", () => {
9595
},
9696
]);
9797
});
98+
99+
it("preserves hook pack update selector and records npm resolution metadata after update", async () => {
100+
installHooksFromNpmSpecMock.mockResolvedValue({
101+
ok: true,
102+
hookPackId: "demo-hooks",
103+
hooks: ["demo"],
104+
targetDir: "/tmp/hooks/demo-hooks",
105+
version: "1.2.3",
106+
npmResolution: {
107+
name: "@openclaw/demo-hooks",
108+
version: "1.2.3",
109+
resolvedSpec: "@openclaw/demo-hooks@1.2.3",
110+
integrity: "sha512-new",
111+
shasum: "abc123",
112+
resolvedAt: "2026-05-11T20:00:00.000Z",
113+
},
114+
});
115+
116+
const result = await updateNpmInstalledHookPacks({
117+
config: createHookInstallConfig({
118+
hookId: "demo-hooks",
119+
spec: "@openclaw/demo-hooks",
120+
}),
121+
hookIds: ["demo-hooks"],
122+
});
123+
124+
expect(result.changed).toBe(true);
125+
expect(result.config.hooks?.internal?.installs?.["demo-hooks"]).toEqual({
126+
source: "npm",
127+
spec: "@openclaw/demo-hooks",
128+
installPath: "/tmp/hooks/demo-hooks",
129+
version: "1.2.3",
130+
resolvedName: "@openclaw/demo-hooks",
131+
resolvedVersion: "1.2.3",
132+
resolvedSpec: "@openclaw/demo-hooks@1.2.3",
133+
integrity: "sha512-new",
134+
shasum: "abc123",
135+
resolvedAt: "2026-05-11T20:00:00.000Z",
136+
hooks: ["demo"],
137+
installedAt: expect.any(String),
138+
});
139+
});
98140
});

src/hooks/update.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { OpenClawConfig } from "../config/types.openclaw.js";
2+
import { buildNpmResolutionFields } from "../infra/install-source-utils.js";
23
import {
34
expectedIntegrityForUpdate,
45
readInstalledPackageVersion,
@@ -175,9 +176,7 @@ export async function updateNpmInstalledHookPacks(params: {
175176
spec: effectiveSpec,
176177
installPath: result.targetDir,
177178
version: nextVersion,
178-
resolvedName: result.npmResolution?.name,
179-
resolvedSpec: result.npmResolution?.resolvedSpec,
180-
integrity: result.npmResolution?.integrity,
179+
...buildNpmResolutionFields(result.npmResolution),
181180
hooks: result.hooks,
182181
});
183182
changed = true;

src/plugins/update.test.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1158,6 +1158,7 @@ describe("updateNpmInstalledPlugins", () => {
11581158
});
11591159
expectRecordFields(result.config.plugins?.installs?.["lossless-claw"], {
11601160
source: "npm",
1161+
spec: "@martian-engineering/lossless-claw",
11611162
resolvedName: "@martian-engineering/lossless-claw",
11621163
resolvedVersion: "0.9.0",
11631164
resolvedSpec: "@martian-engineering/lossless-claw@0.9.0",
@@ -1932,6 +1933,7 @@ describe("updateNpmInstalledPlugins", () => {
19321933
"openclaw-codex-app-server": "openclaw-codex-app-server@beta",
19331934
},
19341935
expectedSpec: "openclaw-codex-app-server@beta",
1936+
expectedRecordSpec: "openclaw-codex-app-server@beta",
19351937
expectedVersion: "0.2.0-beta.4",
19361938
expectedResolvedSpec: "openclaw-codex-app-server@0.2.0-beta.4",
19371939
},
@@ -1942,6 +1944,7 @@ describe("updateNpmInstalledPlugins", () => {
19421944
config,
19431945
specOverrides,
19441946
expectedSpec,
1947+
expectedRecordSpec,
19451948
expectedVersion,
19461949
expectedResolvedSpec,
19471950
}) => {
@@ -1959,14 +1962,14 @@ describe("updateNpmInstalledPlugins", () => {
19591962
});
19601963
expectCodexAppServerInstallState({
19611964
result,
1962-
spec: expectedSpec,
1965+
spec: expectedRecordSpec ?? expectedSpec,
19631966
version: expectedVersion,
19641967
...(expectedResolvedSpec ? { resolvedSpec: expectedResolvedSpec } : {}),
19651968
});
19661969
},
19671970
);
19681971

1969-
it("tries npm beta for default npm specs on beta channel without persisting the beta tag", async () => {
1972+
it("tries npm beta for default npm specs on beta channel and preserves the default selector", async () => {
19701973
installPluginFromNpmSpecMock.mockResolvedValue(
19711974
createSuccessfulNpmUpdateResult({
19721975
pluginId: "openclaw-codex-app-server",

src/security/audit-plugins-trust.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,42 @@ describe("security audit install metadata findings", () => {
295295
"hooks.installs_missing_integrity",
296296
],
297297
},
298+
{
299+
name: "still warns when active npm specs are unpinned even with resolved metadata",
300+
run: async () => {
301+
const stateDir = await makeTmpDir("unpinned-active-spec-resolved-plugin-index");
302+
await writePluginIndexInstallRecords(stateDir, {
303+
"voice-call": {
304+
source: "npm",
305+
spec: "@openclaw/voice-call",
306+
resolvedSpec: "@openclaw/voice-call@1.2.3",
307+
integrity: "sha512-plugin",
308+
},
309+
});
310+
return runInstallMetadataAudit(
311+
{
312+
hooks: {
313+
internal: {
314+
installs: {
315+
"test-hooks": {
316+
source: "npm",
317+
spec: "@openclaw/test-hooks",
318+
resolvedSpec: "@openclaw/test-hooks@1.2.3",
319+
integrity: "sha512-hook",
320+
},
321+
},
322+
},
323+
},
324+
},
325+
stateDir,
326+
);
327+
},
328+
expectedPresent: [
329+
"plugins.installs_unpinned_npm_specs",
330+
"hooks.installs_unpinned_npm_specs",
331+
],
332+
expectedAbsent: ["plugins.installs_missing_integrity", "hooks.installs_missing_integrity"],
333+
},
298334
{
299335
name: "warns when install records drift from installed package versions",
300336
run: async () => {

0 commit comments

Comments
 (0)