context-mode 1.0.151 → 1.0.153
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/mcp.json +5 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.openclaw-plugin/openclaw.plugin.json +16 -1
- package/.openclaw-plugin/package.json +1 -1
- package/README.md +89 -3
- package/build/adapters/claude-code/hooks.js +2 -2
- package/build/adapters/claude-code/index.js +14 -13
- package/build/adapters/client-map.js +3 -0
- package/build/adapters/detect.js +13 -1
- package/build/adapters/gemini-cli/hooks.d.ts +10 -0
- package/build/adapters/gemini-cli/hooks.js +12 -2
- package/build/adapters/gemini-cli/index.d.ts +21 -1
- package/build/adapters/gemini-cli/index.js +37 -1
- package/build/adapters/kimi/config.d.ts +8 -0
- package/build/adapters/kimi/config.js +8 -0
- package/build/adapters/kimi/hooks.d.ts +28 -0
- package/build/adapters/kimi/hooks.js +34 -0
- package/build/adapters/kimi/index.d.ts +66 -0
- package/build/adapters/kimi/index.js +537 -0
- package/build/adapters/kimi/paths.d.ts +1 -0
- package/build/adapters/kimi/paths.js +12 -0
- package/build/adapters/kiro/hooks.js +2 -2
- package/build/adapters/openclaw/plugin.d.ts +14 -13
- package/build/adapters/openclaw/plugin.js +140 -40
- package/build/adapters/opencode/plugin.js +4 -3
- package/build/adapters/opencode/zod3tov4.js +8 -8
- package/build/adapters/pi/extension.js +9 -24
- package/build/adapters/pi/mcp-bridge.js +37 -0
- package/build/adapters/qwen-code/index.js +7 -7
- package/build/adapters/types.d.ts +39 -2
- package/build/adapters/types.js +55 -2
- package/build/cli.js +433 -25
- package/build/executor.js +6 -3
- package/build/runtime.d.ts +81 -1
- package/build/runtime.js +195 -9
- package/build/search/ctx-search-schema.d.ts +90 -0
- package/build/search/ctx-search-schema.js +135 -0
- package/build/search/unified.d.ts +12 -0
- package/build/search/unified.js +17 -2
- package/build/server.d.ts +2 -1
- package/build/server.js +378 -97
- package/build/session/analytics.d.ts +36 -3
- package/build/session/analytics.js +88 -26
- package/build/session/db.d.ts +24 -0
- package/build/session/db.js +41 -0
- package/build/session/extract.js +30 -0
- package/build/session/snapshot.js +24 -0
- package/build/store.d.ts +12 -1
- package/build/store.js +72 -20
- package/build/types.d.ts +7 -0
- package/build/util/project-dir.d.ts +19 -16
- package/build/util/project-dir.js +80 -45
- package/cli.bundle.mjs +370 -319
- package/configs/kimi/hooks.json +54 -0
- package/configs/pi/AGENTS.md +3 -85
- package/hooks/cache-heal-utils.mjs +148 -0
- package/hooks/core/formatters.mjs +26 -0
- package/hooks/core/routing.mjs +9 -1
- package/hooks/core/stdin.mjs +74 -3
- package/hooks/core/tool-naming.mjs +1 -0
- package/hooks/heal-partial-install.mjs +712 -0
- package/hooks/kimi/platform.mjs +1 -0
- package/hooks/kimi/posttooluse.mjs +72 -0
- package/hooks/kimi/precompact.mjs +80 -0
- package/hooks/kimi/pretooluse.mjs +42 -0
- package/hooks/kimi/sessionend.mjs +61 -0
- package/hooks/kimi/sessionstart.mjs +113 -0
- package/hooks/kimi/stop.mjs +61 -0
- package/hooks/kimi/userpromptsubmit.mjs +90 -0
- package/hooks/normalize-hooks.mjs +66 -12
- package/hooks/routing-block.mjs +8 -2
- package/hooks/security.bundle.mjs +1 -1
- package/hooks/session-db.bundle.mjs +6 -4
- package/hooks/session-extract.bundle.mjs +2 -2
- package/hooks/session-helpers.mjs +93 -3
- package/hooks/session-snapshot.bundle.mjs +20 -19
- package/hooks/sessionstart.mjs +64 -0
- package/insight/server.mjs +15 -3
- package/openclaw.plugin.json +16 -1
- package/package.json +1 -1
- package/scripts/heal-installed-plugins.mjs +31 -10
- package/scripts/postinstall.mjs +10 -0
- package/server.bundle.mjs +206 -157
- package/skills/ctx-index/SKILL.md +46 -0
- package/skills/ctx-search/SKILL.md +35 -0
- package/start.mjs +84 -11
- package/build/cache-heal.d.ts +0 -48
- package/build/cache-heal.js +0 -150
- package/build/concurrency/runPool.d.ts +0 -36
- package/build/concurrency/runPool.js +0 -51
- package/build/openclaw/mcp-tools.d.ts +0 -54
- package/build/openclaw/mcp-tools.js +0 -198
- package/build/openclaw/workspace-router.d.ts +0 -29
- package/build/openclaw/workspace-router.js +0 -64
- package/build/openclaw-plugin.d.ts +0 -130
- package/build/openclaw-plugin.js +0 -626
- package/build/opencode-plugin.d.ts +0 -122
- package/build/opencode-plugin.js +0 -375
- package/build/pi-extension.d.ts +0 -14
- package/build/pi-extension.js +0 -451
- package/build/routing-block.d.ts +0 -8
- package/build/routing-block.js +0 -86
- package/build/tool-naming.d.ts +0 -4
- package/build/tool-naming.js +0 -24
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* heal-partial-install.mjs - self-heal a partial plugin cache install.
|
|
3
|
+
*
|
|
4
|
+
* Failure mode this addresses:
|
|
5
|
+
*
|
|
6
|
+
* The per-version plugin cache dir at
|
|
7
|
+
* ~/.claude/plugins/cache/<owner>/<plugin>/<version>/ holds only a
|
|
8
|
+
* subset of the published files. start.mjs, cli.bundle.mjs,
|
|
9
|
+
* server.bundle.mjs, package.json and several other entries from
|
|
10
|
+
* `package.json files[]` may be absent, while hooks/ and
|
|
11
|
+
* .claude-plugin/ tend to survive. The cache's
|
|
12
|
+
* .claude-plugin/plugin.json can also be a verbatim carry-forward
|
|
13
|
+
* from a prior version's install, with mcpServers.args[0] holding
|
|
14
|
+
* an absolute path under a since-deleted cache dir; MCP launch
|
|
15
|
+
* ENOENTs as a result.
|
|
16
|
+
*
|
|
17
|
+
* Existing defenses don't repair this:
|
|
18
|
+
*
|
|
19
|
+
* - scripts/plugin-cache-integrity.mjs (#550) exits 2 from
|
|
20
|
+
* start.mjs, but start.mjs is one of the missing files.
|
|
21
|
+
* - The #604 stale-cache-version ratchet in
|
|
22
|
+
* hooks/normalize-hooks.mjs runs from start.mjs's
|
|
23
|
+
* normalize-hooks call, same boot-time dependency.
|
|
24
|
+
* - /ctx-upgrade needs the cli.bundle.mjs the partial install lost.
|
|
25
|
+
*
|
|
26
|
+
* So the cache cannot self-recover, and the only available
|
|
27
|
+
* workaround from the user side is `rm -rf <version-dir>` plus a
|
|
28
|
+
* session restart.
|
|
29
|
+
*
|
|
30
|
+
* What this module does:
|
|
31
|
+
*
|
|
32
|
+
* Detect partial install via a cheap existsSync probe of a few
|
|
33
|
+
* launch-critical files. On a trip, re-copy the missing entries
|
|
34
|
+
* from the marketplace clone at
|
|
35
|
+
* ~/.claude/plugins/marketplaces/<owner>/ (Claude Code's canonical
|
|
36
|
+
* source for the per-version cache dir) and rewrite any
|
|
37
|
+
* carry-forward stale args[0] in plugin.json to the current
|
|
38
|
+
* pluginRoot. Both halves are mechanism-agnostic.
|
|
39
|
+
*
|
|
40
|
+
* Placement:
|
|
41
|
+
*
|
|
42
|
+
* Lives in hooks/ rather than scripts/ because the failure mode can
|
|
43
|
+
* strip scripts/ from the cache dir. hooks/ has been intact in every
|
|
44
|
+
* observed instance, so it's the most reliable available host.
|
|
45
|
+
*
|
|
46
|
+
* Scope:
|
|
47
|
+
*
|
|
48
|
+
* CC-only. The failure mode is specific to Claude Code's
|
|
49
|
+
* per-version cache layout at
|
|
50
|
+
* ~/.claude/plugins/cache/<owner>/<plugin>/<version>/. Other
|
|
51
|
+
* clients (Codex, Cursor, OpenCode, Kiro, ...) ship their own
|
|
52
|
+
* SessionStart wrappers under hooks/<client>/, none of which call
|
|
53
|
+
* this module. The two call sites that exist
|
|
54
|
+
* (hooks/sessionstart.mjs and start.mjs) are themselves CC-only
|
|
55
|
+
* entry points: sessionstart.mjs is wired only from
|
|
56
|
+
* hooks/hooks.json (CC's hook config), and start.mjs is invoked
|
|
57
|
+
* only by CC's .claude-plugin/plugin.json mcpServers entry. The
|
|
58
|
+
* module also enforces this at runtime: pluginRoot must match the
|
|
59
|
+
* CC cache layout (deriveMarketplaceClonePath returns null
|
|
60
|
+
* otherwise) or the function short-circuits with
|
|
61
|
+
* `skipped: "not-claude-code"`, before any further work runs.
|
|
62
|
+
*
|
|
63
|
+
* Contract:
|
|
64
|
+
*
|
|
65
|
+
* - Pure JS, Node built-ins only.
|
|
66
|
+
* - Never throws. Returns a structured result; callers log it.
|
|
67
|
+
* - Idempotent: the cheap probe makes the healthy case a few
|
|
68
|
+
* existsSync calls, not a full files[] expansion.
|
|
69
|
+
* - Path-traversal guarded at six layers, symmetric on both ends:
|
|
70
|
+
* 1. pluginRoot must match the CC cache layout, the derived
|
|
71
|
+
* marketplace path must exist and not equal pluginRoot.
|
|
72
|
+
* 2. files[] entries that resolve outside rootDir are dropped.
|
|
73
|
+
* 3. Directory walks use lstatSync and skip symlinks, so a
|
|
74
|
+
* symlink-to-outside in the marketplace tree can't be
|
|
75
|
+
* harvested as a regular file during manifest expansion.
|
|
76
|
+
* 4. After mkdirSync(dirname(to)), realpathSync(dirname(to)) is
|
|
77
|
+
* re-checked against realpathSync(pluginRoot), catching the
|
|
78
|
+
* case where a parent component of `to` is already a
|
|
79
|
+
* symlink-to-outside that mkdirSync followed.
|
|
80
|
+
* 5. Just before the destination write, the source is checked
|
|
81
|
+
* two ways: lstat(from).isSymbolicLink() drops leaf symlinks
|
|
82
|
+
* (fast path), and realpathSync(from) is required to fall
|
|
83
|
+
* under realpathSync(marketplaceClonePath). The realpath
|
|
84
|
+
* check collapses ancestor symlinks (e.g. a marketplace
|
|
85
|
+
* `scripts/` dir that's been swapped for a symlink to
|
|
86
|
+
* outside), which the leaf-lstat would miss because the
|
|
87
|
+
* leaf itself is a regular file at the symlink's resolved
|
|
88
|
+
* target.
|
|
89
|
+
* 6. Just before the destination write, lstat(to) and unlink
|
|
90
|
+
* any pre-existing symlink at `to`. The write itself uses
|
|
91
|
+
* writeFileSync with `flag: "wx"` (O_CREAT | O_EXCL), which
|
|
92
|
+
* per POSIX open(2) refuses to follow a symlink at the
|
|
93
|
+
* final component. That closes the residual race window
|
|
94
|
+
* where a same-user attacker re-plants a symlink at `to`
|
|
95
|
+
* between our unlink and the open.
|
|
96
|
+
* - Source-file reads that drive rewrites are symlink-checked too.
|
|
97
|
+
* rewritePluginJsonArgs opens `.claude-plugin/plugin.json` with
|
|
98
|
+
* O_NOFOLLOW, so the open(2) call itself fails with ELOOP when
|
|
99
|
+
* the path is a symlink. Reading from the returned fd then binds
|
|
100
|
+
* subsequent reads to the inode, closing the TOCTOU window that
|
|
101
|
+
* a lstat+readFileSync(path) pair would have left open. Without
|
|
102
|
+
* this, a same-user-planted redirect would feed attacker JSON to
|
|
103
|
+
* the read, and the subsequent atomic rename would replace the
|
|
104
|
+
* symlink with a regular file containing attacker-controlled
|
|
105
|
+
* mcpServers config.
|
|
106
|
+
* - plugin.json rewrites are atomic AND symlink-safe:
|
|
107
|
+
* writeFileSync targets a tmp sibling with a 64-bit random
|
|
108
|
+
* suffix (unguessable) under O_CREAT | O_EXCL (refuses to follow
|
|
109
|
+
* a pre-planted symlink at the tmp path), then renameSync over
|
|
110
|
+
* the real path. A racing writer can't observe a torn JSON file,
|
|
111
|
+
* and a local attacker can't redirect the write via symlink.
|
|
112
|
+
*/
|
|
113
|
+
|
|
114
|
+
import {
|
|
115
|
+
existsSync,
|
|
116
|
+
readFileSync,
|
|
117
|
+
readdirSync,
|
|
118
|
+
lstatSync,
|
|
119
|
+
mkdirSync,
|
|
120
|
+
renameSync,
|
|
121
|
+
realpathSync,
|
|
122
|
+
unlinkSync,
|
|
123
|
+
writeFileSync,
|
|
124
|
+
appendFileSync,
|
|
125
|
+
openSync,
|
|
126
|
+
closeSync,
|
|
127
|
+
constants as fsConstants,
|
|
128
|
+
} from "node:fs";
|
|
129
|
+
import { join, dirname, resolve, sep } from "node:path";
|
|
130
|
+
import { homedir } from "node:os";
|
|
131
|
+
import { randomBytes } from "node:crypto";
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Cheap-probe partial-install detection. Runs on every session start, so
|
|
135
|
+
* the healthy case must be O(few existsSync), not O(files[].length).
|
|
136
|
+
*
|
|
137
|
+
* A file is "launch-critical" when its absence keeps either start.mjs
|
|
138
|
+
* from booting or /ctx-upgrade from running. We don't probe every file
|
|
139
|
+
* in files[]; that's what the full heal does once this probe trips.
|
|
140
|
+
*/
|
|
141
|
+
export function isPartialInstall(pluginRoot) {
|
|
142
|
+
if (!pluginRoot) return false;
|
|
143
|
+
if (!existsSync(join(pluginRoot, "start.mjs"))) return true;
|
|
144
|
+
if (!existsSync(join(pluginRoot, "package.json"))) return true;
|
|
145
|
+
if (
|
|
146
|
+
!existsSync(join(pluginRoot, "cli.bundle.mjs")) &&
|
|
147
|
+
!existsSync(join(pluginRoot, "build", "cli.js"))
|
|
148
|
+
) {
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
if (
|
|
152
|
+
!existsSync(join(pluginRoot, "server.bundle.mjs")) &&
|
|
153
|
+
!existsSync(join(pluginRoot, "build", "server.js"))
|
|
154
|
+
) {
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Derive the marketplace clone path for a CC plugin cache pluginRoot.
|
|
162
|
+
*
|
|
163
|
+
* Layout (forward slashes on POSIX, backslashes on Windows):
|
|
164
|
+
* <configDir>/plugins/cache/<owner>/<plugin>/<version>/
|
|
165
|
+
* <configDir>/plugins/marketplaces/<owner>/
|
|
166
|
+
*
|
|
167
|
+
* Returns null when pluginRoot doesn't match the cache layout (npm-global
|
|
168
|
+
* install, dev checkout, opencode cache, ...). Those don't have a
|
|
169
|
+
* marketplace clone to heal from, and we'd rather skip than guess.
|
|
170
|
+
*/
|
|
171
|
+
export function deriveMarketplaceClonePath(pluginRoot) {
|
|
172
|
+
if (!pluginRoot) return null;
|
|
173
|
+
const fwd = String(pluginRoot).replace(/\\/g, "/");
|
|
174
|
+
const trailing = fwd.endsWith("/") ? fwd : fwd + "/";
|
|
175
|
+
const m = /^(.*\/plugins\/)cache\/([^/]+)\/[^/]+\/[^/]+\/$/.exec(trailing);
|
|
176
|
+
if (!m) return null;
|
|
177
|
+
return resolve(m[1], "marketplaces", m[2]);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Walk a directory recursively, returning relative file paths from
|
|
182
|
+
* baseAbs. Skips unreadable entries silently. Uses lstatSync and skips
|
|
183
|
+
* symlinks so a stray symlink-to-outside in the marketplace tree can't
|
|
184
|
+
* be harvested as a regular file and copied into pluginRoot. statSync
|
|
185
|
+
* would dereference and silently follow the link.
|
|
186
|
+
*/
|
|
187
|
+
function listFilesRecursive(absDir, baseAbs) {
|
|
188
|
+
const out = [];
|
|
189
|
+
let entries;
|
|
190
|
+
try {
|
|
191
|
+
entries = readdirSync(absDir);
|
|
192
|
+
} catch {
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
for (const name of entries) {
|
|
196
|
+
const full = join(absDir, name);
|
|
197
|
+
let st;
|
|
198
|
+
try {
|
|
199
|
+
st = lstatSync(full);
|
|
200
|
+
} catch {
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (st.isSymbolicLink()) continue;
|
|
204
|
+
if (st.isDirectory()) {
|
|
205
|
+
out.push(...listFilesRecursive(full, baseAbs));
|
|
206
|
+
} else if (st.isFile()) {
|
|
207
|
+
out.push(full.slice(baseAbs.length + 1));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Expand a `files[]` array against rootDir into a flat list of file paths
|
|
215
|
+
* (relative to rootDir, OS-native separator). Entries that don't exist on
|
|
216
|
+
* disk are silently dropped, matching the semantics of
|
|
217
|
+
* scripts/plugin-cache-integrity.mjs::derivePluginManifest.
|
|
218
|
+
*
|
|
219
|
+
* Containment: entries whose resolved path escapes rootDir (e.g. a
|
|
220
|
+
* corrupted marketplace package.json with `files: ["../outside.txt"]`)
|
|
221
|
+
* are rejected, so the downstream copy loop can't be tricked into
|
|
222
|
+
* reading from or writing outside the trusted root. `path.join` itself
|
|
223
|
+
* does not clamp `..` segments; it normalizes them. As such, we resolve
|
|
224
|
+
* and prefix-match against rootDir + sep explicitly.
|
|
225
|
+
*
|
|
226
|
+
* Symlinks: lstatSync + isSymbolicLink() drops top-level entries that
|
|
227
|
+
* are themselves symlinks. A symlink-to-outside sitting in rootDir
|
|
228
|
+
* passes the lexical resolve+startsWith check, since the symlink's own
|
|
229
|
+
* path stays inside rootDir; we have to refuse symlinks outright to
|
|
230
|
+
* close that bypass.
|
|
231
|
+
*/
|
|
232
|
+
function expandFilesArray(rootDir, files) {
|
|
233
|
+
if (!Array.isArray(files)) return [];
|
|
234
|
+
const out = new Set();
|
|
235
|
+
const rootWithSep = resolve(rootDir) + sep;
|
|
236
|
+
for (const entry of files) {
|
|
237
|
+
if (typeof entry !== "string" || !entry) continue;
|
|
238
|
+
const abs = join(rootDir, entry);
|
|
239
|
+
if (!resolve(abs).startsWith(rootWithSep)) continue;
|
|
240
|
+
if (!existsSync(abs)) continue;
|
|
241
|
+
let st;
|
|
242
|
+
try {
|
|
243
|
+
st = lstatSync(abs);
|
|
244
|
+
} catch {
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
if (st.isSymbolicLink()) continue;
|
|
248
|
+
if (st.isDirectory()) {
|
|
249
|
+
for (const f of listFilesRecursive(abs, rootDir)) out.add(f);
|
|
250
|
+
} else if (st.isFile()) {
|
|
251
|
+
out.add(entry);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return [...out];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function readJsonSafe(path) {
|
|
258
|
+
try {
|
|
259
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
260
|
+
} catch {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Repair `.claude-plugin/plugin.json` mcpServers.args[0] to point at the
|
|
267
|
+
* current pluginRoot. This is the carry-forward fingerprint from CC's
|
|
268
|
+
* native plugin manager: when it creates a new version dir, it preserves
|
|
269
|
+
* the previous version's plugin.json (including any absolute start.mjs
|
|
270
|
+
* path that hooks/normalize-hooks.mjs (#604) wrote on Linux/Windows) and
|
|
271
|
+
* only bumps the `version` field. The result is args[0] pointing at a
|
|
272
|
+
* version dir that's typically been cleaned up by the age-gated sweep,
|
|
273
|
+
* so MCP launch ENOENTs.
|
|
274
|
+
*
|
|
275
|
+
* We can't lean on normalize-hooks.mjs's existing rewrite path since
|
|
276
|
+
* that fires from start.mjs at boot, and start.mjs is what's missing
|
|
277
|
+
* (or, if present, would have already loaded plugin.json with the stale
|
|
278
|
+
* path before normalize ran). The heal does the rewrite up front.
|
|
279
|
+
*
|
|
280
|
+
* Mirrors the rewrite logic in hooks/normalize-hooks.mjs for placeholder
|
|
281
|
+
* and stale-cache-version drift shapes. The `command` field is left
|
|
282
|
+
* alone (start.mjs's normalize call repairs it on the next clean boot).
|
|
283
|
+
*/
|
|
284
|
+
function rewritePluginJsonArgs(pluginRoot) {
|
|
285
|
+
const pluginJsonPath = join(pluginRoot, ".claude-plugin", "plugin.json");
|
|
286
|
+
if (!existsSync(pluginJsonPath)) return false;
|
|
287
|
+
// Refuse to operate on plugin.json when it's a symlink, atomically.
|
|
288
|
+
// O_NOFOLLOW makes open(2) fail with ELOOP when the final path
|
|
289
|
+
// component is a symlink, in the same syscall that opens the fd.
|
|
290
|
+
// A naive `lstatSync().isSymbolicLink() ? return false : readFileSync(path)`
|
|
291
|
+
// would have a TOCTOU window between the lstat and the read where
|
|
292
|
+
// a same-user attacker could swap the regular file for a symlink
|
|
293
|
+
// pointing at attacker JSON, feeding the read attacker bytes; the
|
|
294
|
+
// subsequent atomic rename would then replace the symlink with a
|
|
295
|
+
// regular file holding attacker mcpServers config, executed at next
|
|
296
|
+
// MCP launch. Reading from the fd returned by openSync(O_NOFOLLOW)
|
|
297
|
+
// closes that window since the fd is bound to an inode, not a path.
|
|
298
|
+
// POSIX 0700 on ~/.claude scopes this to same-user threats; the
|
|
299
|
+
// defense mirrors the source/destination symlink refusals elsewhere.
|
|
300
|
+
let fd;
|
|
301
|
+
try {
|
|
302
|
+
fd = openSync(
|
|
303
|
+
pluginJsonPath,
|
|
304
|
+
fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW,
|
|
305
|
+
);
|
|
306
|
+
} catch {
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
let content;
|
|
310
|
+
try {
|
|
311
|
+
content = readFileSync(fd, "utf-8");
|
|
312
|
+
} catch {
|
|
313
|
+
try {
|
|
314
|
+
closeSync(fd);
|
|
315
|
+
} catch {
|
|
316
|
+
/* ignore */
|
|
317
|
+
}
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
closeSync(fd);
|
|
322
|
+
} catch {
|
|
323
|
+
/* ignore */
|
|
324
|
+
}
|
|
325
|
+
let parsed;
|
|
326
|
+
try {
|
|
327
|
+
parsed = JSON.parse(content);
|
|
328
|
+
} catch {
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
const servers = parsed?.mcpServers;
|
|
332
|
+
if (!servers || typeof servers !== "object") return false;
|
|
333
|
+
|
|
334
|
+
const safeRoot = String(pluginRoot).replace(/\\/g, "/");
|
|
335
|
+
const versionM = /context-mode\/context-mode\/([0-9]+\.[0-9]+\.[0-9]+)(?:\/|$)/.exec(safeRoot);
|
|
336
|
+
const currentVersion = versionM ? versionM[1] : null;
|
|
337
|
+
const STALE_VERSION_RE = /context-mode\/context-mode\/([0-9]+\.[0-9]+\.[0-9]+)(?=\/)/g;
|
|
338
|
+
const PLACEHOLDER = "${CLAUDE_PLUGIN_ROOT}";
|
|
339
|
+
|
|
340
|
+
let mutated = false;
|
|
341
|
+
for (const key of Object.keys(servers)) {
|
|
342
|
+
const srv = servers[key];
|
|
343
|
+
if (!srv || typeof srv !== "object" || !Array.isArray(srv.args)) continue;
|
|
344
|
+
const before = srv.args;
|
|
345
|
+
const after = before.map((a) => {
|
|
346
|
+
if (typeof a !== "string") return a;
|
|
347
|
+
let next = a;
|
|
348
|
+
if (next.includes(PLACEHOLDER)) {
|
|
349
|
+
next = next.replaceAll(PLACEHOLDER, safeRoot);
|
|
350
|
+
}
|
|
351
|
+
if (currentVersion) {
|
|
352
|
+
const fwd = next.replace(/\\/g, "/");
|
|
353
|
+
STALE_VERSION_RE.lastIndex = 0;
|
|
354
|
+
let hasStale = false;
|
|
355
|
+
let m;
|
|
356
|
+
while ((m = STALE_VERSION_RE.exec(fwd)) !== null) {
|
|
357
|
+
if (m[1] !== currentVersion) {
|
|
358
|
+
hasStale = true;
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (hasStale) {
|
|
363
|
+
next = fwd.replace(
|
|
364
|
+
STALE_VERSION_RE,
|
|
365
|
+
`context-mode/context-mode/${currentVersion}`,
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return next;
|
|
370
|
+
});
|
|
371
|
+
if (after.some((v, i) => v !== before[i])) {
|
|
372
|
+
srv.args = after;
|
|
373
|
+
mutated = true;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (!mutated) return false;
|
|
378
|
+
// Atomic write with two security properties on top of atomicity:
|
|
379
|
+
// 1. Unguessable tmp filename via randomBytes(8). A local attacker
|
|
380
|
+
// polling /proc/<pid>/comm can predict process.pid and pre-plant
|
|
381
|
+
// a symlink at a deterministic tmp path, redirecting our write.
|
|
382
|
+
// 64 bits of randomness make that infeasible.
|
|
383
|
+
// 2. O_CREAT | O_EXCL via `flag: "wx"`. open(2) with O_EXCL refuses
|
|
384
|
+
// to follow symlinks at the final path component, raising EEXIST
|
|
385
|
+
// instead. So even if the random tmp name happens to collide
|
|
386
|
+
// with a pre-existing symlink, the write fails closed rather
|
|
387
|
+
// than redirecting.
|
|
388
|
+
// renameSync remains atomic on POSIX so a concurrent reader can't
|
|
389
|
+
// observe a torn JSON file. On Windows the implementation maps to
|
|
390
|
+
// MoveFileEx(MOVEFILE_REPLACE_EXISTING), which is atomic for
|
|
391
|
+
// non-directory writes.
|
|
392
|
+
const tmp = `${pluginJsonPath}.tmp-${randomBytes(8).toString("hex")}`;
|
|
393
|
+
try {
|
|
394
|
+
writeFileSync(tmp, JSON.stringify(parsed, null, 2), {
|
|
395
|
+
encoding: "utf-8",
|
|
396
|
+
flag: "wx",
|
|
397
|
+
});
|
|
398
|
+
renameSync(tmp, pluginJsonPath);
|
|
399
|
+
return true;
|
|
400
|
+
} catch {
|
|
401
|
+
// Best-effort cleanup. If writeFileSync threw, tmp may not exist;
|
|
402
|
+
// if renameSync threw, tmp still does. unlinkSync's own catch
|
|
403
|
+
// swallows ENOENT either way.
|
|
404
|
+
try {
|
|
405
|
+
unlinkSync(tmp);
|
|
406
|
+
} catch {
|
|
407
|
+
/* ignore */
|
|
408
|
+
}
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function resolveConfigDir() {
|
|
414
|
+
const envVal = process.env.CLAUDE_CONFIG_DIR;
|
|
415
|
+
if (envVal && envVal.trim() !== "") {
|
|
416
|
+
if (envVal.startsWith("~")) {
|
|
417
|
+
return resolve(homedir(), envVal.replace(/^~[/\\]?/, ""));
|
|
418
|
+
}
|
|
419
|
+
return resolve(envVal);
|
|
420
|
+
}
|
|
421
|
+
return resolve(homedir(), ".claude");
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function logHealResult(result) {
|
|
425
|
+
try {
|
|
426
|
+
const logDir = join(resolveConfigDir(), "context-mode");
|
|
427
|
+
mkdirSync(logDir, { recursive: true });
|
|
428
|
+
appendFileSync(
|
|
429
|
+
join(logDir, "heal-partial-install.log"),
|
|
430
|
+
JSON.stringify({ ts: new Date().toISOString(), ...result }) + "\n",
|
|
431
|
+
"utf-8",
|
|
432
|
+
);
|
|
433
|
+
} catch {
|
|
434
|
+
/* best effort */
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Heal a partial install by copying missing files from the marketplace
|
|
440
|
+
* clone. Best-effort and idempotent. Never throws.
|
|
441
|
+
*
|
|
442
|
+
* Inputs:
|
|
443
|
+
* pluginRoot - absolute path to the cache version dir (CLAUDE_PLUGIN_ROOT).
|
|
444
|
+
* marketplaceClonePath - absolute path to the marketplace clone. Auto-derived
|
|
445
|
+
* from pluginRoot when omitted.
|
|
446
|
+
* log - when true (default), appends a JSON line to
|
|
447
|
+
* ~/.claude/context-mode/heal-partial-install.log
|
|
448
|
+
* on every run, including skipped ones, so an
|
|
449
|
+
* operator can grep for evidence the hook fired.
|
|
450
|
+
*
|
|
451
|
+
* Returns an object whose exact shape depends on the branch taken.
|
|
452
|
+
* Common fields:
|
|
453
|
+
* healed: string[] // relative paths successfully copied (always present, [] when skipped)
|
|
454
|
+
* stillMissing: string[] // relative paths the heal couldn't restore (always present, [] when skipped)
|
|
455
|
+
* skipped?: string // reason the heal short-circuited; absent on the success path
|
|
456
|
+
* pluginRoot?: string // echoed back when known; absent only on the "no-plugin-root" branch
|
|
457
|
+
* pkgSource?: string // "marketplace" or "pluginRoot", which package.json the files[] came from; absent until the manifest read happens
|
|
458
|
+
* argsRewritten?: boolean // whether plugin.json mcpServers.args was rewritten; present on the success path and on "files-already-present"
|
|
459
|
+
* missingBefore?: string[] // present on the success path only; relative paths that were missing before the copy loop ran
|
|
460
|
+
*
|
|
461
|
+
* Branch matrix:
|
|
462
|
+
* skipped="no-plugin-root" : {healed, stillMissing, skipped}
|
|
463
|
+
* skipped="not-claude-code" : {healed, stillMissing, skipped, pluginRoot}
|
|
464
|
+
* skipped="not-partial" : {healed, stillMissing, skipped, pluginRoot}
|
|
465
|
+
* skipped="no-marketplace" : {healed, stillMissing, skipped, pluginRoot}
|
|
466
|
+
* skipped="same-as-marketplace" : {healed, stillMissing, skipped, pluginRoot}
|
|
467
|
+
* skipped="no-files-manifest" : {healed, stillMissing, skipped, pluginRoot, pkgSource}
|
|
468
|
+
* skipped="marketplace-empty" : {healed, stillMissing, skipped, pluginRoot, pkgSource}
|
|
469
|
+
* skipped="files-already-present" : {healed, stillMissing, skipped, pluginRoot, pkgSource, argsRewritten}
|
|
470
|
+
* success path (no `skipped` key) : {healed, stillMissing, pluginRoot, pkgSource, argsRewritten, missingBefore}
|
|
471
|
+
*/
|
|
472
|
+
export function healPartialInstallFromMarketplace(opts = {}) {
|
|
473
|
+
const pluginRoot = opts.pluginRoot ?? process.env.CLAUDE_PLUGIN_ROOT;
|
|
474
|
+
const log = opts.log !== false;
|
|
475
|
+
|
|
476
|
+
if (!pluginRoot) {
|
|
477
|
+
const result = {
|
|
478
|
+
healed: [],
|
|
479
|
+
stillMissing: [],
|
|
480
|
+
skipped: "no-plugin-root",
|
|
481
|
+
};
|
|
482
|
+
if (log) logHealResult(result);
|
|
483
|
+
return result;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// CC-only scope check. The partial-install failure mode this module
|
|
487
|
+
// addresses is specific to Claude Code's per-version cache layout at
|
|
488
|
+
// ~/.claude/plugins/cache/<owner>/<plugin>/<version>/. Other clients
|
|
489
|
+
// (Codex, Cursor, OpenCode, Kiro, ...) ship their own SessionStart
|
|
490
|
+
// hooks under hooks/<client>/ and don't go through this module at
|
|
491
|
+
// all; npm-global, npx, and dev-checkout installs don't have the
|
|
492
|
+
// cache layout either. deriveMarketplaceClonePath returns null for
|
|
493
|
+
// anything that isn't a CC cache pluginRoot. Bailing here keeps the
|
|
494
|
+
// healthy-case fast path cheap for non-CC contexts (no isPartialInstall
|
|
495
|
+
// probe, no filesystem reads) and makes the scope intent explicit in
|
|
496
|
+
// the log: a "not-claude-code" line is the signal that the heal saw
|
|
497
|
+
// a non-CC pluginRoot.
|
|
498
|
+
const marketplaceClonePath =
|
|
499
|
+
opts.marketplaceClonePath ?? deriveMarketplaceClonePath(pluginRoot);
|
|
500
|
+
if (!marketplaceClonePath) {
|
|
501
|
+
const result = {
|
|
502
|
+
healed: [],
|
|
503
|
+
stillMissing: [],
|
|
504
|
+
skipped: "not-claude-code",
|
|
505
|
+
pluginRoot,
|
|
506
|
+
};
|
|
507
|
+
if (log) logHealResult(result);
|
|
508
|
+
return result;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (!isPartialInstall(pluginRoot)) {
|
|
512
|
+
const result = {
|
|
513
|
+
healed: [],
|
|
514
|
+
stillMissing: [],
|
|
515
|
+
skipped: "not-partial",
|
|
516
|
+
pluginRoot,
|
|
517
|
+
};
|
|
518
|
+
if (log) logHealResult(result);
|
|
519
|
+
return result;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (!existsSync(marketplaceClonePath)) {
|
|
523
|
+
const result = {
|
|
524
|
+
healed: [],
|
|
525
|
+
stillMissing: [],
|
|
526
|
+
skipped: "no-marketplace",
|
|
527
|
+
pluginRoot,
|
|
528
|
+
};
|
|
529
|
+
if (log) logHealResult(result);
|
|
530
|
+
return result;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Path-traversal guard: refuse to heal a pluginRoot that is itself the
|
|
534
|
+
// marketplace clone (would happen on a dev checkout where someone
|
|
535
|
+
// symlinks the cache into the marketplace tree).
|
|
536
|
+
if (resolve(pluginRoot) === resolve(marketplaceClonePath)) {
|
|
537
|
+
const result = {
|
|
538
|
+
healed: [],
|
|
539
|
+
stillMissing: [],
|
|
540
|
+
skipped: "same-as-marketplace",
|
|
541
|
+
pluginRoot,
|
|
542
|
+
};
|
|
543
|
+
if (log) logHealResult(result);
|
|
544
|
+
return result;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Prefer the marketplace clone's package.json. The heal only runs
|
|
548
|
+
// once we've established that pluginRoot is in a partial state, and
|
|
549
|
+
// its package.json (when present at all) can itself be a stale
|
|
550
|
+
// carry-forward from a prior version. The marketplace clone is the
|
|
551
|
+
// canonical source CC extracted the cache from, so its files[] is
|
|
552
|
+
// the right manifest to expand. Fall back to pluginRoot only when
|
|
553
|
+
// the marketplace clone's package.json is unreadable.
|
|
554
|
+
let pkg = readJsonSafe(join(marketplaceClonePath, "package.json"));
|
|
555
|
+
let pkgSource = "marketplace";
|
|
556
|
+
if (!pkg) {
|
|
557
|
+
pkg = readJsonSafe(join(pluginRoot, "package.json"));
|
|
558
|
+
pkgSource = "pluginRoot";
|
|
559
|
+
}
|
|
560
|
+
if (!pkg || !Array.isArray(pkg.files)) {
|
|
561
|
+
const result = {
|
|
562
|
+
healed: [],
|
|
563
|
+
stillMissing: [],
|
|
564
|
+
skipped: "no-files-manifest",
|
|
565
|
+
pluginRoot,
|
|
566
|
+
pkgSource,
|
|
567
|
+
};
|
|
568
|
+
if (log) logHealResult(result);
|
|
569
|
+
return result;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Always include package.json itself so the next boot's getLocalVersion
|
|
573
|
+
// and integrity check have something to read. npm's `files[]` semantics
|
|
574
|
+
// include package.json implicitly; we have to add it back manually since
|
|
575
|
+
// we're expanding the array ourselves.
|
|
576
|
+
const items = new Set(pkg.files);
|
|
577
|
+
items.add("package.json");
|
|
578
|
+
|
|
579
|
+
// Expand against the marketplace clone since that's the source of truth.
|
|
580
|
+
// Entries that don't exist on the marketplace side are dropped.
|
|
581
|
+
const expanded = expandFilesArray(marketplaceClonePath, [...items]);
|
|
582
|
+
if (expanded.length === 0) {
|
|
583
|
+
const result = {
|
|
584
|
+
healed: [],
|
|
585
|
+
stillMissing: [],
|
|
586
|
+
skipped: "marketplace-empty",
|
|
587
|
+
pluginRoot,
|
|
588
|
+
pkgSource,
|
|
589
|
+
};
|
|
590
|
+
if (log) logHealResult(result);
|
|
591
|
+
return result;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const missingBefore = expanded.filter((rel) => !existsSync(join(pluginRoot, rel)));
|
|
595
|
+
if (missingBefore.length === 0) {
|
|
596
|
+
// The cheap probe tripped but the full expansion shows everything's
|
|
597
|
+
// present, e.g. start.mjs is missing but isn't in files[] anymore
|
|
598
|
+
// (unlikely, but defensive). Still attempt the args rewrite in case
|
|
599
|
+
// a carry-forward plugin.json drifted independently.
|
|
600
|
+
const argsRewritten = rewritePluginJsonArgs(pluginRoot);
|
|
601
|
+
const result = {
|
|
602
|
+
healed: [],
|
|
603
|
+
stillMissing: [],
|
|
604
|
+
argsRewritten,
|
|
605
|
+
pkgSource,
|
|
606
|
+
pluginRoot,
|
|
607
|
+
skipped: "files-already-present",
|
|
608
|
+
};
|
|
609
|
+
if (log) logHealResult(result);
|
|
610
|
+
return result;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// Copy each missing item. The guards below mirror the Contract
|
|
614
|
+
// block at the top of this module: layer 2 (lexical), layer 4
|
|
615
|
+
// (realpath on dest), layer 5 (lstat + realpath on source), layer 6
|
|
616
|
+
// (lstat + unlink on dest, plus an O_EXCL destination open). The
|
|
617
|
+
// non-layered existsSync(from) guard skips entries that disappeared
|
|
618
|
+
// from the marketplace between manifest expansion and this
|
|
619
|
+
// iteration, so the read isn't asked to open a missing source.
|
|
620
|
+
// realpathSync(pluginRoot) and realpathSync(marketplaceClonePath)
|
|
621
|
+
// are cached once outside the loop; failures there just disable
|
|
622
|
+
// the realpath guards for this run, leaving the lexical guards in
|
|
623
|
+
// force (heal contract: never throws).
|
|
624
|
+
const pluginRootWithSep = resolve(pluginRoot) + sep;
|
|
625
|
+
const marketplaceWithSep = resolve(marketplaceClonePath) + sep;
|
|
626
|
+
let pluginRootRealWithSep = null;
|
|
627
|
+
try {
|
|
628
|
+
pluginRootRealWithSep = realpathSync(pluginRoot) + sep;
|
|
629
|
+
} catch {
|
|
630
|
+
/* lexical guard still in force */
|
|
631
|
+
}
|
|
632
|
+
let marketplaceCloneRealWithSep = null;
|
|
633
|
+
try {
|
|
634
|
+
marketplaceCloneRealWithSep = realpathSync(marketplaceClonePath) + sep;
|
|
635
|
+
} catch {
|
|
636
|
+
/* lexical guard still in force */
|
|
637
|
+
}
|
|
638
|
+
const healed = [];
|
|
639
|
+
for (const rel of missingBefore) {
|
|
640
|
+
const from = join(marketplaceClonePath, rel);
|
|
641
|
+
const to = join(pluginRoot, rel);
|
|
642
|
+
if (!resolve(from).startsWith(marketplaceWithSep)) continue;
|
|
643
|
+
if (!resolve(to).startsWith(pluginRootWithSep)) continue;
|
|
644
|
+
if (!existsSync(from)) continue;
|
|
645
|
+
try {
|
|
646
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
647
|
+
if (pluginRootRealWithSep) {
|
|
648
|
+
const toParentReal = realpathSync(dirname(to)) + sep;
|
|
649
|
+
if (!toParentReal.startsWith(pluginRootRealWithSep)) continue;
|
|
650
|
+
}
|
|
651
|
+
let stFrom;
|
|
652
|
+
try {
|
|
653
|
+
stFrom = lstatSync(from);
|
|
654
|
+
} catch {
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
if (stFrom.isSymbolicLink()) continue;
|
|
658
|
+
if (marketplaceCloneRealWithSep) {
|
|
659
|
+
let fromReal;
|
|
660
|
+
try {
|
|
661
|
+
fromReal = realpathSync(from);
|
|
662
|
+
} catch {
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
if (!(fromReal + sep).startsWith(marketplaceCloneRealWithSep)) {
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
try {
|
|
670
|
+
const stTo = lstatSync(to);
|
|
671
|
+
if (stTo.isSymbolicLink()) unlinkSync(to);
|
|
672
|
+
} catch {
|
|
673
|
+
/* `to` doesn't exist yet, which is the common case */
|
|
674
|
+
}
|
|
675
|
+
// Read-then-write with O_CREAT | O_EXCL (Node's `wx` flag) so
|
|
676
|
+
// the destination open refuses to follow a symlink at the leaf.
|
|
677
|
+
// This closes the residual race window between the unlinkSync
|
|
678
|
+
// above and the destination open: a same-user attacker who
|
|
679
|
+
// re-plants a symlink at `to` after our unlink would have made
|
|
680
|
+
// a default cpSync (or any non-EXCL open) follow it. EXCL on
|
|
681
|
+
// its own implies "do not follow symlinks at the final
|
|
682
|
+
// component" per POSIX open(2), so we don't need an explicit
|
|
683
|
+
// O_NOFOLLOW. mode is preserved from the source so executable
|
|
684
|
+
// bits on bin/* survive the copy.
|
|
685
|
+
const content = readFileSync(from);
|
|
686
|
+
writeFileSync(to, content, {
|
|
687
|
+
flag: "wx",
|
|
688
|
+
mode: stFrom.mode & 0o777,
|
|
689
|
+
});
|
|
690
|
+
healed.push(rel);
|
|
691
|
+
} catch {
|
|
692
|
+
/* best-effort: keep going so as many files as possible get restored */
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const argsRewritten = rewritePluginJsonArgs(pluginRoot);
|
|
697
|
+
const stillMissing = expanded.filter((rel) => !existsSync(join(pluginRoot, rel)));
|
|
698
|
+
|
|
699
|
+
const result = {
|
|
700
|
+
healed,
|
|
701
|
+
stillMissing,
|
|
702
|
+
argsRewritten,
|
|
703
|
+
pkgSource,
|
|
704
|
+
missingBefore,
|
|
705
|
+
pluginRoot,
|
|
706
|
+
};
|
|
707
|
+
if (log) logHealResult(result);
|
|
708
|
+
return result;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// Default export for ergonomic call sites that don't need the named exports.
|
|
712
|
+
export default healPartialInstallFromMarketplace;
|