Skip to content

Commit 0946e37

Browse files
committed
fix(plugins): skip unchanged npm updates
1 parent bf132d6 commit 0946e37

8 files changed

Lines changed: 507 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ Docs: https://docs.openclaw.ai
3535
- Providers/Amazon Bedrock Mantle: refresh IAM-backed bearer tokens at runtime instead of baking discovery-time tokens into provider config, so long-lived Mantle sessions keep working after the initial token ages out. Thanks @wirjo.
3636
- Config/includes: write through single-file top-level includes for isolated OpenClaw-owned mutations, so `plugins install` and `plugins update` update an included `plugins.json5` file instead of flattening modular `$include` configs. Fixes #41050 and #66048.
3737
- Config/reload: plan gateway reloads from source-authored config instead of runtime-materialized snapshots, so plugin update writes no longer trigger false restarts from derived provider/plugin config paths. Fixes #68732.
38+
- Plugins/update: skip npm plugin reinstall/config rewrites when the installed version and recorded artifact identity already match the registry target, and let bare npm package names resolve back to tracked install records. Fixes #46955 and #67957.
3839
- Agents/MCP: keep `mcp.servers` and bundle MCP tools available in Pi embedded
3940
`coding` and `messaging` sessions while preserving `minimal` profile and
4041
`tools.deny: ["bundle-mcp"]` opt-out behavior. Fixes #68875 and #68818.

docs/cli/plugins.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,15 @@ or exact version. OpenClaw resolves that package name back to the tracked plugin
243243
record, updates that installed plugin, and records the new npm spec for future
244244
id-based updates.
245245

246+
Passing the npm package name without a version or tag also resolves back to the
247+
tracked plugin record. Use this when a plugin was pinned to an exact version and
248+
you want to move it back to the registry's default release line.
249+
250+
Before a live npm update, OpenClaw checks the installed package version against
251+
the npm registry metadata. If the installed version and recorded artifact
252+
identity already match the resolved target, the update is skipped without
253+
downloading, reinstalling, or rewriting `openclaw.json`.
254+
246255
When a stored integrity hash exists and the fetched artifact hash changes,
247256
OpenClaw treats that as npm artifact drift. The interactive
248257
`openclaw plugins update` command prints the expected and actual hashes and asks
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
5+
source "$ROOT_DIR/scripts/lib/docker-e2e-logs.sh"
6+
7+
IMAGE_NAME="${OPENCLAW_PLUGIN_UPDATE_E2E_IMAGE:-openclaw-plugin-update-e2e}"
8+
SKIP_BUILD="${OPENCLAW_PLUGIN_UPDATE_E2E_SKIP_BUILD:-0}"
9+
10+
if [ "$SKIP_BUILD" = "1" ]; then
11+
echo "Reusing Docker image: $IMAGE_NAME"
12+
else
13+
echo "Building Docker image..."
14+
run_logged plugin-update-build docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR"
15+
fi
16+
17+
echo "Running unchanged plugin update smoke..."
18+
docker run --rm \
19+
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
20+
-e OPENCLAW_SKIP_CHANNELS=1 \
21+
-e OPENCLAW_SKIP_PROVIDERS=1 \
22+
"$IMAGE_NAME" \
23+
bash -lc "set -euo pipefail
24+
entry=dist/index.mjs
25+
[ -f \"\$entry\" ] || entry=dist/index.js
26+
export NPM_CONFIG_REGISTRY=http://127.0.0.1:4873
27+
28+
mkdir -p \"\$HOME/.openclaw/extensions/lossless-claw\"
29+
cat > \"\$HOME/.openclaw/extensions/lossless-claw/package.json\" <<'JSON'
30+
{
31+
\"name\": \"@example/lossless-claw\",
32+
\"version\": \"0.9.0\"
33+
}
34+
JSON
35+
cat > \"\$HOME/.openclaw/openclaw.json\" <<'JSON'
36+
{
37+
\"plugins\": {
38+
\"installs\": {
39+
\"lossless-claw\": {
40+
\"source\": \"npm\",
41+
\"spec\": \"@example/[email protected]\",
42+
\"installPath\": \"~/.openclaw/extensions/lossless-claw\",
43+
\"resolvedName\": \"@example/lossless-claw\",
44+
\"resolvedVersion\": \"0.9.0\",
45+
\"resolvedSpec\": \"@example/[email protected]\",
46+
\"integrity\": \"sha512-same\",
47+
\"shasum\": \"same\"
48+
}
49+
}
50+
}
51+
}
52+
JSON
53+
54+
cat > /tmp/openclaw-e2e-registry.mjs <<'NODE'
55+
import http from 'node:http';
56+
57+
const metadata = {
58+
name: '@example/lossless-claw',
59+
'dist-tags': { latest: '0.9.0' },
60+
versions: {
61+
'0.9.0': {
62+
name: '@example/lossless-claw',
63+
version: '0.9.0',
64+
dist: {
65+
integrity: 'sha512-same',
66+
shasum: 'same',
67+
tarball: 'http://127.0.0.1:4873/@example/lossless-claw/-/lossless-claw-0.9.0.tgz'
68+
}
69+
}
70+
}
71+
};
72+
73+
const server = http.createServer((req, res) => {
74+
if (req.url === '/@example%2flossless-claw' || req.url === '/@example%2Flossless-claw') {
75+
res.writeHead(200, { 'content-type': 'application/json' });
76+
res.end(JSON.stringify(metadata));
77+
return;
78+
}
79+
res.writeHead(404, { 'content-type': 'text/plain' });
80+
res.end('not found: ' + req.url);
81+
});
82+
83+
server.listen(4873, '127.0.0.1');
84+
NODE
85+
node /tmp/openclaw-e2e-registry.mjs >/tmp/openclaw-e2e-registry.log 2>&1 &
86+
registry_pid=\$!
87+
trap 'kill \"\$registry_pid\" >/dev/null 2>&1 || true' EXIT
88+
89+
registry_ready=0
90+
for _ in \$(seq 1 50); do
91+
if node --input-type=module -e '
92+
import http from \"node:http\";
93+
const req = http.get(\"http://127.0.0.1:4873/@example%2flossless-claw\", (res) => {
94+
process.exit(res.statusCode === 200 ? 0 : 1);
95+
});
96+
req.on(\"error\", () => process.exit(1));
97+
req.setTimeout(200, () => {
98+
req.destroy();
99+
process.exit(1);
100+
});
101+
'; then
102+
registry_ready=1
103+
break
104+
fi
105+
sleep 0.1
106+
done
107+
if [ \"\$registry_ready\" -ne 1 ]; then
108+
echo \"Local npm metadata registry failed to start\"
109+
cat /tmp/openclaw-e2e-registry.log || true
110+
exit 1
111+
fi
112+
113+
before_hash=\$(node --input-type=module -e '
114+
import crypto from \"node:crypto\";
115+
import fs from \"node:fs\";
116+
import os from \"node:os\";
117+
import path from \"node:path\";
118+
const file = path.join(os.homedir(), \".openclaw\", \"openclaw.json\");
119+
process.stdout.write(crypto.createHash(\"sha256\").update(fs.readFileSync(file)).digest(\"hex\"));
120+
')
121+
122+
node \"\$entry\" plugins update @example/lossless-claw > /tmp/plugin-update-output.log 2>&1
123+
124+
after_hash=\$(node --input-type=module -e '
125+
import crypto from \"node:crypto\";
126+
import fs from \"node:fs\";
127+
import os from \"node:os\";
128+
import path from \"node:path\";
129+
const file = path.join(os.homedir(), \".openclaw\", \"openclaw.json\");
130+
process.stdout.write(crypto.createHash(\"sha256\").update(fs.readFileSync(file)).digest(\"hex\"));
131+
')
132+
133+
if [ \"\$before_hash\" != \"\$after_hash\" ]; then
134+
echo \"Config changed unexpectedly\"
135+
cat /tmp/plugin-update-output.log
136+
exit 1
137+
fi
138+
if grep -q 'Downloading @example/lossless-claw' /tmp/plugin-update-output.log; then
139+
echo \"Unexpected npm download/reinstall path\"
140+
cat /tmp/plugin-update-output.log
141+
exit 1
142+
fi
143+
if ! grep -q 'lossless-claw is up to date (0.9.0).' /tmp/plugin-update-output.log; then
144+
echo \"Expected up-to-date output missing\"
145+
cat /tmp/plugin-update-output.log
146+
exit 1
147+
fi
148+
cat /tmp/plugin-update-output.log
149+
"
150+
151+
echo "Plugin update unchanged Docker E2E passed."

src/cli/plugins-update-selection.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,4 +92,24 @@ describe("resolvePluginUpdateSelection", () => {
9292
pluginIds: ["openclaw-codex-app-server"],
9393
});
9494
});
95+
96+
it("maps a bare scoped npm package update to the tracked plugin id", () => {
97+
expect(
98+
resolvePluginUpdateSelection({
99+
installs: {
100+
"lossless-claw": createNpmInstall({
101+
spec: "@martian-engineering/[email protected]",
102+
installPath: "/tmp/lossless-claw",
103+
resolvedName: "@martian-engineering/lossless-claw",
104+
}),
105+
},
106+
rawId: "@martian-engineering/lossless-claw",
107+
}),
108+
).toEqual({
109+
pluginIds: ["lossless-claw"],
110+
specOverrides: {
111+
"lossless-claw": "@martian-engineering/lossless-claw",
112+
},
113+
});
114+
});
95115
});

src/cli/plugins-update-selection.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,14 @@ export function resolvePluginUpdateSelection(params: {
1818
return { pluginIds: [] };
1919
}
2020

21-
const parsedSpec = parseRegistryNpmSpec(params.rawId);
22-
if (!parsedSpec || parsedSpec.selectorKind === "none") {
21+
if (params.rawId in params.installs) {
2322
return { pluginIds: [params.rawId] };
2423
}
2524

25+
const parsedSpec = parseRegistryNpmSpec(params.rawId);
26+
if (!parsedSpec) {
27+
return { pluginIds: [params.rawId] };
28+
}
2629
const matches = Object.entries(params.installs).filter(([, install]) => {
2730
return extractInstalledNpmPackageName(install) === parsedSpec.name;
2831
});
@@ -34,6 +37,14 @@ export function resolvePluginUpdateSelection(params: {
3437
if (!pluginId) {
3538
return { pluginIds: [params.rawId] };
3639
}
40+
if (parsedSpec.selectorKind === "none") {
41+
return {
42+
pluginIds: [pluginId],
43+
specOverrides: {
44+
[pluginId]: parsedSpec.raw,
45+
},
46+
};
47+
}
3748
return {
3849
pluginIds: [pluginId],
3950
specOverrides: {

src/infra/install-source-utils.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,68 @@ export function buildNpmResolutionFields(resolution?: NpmSpecResolution): NpmRes
3434
};
3535
}
3636

37+
function normalizeNpmViewMetadata(value: unknown): NpmSpecResolution | null {
38+
if (!value || typeof value !== "object") {
39+
return null;
40+
}
41+
const rec = value as Record<string, unknown>;
42+
const name = toOptionalString(rec.name);
43+
const version = toOptionalString(rec.version);
44+
const resolvedSpec = name && version ? `${name}@${version}` : undefined;
45+
const dist =
46+
rec.dist && typeof rec.dist === "object" ? (rec.dist as Record<string, unknown>) : {};
47+
return {
48+
name,
49+
version,
50+
resolvedSpec,
51+
integrity: toOptionalString(rec["dist.integrity"]) ?? toOptionalString(dist.integrity),
52+
shasum: toOptionalString(rec["dist.shasum"]) ?? toOptionalString(dist.shasum),
53+
};
54+
}
55+
56+
export async function resolveNpmSpecMetadata(params: { spec: string; timeoutMs?: number }): Promise<
57+
| {
58+
ok: true;
59+
metadata: NpmSpecResolution;
60+
}
61+
| {
62+
ok: false;
63+
error: string;
64+
}
65+
> {
66+
const res = await runCommandWithTimeout(
67+
["npm", "view", params.spec, "name", "version", "dist.integrity", "dist.shasum", "--json"],
68+
{
69+
timeoutMs: Math.max(params.timeoutMs ?? 60_000, 60_000),
70+
env: {
71+
COREPACK_ENABLE_DOWNLOAD_PROMPT: "0",
72+
NPM_CONFIG_IGNORE_SCRIPTS: "true",
73+
},
74+
},
75+
);
76+
if (res.code !== 0) {
77+
const raw = res.stderr.trim() || res.stdout.trim();
78+
if (/E404|is not in this registry/i.test(raw)) {
79+
return {
80+
ok: false,
81+
error: `Package not found on npm: ${params.spec}. See https://docs.openclaw.ai/tools/plugin for installable plugins.`,
82+
};
83+
}
84+
return { ok: false, error: `npm view failed: ${raw}` };
85+
}
86+
87+
try {
88+
const parsed = JSON.parse(res.stdout.trim()) as unknown;
89+
const metadata = normalizeNpmViewMetadata(parsed);
90+
if (!metadata?.name || !metadata.version) {
91+
return { ok: false, error: "npm view produced incomplete package metadata" };
92+
}
93+
return { ok: true, metadata };
94+
} catch (err) {
95+
return { ok: false, error: `npm view produced invalid JSON: ${String(err)}` };
96+
}
97+
}
98+
3799
export type NpmIntegrityDrift = {
38100
expectedIntegrity: string;
39101
actualIntegrity: string;

0 commit comments

Comments
 (0)