context-mode 1.0.148 → 1.0.150
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/plugin.json +1 -1
- package/.openclaw-plugin/openclaw.plugin.json +1 -1
- package/.openclaw-plugin/package.json +1 -1
- package/build/cache-heal.d.ts +48 -0
- package/build/cache-heal.js +150 -0
- package/build/executor.d.ts +9 -0
- package/build/executor.js +6 -2
- package/build/opencode-plugin.js +5 -2
- package/build/routing-block.d.ts +8 -0
- package/build/routing-block.js +86 -0
- package/build/server.d.ts +12 -0
- package/build/server.js +68 -4
- package/build/store-directory.d.ts +56 -0
- package/build/store-directory.js +254 -0
- package/build/store.d.ts +29 -0
- package/build/store.js +46 -0
- package/build/tool-naming.d.ts +4 -0
- package/build/tool-naming.js +24 -0
- package/build/util/plugin-cache-integrity.d.ts +14 -0
- package/build/util/plugin-cache-integrity.js +41 -0
- package/build/util/project-dir.d.ts +43 -0
- package/build/util/project-dir.js +102 -1
- package/cli.bundle.mjs +149 -147
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/server.bundle.mjs +134 -132
- package/build/util/db-lock.d.ts +0 -65
- package/build/util/db-lock.js +0 -166
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* walkDirectory — bounded recursive directory walker for ctx_index (#687).
|
|
3
|
+
*
|
|
4
|
+
* Issue: ctx_index refused directory paths via the security gate at
|
|
5
|
+
* src/store.ts:845 ("refusing to index <path>: not a regular file"). The gate
|
|
6
|
+
* is a TOCTOU defense from #442 round-3 and MUST be preserved — directory
|
|
7
|
+
* support is layered as a separate concern here. Each file produced by
|
|
8
|
+
* walkDirectory is then read via the existing per-file
|
|
9
|
+
* `openSync + fstatSync.isFile()` invariant in `ContentStore.index()`.
|
|
10
|
+
*
|
|
11
|
+
* Reported by @matiasduartee across 4 clients × Windows 11.
|
|
12
|
+
* https://github.com/anthropic-experimental/context-mode/issues/687
|
|
13
|
+
*
|
|
14
|
+
* Design constraints:
|
|
15
|
+
* - No new dependencies (avoid the `ignore` package — issue #687 Diagnose).
|
|
16
|
+
* - Cross-OS: path.sep / path.join everywhere, never raw "/" string ops.
|
|
17
|
+
* - Symlink cycle detection via a resolved-path Set.
|
|
18
|
+
* - Symlink-escape rejection: refuse to follow symlinks that resolve outside
|
|
19
|
+
* the rootPath (defense-in-depth alongside per-file checkFilePathDenyPolicy).
|
|
20
|
+
* - FTS5-blowup guard: hard cap maxFiles (default 200, per Architect).
|
|
21
|
+
*/
|
|
22
|
+
import { readdirSync, statSync, lstatSync, realpathSync, existsSync, readFileSync, } from "node:fs";
|
|
23
|
+
import { join, extname, relative, sep, resolve } from "node:path";
|
|
24
|
+
const DEFAULT_EXCLUDES = [
|
|
25
|
+
"node_modules",
|
|
26
|
+
".git",
|
|
27
|
+
"dist",
|
|
28
|
+
"build",
|
|
29
|
+
".next",
|
|
30
|
+
"coverage",
|
|
31
|
+
".venv",
|
|
32
|
+
"__pycache__",
|
|
33
|
+
".DS_Store",
|
|
34
|
+
];
|
|
35
|
+
const DEFAULT_EXTENSIONS = [
|
|
36
|
+
".md",
|
|
37
|
+
".mdx",
|
|
38
|
+
".txt",
|
|
39
|
+
".json",
|
|
40
|
+
".yaml",
|
|
41
|
+
".yml",
|
|
42
|
+
".ts",
|
|
43
|
+
".tsx",
|
|
44
|
+
".js",
|
|
45
|
+
".jsx",
|
|
46
|
+
".py",
|
|
47
|
+
".rs",
|
|
48
|
+
".go",
|
|
49
|
+
".sh",
|
|
50
|
+
];
|
|
51
|
+
const DEFAULT_MAX_DEPTH = 5;
|
|
52
|
+
const DEFAULT_MAX_FILES = 200;
|
|
53
|
+
/**
|
|
54
|
+
* Convert a simple glob pattern (`*`, `**`, `?`) to a RegExp. Anchors at
|
|
55
|
+
* boundaries so `node_modules` matches `node_modules` AND `node_modules/pkg`.
|
|
56
|
+
* Patterns are matched against POSIX-style relative paths (forward slashes)
|
|
57
|
+
* to give consistent behavior across macOS / Windows.
|
|
58
|
+
*/
|
|
59
|
+
function globToRegExp(pattern) {
|
|
60
|
+
// Escape regex metachars except glob ones.
|
|
61
|
+
let re = "";
|
|
62
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
63
|
+
const c = pattern[i];
|
|
64
|
+
if (c === "*") {
|
|
65
|
+
if (pattern[i + 1] === "*") {
|
|
66
|
+
re += ".*";
|
|
67
|
+
i++;
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
re += "[^/]*";
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
else if (c === "?") {
|
|
74
|
+
re += "[^/]";
|
|
75
|
+
}
|
|
76
|
+
else if ("\\^$.|+()[]{}".includes(c)) {
|
|
77
|
+
re += "\\" + c;
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
re += c;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return new RegExp(`^${re}$`);
|
|
84
|
+
}
|
|
85
|
+
/** Match a posix-style relative path against any of the patterns. */
|
|
86
|
+
function matchesAny(relPosix, patterns) {
|
|
87
|
+
if (patterns.length === 0)
|
|
88
|
+
return false;
|
|
89
|
+
const basename = relPosix.split("/").pop() ?? relPosix;
|
|
90
|
+
for (const p of patterns) {
|
|
91
|
+
// Bare names match basename OR any path segment.
|
|
92
|
+
if (!p.includes("/") && !p.includes("*")) {
|
|
93
|
+
if (basename === p)
|
|
94
|
+
return true;
|
|
95
|
+
if (relPosix.split("/").includes(p))
|
|
96
|
+
return true;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const re = globToRegExp(p);
|
|
100
|
+
if (re.test(relPosix))
|
|
101
|
+
return true;
|
|
102
|
+
if (re.test(basename))
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Parse a .gitignore file into a list of patterns. Comments and blank lines
|
|
109
|
+
* are stripped. Negation (`!`) is not supported — kept conservative.
|
|
110
|
+
*/
|
|
111
|
+
function parseGitignore(rootPath) {
|
|
112
|
+
const giPath = join(rootPath, ".gitignore");
|
|
113
|
+
if (!existsSync(giPath))
|
|
114
|
+
return [];
|
|
115
|
+
try {
|
|
116
|
+
const text = readFileSync(giPath, "utf-8");
|
|
117
|
+
return text
|
|
118
|
+
.split(/\r?\n/)
|
|
119
|
+
.map(l => l.trim())
|
|
120
|
+
.filter(l => l.length > 0 && !l.startsWith("#") && !l.startsWith("!"))
|
|
121
|
+
.map(l => l.replace(/^\//, "").replace(/\/$/, ""));
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Convert an absolute path under rootPath to a POSIX-style relative path
|
|
129
|
+
* so glob matching is identical across macOS/Linux/Windows.
|
|
130
|
+
*/
|
|
131
|
+
function toPosixRel(rootPath, absPath) {
|
|
132
|
+
const rel = relative(rootPath, absPath);
|
|
133
|
+
return rel.split(sep).join("/");
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Walk `rootPath` recursively under the given bounds and return absolute file
|
|
137
|
+
* paths matching the filters. Pure synchronous traversal — no allocations
|
|
138
|
+
* beyond the result array. Symlink cycles are detected via a resolved-path
|
|
139
|
+
* Set; symlink escapes (resolving outside rootPath) are silently skipped.
|
|
140
|
+
*/
|
|
141
|
+
export function walkDirectory(rootPath, opts = {}) {
|
|
142
|
+
return walkDirectoryDetailed(rootPath, opts).files;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Same as walkDirectory but returns capped + totalSeen so callers can surface
|
|
146
|
+
* a "capped at N files" notice in their response.
|
|
147
|
+
*/
|
|
148
|
+
export function walkDirectoryDetailed(rootPath, opts = {}) {
|
|
149
|
+
const { include, exclude, maxDepth = DEFAULT_MAX_DEPTH, maxFiles = DEFAULT_MAX_FILES, extensions, respectGitignore = true, followSymlinks = false, } = opts;
|
|
150
|
+
// Normalize rootPath to its real path so symlink-escape detection is sound.
|
|
151
|
+
let rootReal;
|
|
152
|
+
try {
|
|
153
|
+
rootReal = realpathSync(rootPath);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return { files: [], capped: false, totalSeen: 0 };
|
|
157
|
+
}
|
|
158
|
+
const exts = (extensions && extensions.length > 0 ? extensions : DEFAULT_EXTENSIONS)
|
|
159
|
+
.map(e => (e.startsWith(".") ? e : "." + e).toLowerCase());
|
|
160
|
+
const excludes = [
|
|
161
|
+
...DEFAULT_EXCLUDES,
|
|
162
|
+
...(exclude ?? []),
|
|
163
|
+
...(respectGitignore ? parseGitignore(rootReal) : []),
|
|
164
|
+
];
|
|
165
|
+
const includes = include ?? [];
|
|
166
|
+
const out = [];
|
|
167
|
+
const visited = new Set([rootReal]);
|
|
168
|
+
let totalSeen = 0;
|
|
169
|
+
let capped = false;
|
|
170
|
+
function walk(absDir, depth) {
|
|
171
|
+
if (capped)
|
|
172
|
+
return;
|
|
173
|
+
if (depth > maxDepth)
|
|
174
|
+
return;
|
|
175
|
+
let entries;
|
|
176
|
+
try {
|
|
177
|
+
entries = readdirSync(absDir, { withFileTypes: true });
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return; // unreadable directory — skip silently
|
|
181
|
+
}
|
|
182
|
+
for (const ent of entries) {
|
|
183
|
+
if (capped)
|
|
184
|
+
return;
|
|
185
|
+
const absChild = join(absDir, ent.name);
|
|
186
|
+
const relPosix = toPosixRel(rootReal, absChild);
|
|
187
|
+
// Exclude check applies to both files and dirs — early prune.
|
|
188
|
+
if (matchesAny(relPosix, excludes))
|
|
189
|
+
continue;
|
|
190
|
+
// Include filter applies to files only — see below.
|
|
191
|
+
// Resolve symlinks once; reject escapes; track for cycle detection.
|
|
192
|
+
let isDirChild = ent.isDirectory();
|
|
193
|
+
let isFileChild = ent.isFile();
|
|
194
|
+
let isSymlink = false;
|
|
195
|
+
try {
|
|
196
|
+
const lst = lstatSync(absChild);
|
|
197
|
+
isSymlink = lst.isSymbolicLink();
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (isSymlink) {
|
|
203
|
+
if (!followSymlinks)
|
|
204
|
+
continue;
|
|
205
|
+
let resolved;
|
|
206
|
+
try {
|
|
207
|
+
resolved = realpathSync(absChild);
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
continue; // dangling
|
|
211
|
+
}
|
|
212
|
+
// Symlink-escape: refuse to follow if the resolved target leaves rootReal.
|
|
213
|
+
const escapeRel = relative(rootReal, resolved);
|
|
214
|
+
if (escapeRel.startsWith("..") || resolve(escapeRel) === resolved) {
|
|
215
|
+
// resolve(absolute) === absolute → target is absolute outside root
|
|
216
|
+
if (escapeRel.startsWith(".."))
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (visited.has(resolved))
|
|
220
|
+
continue;
|
|
221
|
+
visited.add(resolved);
|
|
222
|
+
try {
|
|
223
|
+
const st = statSync(resolved);
|
|
224
|
+
isDirChild = st.isDirectory();
|
|
225
|
+
isFileChild = st.isFile();
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (isDirChild) {
|
|
232
|
+
walk(absChild, depth + 1);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (!isFileChild)
|
|
236
|
+
continue;
|
|
237
|
+
// Extension filter.
|
|
238
|
+
const ext = extname(absChild).toLowerCase();
|
|
239
|
+
if (!exts.includes(ext))
|
|
240
|
+
continue;
|
|
241
|
+
// Include filter (if any): file must match at least one include pattern.
|
|
242
|
+
if (includes.length > 0 && !matchesAny(relPosix, includes))
|
|
243
|
+
continue;
|
|
244
|
+
totalSeen++;
|
|
245
|
+
if (out.length >= maxFiles) {
|
|
246
|
+
capped = true;
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
out.push(absChild);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
walk(rootReal, 0);
|
|
253
|
+
return { files: out, capped, totalSeen };
|
|
254
|
+
}
|
package/build/store.d.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* Use for documentation, API references, and any content where
|
|
8
8
|
* you need EXACT text later — not summaries.
|
|
9
9
|
*/
|
|
10
|
+
import { type WalkOptions } from "./store-directory.js";
|
|
10
11
|
type SourceMatchMode = "like" | "exact";
|
|
11
12
|
import type { IndexResult, SearchResult, StoreStats } from "./types.js";
|
|
12
13
|
export type { IndexResult, SearchResult, StoreStats } from "./types.js";
|
|
@@ -51,6 +52,34 @@ export declare class ContentStore {
|
|
|
51
52
|
eventId?: string;
|
|
52
53
|
};
|
|
53
54
|
}): IndexResult;
|
|
55
|
+
/**
|
|
56
|
+
* Index every file under a directory by walking it with `walkDirectory` and
|
|
57
|
+
* delegating each discovered file to `this.index({ path })`. The per-file
|
|
58
|
+
* `openSync + fstatSync.isFile()` security gate at line ~845 stays active
|
|
59
|
+
* for every file — directory support never bypasses the TOCTOU defense
|
|
60
|
+
* from #442 round-3.
|
|
61
|
+
*
|
|
62
|
+
* Reported by @matiasduartee in #687.
|
|
63
|
+
*/
|
|
64
|
+
indexDirectory(opts: {
|
|
65
|
+
path: string;
|
|
66
|
+
source?: string;
|
|
67
|
+
attribution?: {
|
|
68
|
+
sessionId?: string;
|
|
69
|
+
eventId?: string;
|
|
70
|
+
};
|
|
71
|
+
/** Optional per-file deny check — runs INSIDE the walk loop so a denied
|
|
72
|
+
* file does not even open a fd. Returns true to deny. */
|
|
73
|
+
perFileDeny?: (absPath: string) => boolean;
|
|
74
|
+
} & WalkOptions): {
|
|
75
|
+
filesIndexed: number;
|
|
76
|
+
totalChunks: number;
|
|
77
|
+
capped: boolean;
|
|
78
|
+
totalSeen: number;
|
|
79
|
+
denied: number;
|
|
80
|
+
failed: number;
|
|
81
|
+
label: string;
|
|
82
|
+
};
|
|
54
83
|
/**
|
|
55
84
|
* Index plain-text output (logs, build output, test results) by splitting
|
|
56
85
|
* into fixed-size line groups. Unlike markdown indexing, this does not
|
package/build/store.js
CHANGED
|
@@ -13,6 +13,7 @@ import { readFileSync, readdirSync, unlinkSync, existsSync, statSync, openSync,
|
|
|
13
13
|
import { createHash } from "node:crypto";
|
|
14
14
|
import { tmpdir } from "node:os";
|
|
15
15
|
import { join } from "node:path";
|
|
16
|
+
import { walkDirectoryDetailed } from "./store-directory.js";
|
|
16
17
|
// ─────────────────────────────────────────────────────────
|
|
17
18
|
// Constants
|
|
18
19
|
// ─────────────────────────────────────────────────────────
|
|
@@ -756,6 +757,51 @@ export class ContentStore {
|
|
|
756
757
|
const contentHash = filePath ? createHash("sha256").update(text).digest("hex") : undefined;
|
|
757
758
|
return withRetry(() => this.#insertChunks(chunks, label, text, filePath, contentHash, attribution));
|
|
758
759
|
}
|
|
760
|
+
// ── Index Directory (#687) ──
|
|
761
|
+
/**
|
|
762
|
+
* Index every file under a directory by walking it with `walkDirectory` and
|
|
763
|
+
* delegating each discovered file to `this.index({ path })`. The per-file
|
|
764
|
+
* `openSync + fstatSync.isFile()` security gate at line ~845 stays active
|
|
765
|
+
* for every file — directory support never bypasses the TOCTOU defense
|
|
766
|
+
* from #442 round-3.
|
|
767
|
+
*
|
|
768
|
+
* Reported by @matiasduartee in #687.
|
|
769
|
+
*/
|
|
770
|
+
indexDirectory(opts) {
|
|
771
|
+
const { path: rootPath, source, attribution, perFileDeny, ...walkOpts } = opts;
|
|
772
|
+
const walked = walkDirectoryDetailed(rootPath, walkOpts);
|
|
773
|
+
let filesIndexed = 0;
|
|
774
|
+
let totalChunks = 0;
|
|
775
|
+
let denied = 0;
|
|
776
|
+
let failed = 0;
|
|
777
|
+
for (const file of walked.files) {
|
|
778
|
+
if (perFileDeny && perFileDeny(file)) {
|
|
779
|
+
denied++;
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
782
|
+
try {
|
|
783
|
+
// Per-file source label so ctx_search(source: "<file>") still works.
|
|
784
|
+
const fileSource = source ? `${source}:${file}` : file;
|
|
785
|
+
const r = this.index({ path: file, source: fileSource, attribution });
|
|
786
|
+
filesIndexed++;
|
|
787
|
+
totalChunks += r.totalChunks;
|
|
788
|
+
}
|
|
789
|
+
catch {
|
|
790
|
+
// Per-file failure (e.g. fd-bound fstat rejection of a non-regular
|
|
791
|
+
// file that races between walk and read) — count + continue.
|
|
792
|
+
failed++;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
return {
|
|
796
|
+
filesIndexed,
|
|
797
|
+
totalChunks,
|
|
798
|
+
capped: walked.capped,
|
|
799
|
+
totalSeen: walked.totalSeen,
|
|
800
|
+
denied,
|
|
801
|
+
failed,
|
|
802
|
+
label: source ?? rootPath,
|
|
803
|
+
};
|
|
804
|
+
}
|
|
759
805
|
// ── Index Plain Text ──
|
|
760
806
|
/**
|
|
761
807
|
* Index plain-text output (logs, build output, test results) by splitting
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const TOOL_PREFIXES = {
|
|
2
|
+
"claude-code": (tool) => `mcp__plugin_context-mode_context-mode__${tool}`,
|
|
3
|
+
"gemini-cli": (tool) => `mcp__context-mode__${tool}`,
|
|
4
|
+
"antigravity": (tool) => `mcp__context-mode__${tool}`,
|
|
5
|
+
"opencode": (tool) => `context-mode_${tool}`,
|
|
6
|
+
"kilo": (tool) => `context-mode_${tool}`,
|
|
7
|
+
"vscode-copilot": (tool) => `context-mode_${tool}`,
|
|
8
|
+
"jetbrains-copilot": (tool) => `context-mode_${tool}`,
|
|
9
|
+
"kiro": (tool) => `@context-mode/${tool}`,
|
|
10
|
+
"zed": (tool) => `mcp:context-mode:${tool}`,
|
|
11
|
+
"cursor": (tool) => tool,
|
|
12
|
+
"codex": (tool) => tool,
|
|
13
|
+
"openclaw": (tool) => tool,
|
|
14
|
+
"pi": (tool) => tool,
|
|
15
|
+
"qwen-code": (tool) => `mcp__context-mode__${tool}`,
|
|
16
|
+
};
|
|
17
|
+
export function getToolName(platform, bareTool) {
|
|
18
|
+
const fn = TOOL_PREFIXES[platform] || TOOL_PREFIXES["claude-code"];
|
|
19
|
+
return fn(bareTool);
|
|
20
|
+
}
|
|
21
|
+
export function createToolNamer(platform) {
|
|
22
|
+
return (bareTool) => getToolName(platform, bareTool);
|
|
23
|
+
}
|
|
24
|
+
export const KNOWN_PLATFORMS = Object.keys(TOOL_PREFIXES);
|
|
@@ -22,6 +22,20 @@
|
|
|
22
22
|
* (package.json files[]); a missing helper means the install is
|
|
23
23
|
* fundamentally broken.
|
|
24
24
|
*/
|
|
25
|
+
/**
|
|
26
|
+
* Files `start.mjs` needs to launch the MCP server, checked dependency-free
|
|
27
|
+
* (fs only) so this works even when the integrity helper
|
|
28
|
+
* (`scripts/plugin-cache-integrity.mjs`) is itself missing — a missing helper
|
|
29
|
+
* is itself a partial-install symptom, and the operator most needs to know
|
|
30
|
+
* whether the launch entrypoint survived.
|
|
31
|
+
*
|
|
32
|
+
* - `start.mjs` is the plugin `command` target (`.claude-plugin/plugin.json`)
|
|
33
|
+
* and has NO fallback: if absent, `node ${CLAUDE_PLUGIN_ROOT}/start.mjs`
|
|
34
|
+
* fails immediately and the MCP server never starts.
|
|
35
|
+
* - The server is loaded by start.mjs from `server.bundle.mjs`, falling back
|
|
36
|
+
* to `build/server.js`; it is only "missing" when BOTH are absent.
|
|
37
|
+
*/
|
|
38
|
+
export declare function findMissingLaunchFiles(pluginRoot: string): string[];
|
|
25
39
|
/**
|
|
26
40
|
* Run the integrity check synchronously. If the helper module is
|
|
27
41
|
* still loading (not yet cached) returns a FAIL with detail
|
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
* (package.json files[]); a missing helper means the install is
|
|
23
23
|
* fundamentally broken.
|
|
24
24
|
*/
|
|
25
|
+
import { existsSync } from "node:fs";
|
|
26
|
+
import { join } from "node:path";
|
|
25
27
|
let cached = null;
|
|
26
28
|
let cachedError = null;
|
|
27
29
|
async function loadHelper() {
|
|
@@ -67,6 +69,30 @@ async function loadHelper() {
|
|
|
67
69
|
// intentionally — by the time any HealthCheck.check() runs (doctor
|
|
68
70
|
// command, well after MCP server boot), the import has resolved.
|
|
69
71
|
void loadHelper();
|
|
72
|
+
/**
|
|
73
|
+
* Files `start.mjs` needs to launch the MCP server, checked dependency-free
|
|
74
|
+
* (fs only) so this works even when the integrity helper
|
|
75
|
+
* (`scripts/plugin-cache-integrity.mjs`) is itself missing — a missing helper
|
|
76
|
+
* is itself a partial-install symptom, and the operator most needs to know
|
|
77
|
+
* whether the launch entrypoint survived.
|
|
78
|
+
*
|
|
79
|
+
* - `start.mjs` is the plugin `command` target (`.claude-plugin/plugin.json`)
|
|
80
|
+
* and has NO fallback: if absent, `node ${CLAUDE_PLUGIN_ROOT}/start.mjs`
|
|
81
|
+
* fails immediately and the MCP server never starts.
|
|
82
|
+
* - The server is loaded by start.mjs from `server.bundle.mjs`, falling back
|
|
83
|
+
* to `build/server.js`; it is only "missing" when BOTH are absent.
|
|
84
|
+
*/
|
|
85
|
+
export function findMissingLaunchFiles(pluginRoot) {
|
|
86
|
+
const missing = [];
|
|
87
|
+
if (!existsSync(join(pluginRoot, "start.mjs"))) {
|
|
88
|
+
missing.push("start.mjs");
|
|
89
|
+
}
|
|
90
|
+
if (!existsSync(join(pluginRoot, "server.bundle.mjs")) &&
|
|
91
|
+
!existsSync(join(pluginRoot, "build", "server.js"))) {
|
|
92
|
+
missing.push("server.bundle.mjs (or build/server.js)");
|
|
93
|
+
}
|
|
94
|
+
return missing;
|
|
95
|
+
}
|
|
70
96
|
/**
|
|
71
97
|
* Run the integrity check synchronously. If the helper module is
|
|
72
98
|
* still loading (not yet cached) returns a FAIL with detail
|
|
@@ -89,6 +115,21 @@ export function checkPluginCacheIntegritySync(pluginRoot) {
|
|
|
89
115
|
};
|
|
90
116
|
}
|
|
91
117
|
if (cachedError) {
|
|
118
|
+
// The integrity helper (scripts/plugin-cache-integrity.mjs) ships in
|
|
119
|
+
// package.json files[]; if it failed to load, the install is already
|
|
120
|
+
// partial. Don't stop at "helper unavailable" — directly surface whether
|
|
121
|
+
// the launch entrypoint survived, because a missing start.mjs / server
|
|
122
|
+
// bundle is exactly what stops the MCP server from starting (and is what
|
|
123
|
+
// an interrupted /ctx-upgrade swap leaves behind).
|
|
124
|
+
const launchMissing = findMissingLaunchFiles(pluginRoot);
|
|
125
|
+
if (launchMissing.length > 0) {
|
|
126
|
+
return {
|
|
127
|
+
status: "FAIL",
|
|
128
|
+
detail: `partial install — critical launch files missing: ${launchMissing.join(", ")} ` +
|
|
129
|
+
`(integrity helper also missing: ${cachedError}); the MCP server cannot start. ` +
|
|
130
|
+
`Reinstall: npm install -g context-mode@latest`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
92
133
|
return {
|
|
93
134
|
status: "FAIL",
|
|
94
135
|
detail: `integrity helper unavailable: ${cachedError}`,
|
|
@@ -60,6 +60,42 @@ export declare function resolveProjectDirFromTranscript(opts: {
|
|
|
60
60
|
/** Test seam for maxAgeMs. Defaults to Date.now(). */
|
|
61
61
|
nowMs?: number;
|
|
62
62
|
}): string | undefined;
|
|
63
|
+
/**
|
|
64
|
+
* Issue #45 / c4529042182 — recover the project-cwd from a Codex CLI
|
|
65
|
+
* session log when the spawned MCP child inherits a non-project cwd
|
|
66
|
+
* (e.g. $HOME when Codex was launched from anywhere outside the project).
|
|
67
|
+
*
|
|
68
|
+
* Codex writes its session transcripts to
|
|
69
|
+
* `${CODEX_HOME ?? ~/.codex}/sessions/<uuid>.jsonl`. The first line is a
|
|
70
|
+
* `SessionMeta` JSON struct whose `meta.cwd` field carries the literal
|
|
71
|
+
* project directory the CLI was launched from (see refs/platforms/codex/
|
|
72
|
+
* codex-rs SessionMeta). Codex publishes NO workspace env var to its child
|
|
73
|
+
* MCP processes — so unlike Claude/Pi/Cursor, we have no env signal at all.
|
|
74
|
+
* The session log is the strongest available signal.
|
|
75
|
+
*
|
|
76
|
+
* Mirror of `resolveProjectDirFromTranscript` for Claude Code; differences:
|
|
77
|
+
* • Sessions live flat in `${codexHome}/sessions/*.jsonl` (no per-project
|
|
78
|
+
* encoded subdir like Claude's `~/.claude/projects/<encoded>/`).
|
|
79
|
+
* • The cwd is on `meta.cwd` (nested), not top-level `cwd`.
|
|
80
|
+
*
|
|
81
|
+
* Returns `null` when:
|
|
82
|
+
* • `codexHome` or its `sessions/` subdir does not exist.
|
|
83
|
+
* • No `.jsonl` files exist or none has a parseable `meta.cwd` string.
|
|
84
|
+
* • The newest log is older than `transcriptMaxAgeMs` (multi-window guard).
|
|
85
|
+
* • The resolved `meta.cwd` points at a plugin install path (poisoned).
|
|
86
|
+
*/
|
|
87
|
+
export declare function resolveCodexSessionCwd(opts?: {
|
|
88
|
+
/** Defaults to `process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex")`. */
|
|
89
|
+
codexHome?: string;
|
|
90
|
+
/**
|
|
91
|
+
* Optional freshness guard — Codex appends to the active log while the
|
|
92
|
+
* session is running, so a stale log from days ago must not become a
|
|
93
|
+
* global project-dir signal.
|
|
94
|
+
*/
|
|
95
|
+
transcriptMaxAgeMs?: number;
|
|
96
|
+
/** Test seam for transcriptMaxAgeMs. Defaults to Date.now(). */
|
|
97
|
+
now?: number;
|
|
98
|
+
}): string | null;
|
|
63
99
|
/**
|
|
64
100
|
* Pure project-dir resolver. Mirror of the env-var chain inside
|
|
65
101
|
* `src/server.ts getProjectDir()`, but takes its inputs explicitly so the
|
|
@@ -100,4 +136,11 @@ export declare function resolveProjectDir(opts: {
|
|
|
100
136
|
* for `start.mjs` and any non-strict consumer).
|
|
101
137
|
*/
|
|
102
138
|
strictPlatform?: PlatformId;
|
|
139
|
+
/**
|
|
140
|
+
* Issue #45 — override `${CODEX_HOME ?? ~/.codex}` for tests. When
|
|
141
|
+
* `strictPlatform === "codex"` and the env cascade yields nothing, the
|
|
142
|
+
* resolver reads `meta.cwd` from the newest session.jsonl under
|
|
143
|
+
* `${codexHome}/sessions/`.
|
|
144
|
+
*/
|
|
145
|
+
codexHome?: string;
|
|
103
146
|
}): string;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
2
3
|
import * as path from "node:path";
|
|
3
4
|
import { workspaceEnvVarsFor } from "../adapters/detect.js";
|
|
4
5
|
/**
|
|
@@ -156,6 +157,94 @@ export function resolveProjectDirFromTranscript(opts) {
|
|
|
156
157
|
catch { /* file vanished mid-read */ }
|
|
157
158
|
return undefined;
|
|
158
159
|
}
|
|
160
|
+
/**
|
|
161
|
+
* Issue #45 / c4529042182 — recover the project-cwd from a Codex CLI
|
|
162
|
+
* session log when the spawned MCP child inherits a non-project cwd
|
|
163
|
+
* (e.g. $HOME when Codex was launched from anywhere outside the project).
|
|
164
|
+
*
|
|
165
|
+
* Codex writes its session transcripts to
|
|
166
|
+
* `${CODEX_HOME ?? ~/.codex}/sessions/<uuid>.jsonl`. The first line is a
|
|
167
|
+
* `SessionMeta` JSON struct whose `meta.cwd` field carries the literal
|
|
168
|
+
* project directory the CLI was launched from (see refs/platforms/codex/
|
|
169
|
+
* codex-rs SessionMeta). Codex publishes NO workspace env var to its child
|
|
170
|
+
* MCP processes — so unlike Claude/Pi/Cursor, we have no env signal at all.
|
|
171
|
+
* The session log is the strongest available signal.
|
|
172
|
+
*
|
|
173
|
+
* Mirror of `resolveProjectDirFromTranscript` for Claude Code; differences:
|
|
174
|
+
* • Sessions live flat in `${codexHome}/sessions/*.jsonl` (no per-project
|
|
175
|
+
* encoded subdir like Claude's `~/.claude/projects/<encoded>/`).
|
|
176
|
+
* • The cwd is on `meta.cwd` (nested), not top-level `cwd`.
|
|
177
|
+
*
|
|
178
|
+
* Returns `null` when:
|
|
179
|
+
* • `codexHome` or its `sessions/` subdir does not exist.
|
|
180
|
+
* • No `.jsonl` files exist or none has a parseable `meta.cwd` string.
|
|
181
|
+
* • The newest log is older than `transcriptMaxAgeMs` (multi-window guard).
|
|
182
|
+
* • The resolved `meta.cwd` points at a plugin install path (poisoned).
|
|
183
|
+
*/
|
|
184
|
+
export function resolveCodexSessionCwd(opts) {
|
|
185
|
+
const codexHome = opts?.codexHome ?? process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
|
|
186
|
+
const sessionsDir = path.join(codexHome, "sessions");
|
|
187
|
+
if (!fs.existsSync(sessionsDir))
|
|
188
|
+
return null;
|
|
189
|
+
let bestPath;
|
|
190
|
+
let bestMtime = 0;
|
|
191
|
+
try {
|
|
192
|
+
for (const f of fs.readdirSync(sessionsDir)) {
|
|
193
|
+
if (!f.endsWith(".jsonl"))
|
|
194
|
+
continue;
|
|
195
|
+
const fp = path.join(sessionsDir, f);
|
|
196
|
+
try {
|
|
197
|
+
const m = fs.statSync(fp).mtimeMs;
|
|
198
|
+
if (m > bestMtime) {
|
|
199
|
+
bestMtime = m;
|
|
200
|
+
bestPath = fp;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
catch { /* skip */ }
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
if (!bestPath)
|
|
210
|
+
return null;
|
|
211
|
+
if (typeof opts?.transcriptMaxAgeMs === "number") {
|
|
212
|
+
const nowMs = opts.now ?? Date.now();
|
|
213
|
+
if (nowMs - bestMtime > opts.transcriptMaxAgeMs)
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
// Read first ~8KB; the SessionMeta JSON is line 1 and small. Stream-cap
|
|
217
|
+
// mirrors `resolveProjectDirFromTranscript` for memory safety on long logs.
|
|
218
|
+
try {
|
|
219
|
+
const fd = fs.openSync(bestPath, "r");
|
|
220
|
+
try {
|
|
221
|
+
const buf = Buffer.alloc(8192);
|
|
222
|
+
const bytes = fs.readSync(fd, buf, 0, buf.length, 0);
|
|
223
|
+
const text = buf.subarray(0, bytes).toString("utf-8");
|
|
224
|
+
const firstLine = text.split("\n", 1)[0];
|
|
225
|
+
if (!firstLine || !firstLine.trim())
|
|
226
|
+
return null;
|
|
227
|
+
try {
|
|
228
|
+
const obj = JSON.parse(firstLine);
|
|
229
|
+
const cwd = obj?.meta?.cwd;
|
|
230
|
+
if (typeof cwd !== "string" || cwd.length === 0)
|
|
231
|
+
return null;
|
|
232
|
+
if (isPluginInstallPath(cwd))
|
|
233
|
+
return null;
|
|
234
|
+
return cwd;
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
return null; /* malformed first line */
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
finally {
|
|
241
|
+
fs.closeSync(fd);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return null; /* file vanished mid-read */
|
|
246
|
+
}
|
|
247
|
+
}
|
|
159
248
|
/**
|
|
160
249
|
* Pure project-dir resolver. Mirror of the env-var chain inside
|
|
161
250
|
* `src/server.ts getProjectDir()`, but takes its inputs explicitly so the
|
|
@@ -177,7 +266,7 @@ export function resolveProjectDirFromTranscript(opts) {
|
|
|
177
266
|
* operation of project-independent tools (sandbox execute, fetch).
|
|
178
267
|
*/
|
|
179
268
|
export function resolveProjectDir(opts) {
|
|
180
|
-
const { env, cwd, pwd, transcriptsRoot, transcriptMaxAgeMs, nowMs, strictPlatform } = opts;
|
|
269
|
+
const { env, cwd, pwd, transcriptsRoot, transcriptMaxAgeMs, nowMs, strictPlatform, codexHome, } = opts;
|
|
181
270
|
// Build candidate list. Strict path: own workspace vars + universal escape
|
|
182
271
|
// hatch — NO foreign workspace vars, in any order, can win. Non-strict
|
|
183
272
|
// path: frozen legacy literal order for backwards compatibility.
|
|
@@ -198,6 +287,18 @@ export function resolveProjectDir(opts) {
|
|
|
198
287
|
if (fromTranscript && !isPluginInstallPath(fromTranscript))
|
|
199
288
|
return fromTranscript;
|
|
200
289
|
}
|
|
290
|
+
// Issue #45 — Codex has no workspace env var, so when running under
|
|
291
|
+
// strictPlatform="codex" we fall back to the session-log heuristic
|
|
292
|
+
// between env and PWD. Non-codex platforms skip this branch entirely.
|
|
293
|
+
if (strictPlatform === "codex") {
|
|
294
|
+
const fromCodex = resolveCodexSessionCwd({
|
|
295
|
+
codexHome,
|
|
296
|
+
transcriptMaxAgeMs,
|
|
297
|
+
now: nowMs,
|
|
298
|
+
});
|
|
299
|
+
if (fromCodex)
|
|
300
|
+
return fromCodex;
|
|
301
|
+
}
|
|
201
302
|
if (pwd && !isPluginInstallPath(pwd))
|
|
202
303
|
return pwd;
|
|
203
304
|
return cwd;
|