synabun 2026.7.32 → 2026.7.34
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/CHANGELOG.md +38 -0
- package/mcp-server/package-lock.json +2 -2
- package/mcp-server/package.json +1 -1
- package/neural-interface/lib/claude-agent-bridge.js +119 -10
- package/neural-interface/lib/codex-runtime-path.js +102 -35
- package/neural-interface/lib/native-binary-runtime.js +650 -0
- package/neural-interface/lib/native-loop-providers.js +28 -1
- package/neural-interface/package-lock.json +2 -2
- package/neural-interface/package.json +1 -1
- package/neural-interface/public/claude-chat.css +7 -0
- package/neural-interface/public/claude-chat.js +18 -0
- package/neural-interface/public/shared/ui-claude-panel.js +17 -0
- package/neural-interface/scripts/rebuild-pty.js +37 -23
- package/neural-interface/server.js +120 -47
- package/package.json +1 -1
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
// ── Cross-platform native binary runtime (resolve → verify → repair → diagnose) ──
|
|
2
|
+
//
|
|
3
|
+
// The Claude Agent SDK resolves its bundled native CLI with `require.resolve` +
|
|
4
|
+
// `existsSync` and nothing else — no stat, no X_OK probe, no spawn test. When the
|
|
5
|
+
// binary is present but not executable, `spawn` fails EACCES and the SDK reports:
|
|
6
|
+
//
|
|
7
|
+
// "Claude Code native binary at <p> exists but failed to launch. This usually
|
|
8
|
+
// means the binary does not match this system's libc — e.g. spawning a
|
|
9
|
+
// musl-linked binary on a glibc Linux host…"
|
|
10
|
+
//
|
|
11
|
+
// That wording is hardcoded and wrong everywhere except Linux: the SDK buckets
|
|
12
|
+
// ENOENT/EACCES/EPERM/ENOTDIR/ELOOP/ENAMETOOLONG/EROFS together and picks the
|
|
13
|
+
// message using only `existsSync`. It also throws a ReferenceError, not an Error.
|
|
14
|
+
//
|
|
15
|
+
// SynaBun hits this constantly because `node_modules` is committed with
|
|
16
|
+
// core.filemode=false, so every vendored binary checks out mode 0644.
|
|
17
|
+
//
|
|
18
|
+
// This module supplies what the SDK lacks:
|
|
19
|
+
// - a faithful reimplementation of the SDK's candidate resolution, so we know
|
|
20
|
+
// WHICH file it will pick and can stat/chmod exactly that one
|
|
21
|
+
// - exec-bit verification and repair (POSIX), plus an X_OK probe that catches
|
|
22
|
+
// modes the `& 0o111` mask misses (e.g. root-owned 0711)
|
|
23
|
+
// - a sweep over every vendored binary (Claude SDK, Codex, node-pty), built by
|
|
24
|
+
// enumeration rather than a hardcoded list
|
|
25
|
+
// - platform-accurate diagnosis + a recovery plan (repair-and-retry, then fall
|
|
26
|
+
// back to the user's global CLI)
|
|
27
|
+
//
|
|
28
|
+
// Resolution logic below mirrors @anthropic-ai/claude-agent-sdk@0.3.220. We never
|
|
29
|
+
// hand the resolved path back to the SDK on the happy path — it re-resolves for
|
|
30
|
+
// itself, and our copy exists only to know what to repair and how to explain a
|
|
31
|
+
// failure. A drift between the two therefore degrades to "we chmod'd a file
|
|
32
|
+
// nobody uses", never to launching the wrong binary.
|
|
33
|
+
//
|
|
34
|
+
// Every dependency is injectable (platform, arch, exists, stat, chmod, access,
|
|
35
|
+
// readdir, report) so all eight platform targets are testable from one host.
|
|
36
|
+
// Nothing here throws.
|
|
37
|
+
|
|
38
|
+
import { existsSync, statSync, chmodSync, accessSync, readdirSync, constants } from 'node:fs';
|
|
39
|
+
import { createRequire } from 'node:module';
|
|
40
|
+
import path from 'node:path';
|
|
41
|
+
|
|
42
|
+
export const SDK_PKG = '@anthropic-ai/claude-agent-sdk';
|
|
43
|
+
|
|
44
|
+
// Platform packages the SDK declares as optionalDependencies. Kept here so a
|
|
45
|
+
// test can assert we still match the installed SDK's own list (see the drift
|
|
46
|
+
// guard in tests/native-binary-runtime.test.mjs).
|
|
47
|
+
export const SDK_PLATFORM_PACKAGES = [
|
|
48
|
+
`${SDK_PKG}-darwin-arm64`,
|
|
49
|
+
`${SDK_PKG}-darwin-x64`,
|
|
50
|
+
`${SDK_PKG}-linux-arm64`,
|
|
51
|
+
`${SDK_PKG}-linux-arm64-musl`,
|
|
52
|
+
`${SDK_PKG}-linux-x64`,
|
|
53
|
+
`${SDK_PKG}-linux-x64-musl`,
|
|
54
|
+
`${SDK_PKG}-win32-arm64`,
|
|
55
|
+
`${SDK_PKG}-win32-x64`,
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
// The SDK's isNativeBinary(): a path NOT ending in one of these is spawned
|
|
59
|
+
// directly and needs +x. Note `.cjs` is absent — it IS spawned directly.
|
|
60
|
+
const SCRIPT_EXTENSIONS = ['.js', '.mjs', '.tsx', '.ts', '.jsx'];
|
|
61
|
+
|
|
62
|
+
// The SDK's own spawn-error bucket, plus ENOEXEC (wrong-arch / bad Mach-O),
|
|
63
|
+
// which surfaces the same way.
|
|
64
|
+
const LAUNCH_ERROR_CODES = new Set([
|
|
65
|
+
'ENOENT', 'EACCES', 'EPERM', 'ENOTDIR', 'ELOOP', 'ENAMETOOLONG', 'EROFS', 'ENOEXEC',
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
export const SDK_LIBC_MESSAGE_RE = /native binary at .+ exists but failed to launch/i;
|
|
69
|
+
|
|
70
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
71
|
+
// Resolution (faithful to the SDK)
|
|
72
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* musl detection, exactly as the SDK does it: linux-only, and an ABSENT
|
|
76
|
+
* `glibcVersionRuntime` in the diagnostic report means musl. Anything we cannot
|
|
77
|
+
* read falls back to the glibc ordering, matching the SDK.
|
|
78
|
+
*/
|
|
79
|
+
export function detectMusl({ platform = process.platform, report = process.report } = {}) {
|
|
80
|
+
if (platform !== 'linux') return false;
|
|
81
|
+
try {
|
|
82
|
+
if (typeof report?.getReport !== 'function') return false;
|
|
83
|
+
const r = report.getReport();
|
|
84
|
+
return r != null && r.header?.glibcVersionRuntime === undefined;
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The request specifiers the SDK tries, in order. Each is `<pkg>/claude`, or
|
|
92
|
+
* `<pkg>/claude.exe` on Windows. darwin and win32 get a SINGLE candidate with no
|
|
93
|
+
* fallback; only linux has a two-entry list, ordered by libc.
|
|
94
|
+
*/
|
|
95
|
+
export function sdkBinaryCandidates({
|
|
96
|
+
platform = process.platform,
|
|
97
|
+
arch = process.arch,
|
|
98
|
+
preferMusl,
|
|
99
|
+
} = {}) {
|
|
100
|
+
const musl = preferMusl === undefined ? detectMusl({ platform }) : !!preferMusl;
|
|
101
|
+
const binary = platform === 'win32' ? 'claude.exe' : 'claude';
|
|
102
|
+
|
|
103
|
+
let packages;
|
|
104
|
+
if (platform === 'android') {
|
|
105
|
+
packages = [`${SDK_PKG}-linux-${arch}-android`];
|
|
106
|
+
} else if (platform === 'linux') {
|
|
107
|
+
packages = musl
|
|
108
|
+
? [`${SDK_PKG}-linux-${arch}-musl`, `${SDK_PKG}-linux-${arch}`]
|
|
109
|
+
: [`${SDK_PKG}-linux-${arch}`, `${SDK_PKG}-linux-${arch}-musl`];
|
|
110
|
+
} else {
|
|
111
|
+
packages = [`${SDK_PKG}-${platform}-${arch}`];
|
|
112
|
+
}
|
|
113
|
+
return packages.map((p) => `${p}/${binary}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Anchor resolution at the SDK's own entry file, NOT at this module. The SDK
|
|
117
|
+
// calls createRequire(fileURLToPath(import.meta.url)) from inside sdk.mjs, so a
|
|
118
|
+
// nested or pnpm-style layout resolves from that directory's ancestry — not
|
|
119
|
+
// ours. Resolving from here would find a different (or no) platform package.
|
|
120
|
+
const _selfRequire = createRequire(import.meta.url);
|
|
121
|
+
function defaultSdkResolve(specifier) {
|
|
122
|
+
const sdkRequire = createRequire(_selfRequire.resolve(SDK_PKG));
|
|
123
|
+
return sdkRequire.resolve(specifier);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Resolve the native binary the SDK would pick.
|
|
128
|
+
* @returns {{path: string|null, specifier: string|null, candidates: string[], checked: object[]}}
|
|
129
|
+
*/
|
|
130
|
+
export function resolveSdkBinary({
|
|
131
|
+
platform = process.platform,
|
|
132
|
+
arch = process.arch,
|
|
133
|
+
preferMusl,
|
|
134
|
+
resolve = defaultSdkResolve,
|
|
135
|
+
exists = existsSync,
|
|
136
|
+
} = {}) {
|
|
137
|
+
const candidates = sdkBinaryCandidates({ platform, arch, preferMusl });
|
|
138
|
+
const checked = [];
|
|
139
|
+
|
|
140
|
+
for (const specifier of candidates) {
|
|
141
|
+
try {
|
|
142
|
+
const resolved = resolve(specifier);
|
|
143
|
+
const found = exists(resolved);
|
|
144
|
+
checked.push({ specifier, path: resolved, exists: found });
|
|
145
|
+
if (found) return { path: resolved, specifier, candidates, checked };
|
|
146
|
+
} catch (error) {
|
|
147
|
+
// MODULE_NOT_FOUND on an uninstalled optional dep — the SDK swallows this
|
|
148
|
+
// per-candidate too and moves on.
|
|
149
|
+
checked.push({ specifier, error: error?.message || String(error) });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return { path: null, specifier: null, candidates, checked };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
156
|
+
// Exec-bit verification and repair
|
|
157
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
function isScriptPath(p) {
|
|
160
|
+
const lower = String(p).toLowerCase();
|
|
161
|
+
return SCRIPT_EXTENSIONS.some((ext) => lower.endsWith(ext));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function quoteForShell(p) {
|
|
165
|
+
return `"${String(p).replace(/"/g, '\\"')}"`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Verify a binary is launchable, repairing the execute bit where we can.
|
|
170
|
+
*
|
|
171
|
+
* states: ok | repaired | missing | not-a-file | chmod-failed | access-denied
|
|
172
|
+
* | windows-bad-name | script
|
|
173
|
+
*/
|
|
174
|
+
export function ensureExecutable(binPath, {
|
|
175
|
+
platform = process.platform,
|
|
176
|
+
stat = statSync,
|
|
177
|
+
chmod = chmodSync,
|
|
178
|
+
access = accessSync,
|
|
179
|
+
} = {}) {
|
|
180
|
+
const base = { path: binPath, ok: false, state: 'missing', repaired: false, repairCommand: null };
|
|
181
|
+
if (!binPath) return { ...base, reason: 'No path given' };
|
|
182
|
+
|
|
183
|
+
let st;
|
|
184
|
+
try {
|
|
185
|
+
st = stat(binPath);
|
|
186
|
+
} catch (error) {
|
|
187
|
+
return { ...base, state: 'missing', error: error?.message || String(error), reason: `Not found: ${binPath}` };
|
|
188
|
+
}
|
|
189
|
+
if (typeof st?.isFile === 'function' && !st.isFile()) {
|
|
190
|
+
return { ...base, state: 'not-a-file', reason: `Not a regular file: ${binPath}` };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const mode = st?.mode ?? 0;
|
|
194
|
+
|
|
195
|
+
if (platform === 'win32') {
|
|
196
|
+
// Windows has no execute bit. What matters is whether the SDK's
|
|
197
|
+
// spawn(shell:false) can launch this name at all.
|
|
198
|
+
if (isScriptPath(binPath)) {
|
|
199
|
+
return { ...base, ok: true, state: 'script', mode };
|
|
200
|
+
}
|
|
201
|
+
if (/\.exe$/i.test(binPath)) {
|
|
202
|
+
return { ...base, ok: true, state: 'ok', mode };
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
...base,
|
|
206
|
+
state: 'windows-bad-name',
|
|
207
|
+
mode,
|
|
208
|
+
reason: `${binPath} is not a .exe — the SDK spawns with shell:false, which cannot launch .cmd/.bat/.ps1 shims`,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// POSIX: repair the execute bit if it is missing anywhere.
|
|
213
|
+
const repairCommand = `chmod +x ${quoteForShell(binPath)}`;
|
|
214
|
+
let repaired = false;
|
|
215
|
+
if (!(mode & 0o111)) {
|
|
216
|
+
try {
|
|
217
|
+
chmod(binPath, mode | 0o755);
|
|
218
|
+
repaired = true;
|
|
219
|
+
} catch (error) {
|
|
220
|
+
return {
|
|
221
|
+
...base,
|
|
222
|
+
state: 'chmod-failed',
|
|
223
|
+
mode,
|
|
224
|
+
repairCommand,
|
|
225
|
+
error: error?.message || String(error),
|
|
226
|
+
reason: `Cannot add the execute bit to ${binPath} (read-only or not owned by this user)`,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Always probe X_OK: the `& 0o111` mask passes on modes we still cannot run,
|
|
232
|
+
// e.g. a root-owned 0711 when we are neither the owner nor in its group.
|
|
233
|
+
try {
|
|
234
|
+
access(binPath, constants.X_OK);
|
|
235
|
+
} catch (error) {
|
|
236
|
+
return {
|
|
237
|
+
...base,
|
|
238
|
+
state: 'access-denied',
|
|
239
|
+
mode,
|
|
240
|
+
repaired,
|
|
241
|
+
repairCommand,
|
|
242
|
+
error: error?.message || String(error),
|
|
243
|
+
reason: `${binPath} is still not executable by this user`,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
...base,
|
|
249
|
+
ok: true,
|
|
250
|
+
state: repaired ? 'repaired' : 'ok',
|
|
251
|
+
mode,
|
|
252
|
+
repaired,
|
|
253
|
+
repairCommand,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
258
|
+
// Claude runtime (resolution + repair, memoized)
|
|
259
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
const _runtimeCache = new Map();
|
|
262
|
+
|
|
263
|
+
export function clearNativeBinaryCache() {
|
|
264
|
+
_runtimeCache.clear();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Resolve and repair the SDK's bundled Claude binary.
|
|
269
|
+
*
|
|
270
|
+
* The returned `path` is informational: callers should NOT pass it to the SDK as
|
|
271
|
+
* `pathToClaudeCodeExecutable` on the happy path. Calling this for its chmod
|
|
272
|
+
* side effect and letting the SDK resolve for itself keeps us immune to future
|
|
273
|
+
* changes in the SDK's resolution.
|
|
274
|
+
*/
|
|
275
|
+
export function claudeRuntime({
|
|
276
|
+
platform = process.platform,
|
|
277
|
+
arch = process.arch,
|
|
278
|
+
preferMusl,
|
|
279
|
+
resolve,
|
|
280
|
+
exists,
|
|
281
|
+
stat,
|
|
282
|
+
chmod,
|
|
283
|
+
access,
|
|
284
|
+
cache = true,
|
|
285
|
+
} = {}) {
|
|
286
|
+
const musl = preferMusl === undefined ? detectMusl({ platform }) : !!preferMusl;
|
|
287
|
+
const key = `${platform}:${arch}:${musl}`;
|
|
288
|
+
if (cache && _runtimeCache.has(key)) return _runtimeCache.get(key);
|
|
289
|
+
|
|
290
|
+
const resolved = resolveSdkBinary({ platform, arch, preferMusl: musl, resolve, exists });
|
|
291
|
+
|
|
292
|
+
let result;
|
|
293
|
+
if (!resolved.path) {
|
|
294
|
+
result = {
|
|
295
|
+
path: null,
|
|
296
|
+
specifier: null,
|
|
297
|
+
ok: false,
|
|
298
|
+
state: 'not-installed',
|
|
299
|
+
repaired: false,
|
|
300
|
+
repairCommand: null,
|
|
301
|
+
candidates: resolved.candidates,
|
|
302
|
+
checked: resolved.checked,
|
|
303
|
+
reason: `No Claude native binary is installed for ${platform}-${arch}`,
|
|
304
|
+
};
|
|
305
|
+
} else {
|
|
306
|
+
const verdict = ensureExecutable(resolved.path, { platform, stat, chmod, access });
|
|
307
|
+
result = {
|
|
308
|
+
...verdict,
|
|
309
|
+
specifier: resolved.specifier,
|
|
310
|
+
candidates: resolved.candidates,
|
|
311
|
+
checked: resolved.checked,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (cache) _runtimeCache.set(key, result);
|
|
316
|
+
return result;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
320
|
+
// Executable override contract (one contract for every SDK caller)
|
|
321
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Validate a user-supplied `pathToClaudeCodeExecutable`.
|
|
325
|
+
*
|
|
326
|
+
* The SDK uses that value verbatim with zero validation, so a bad one produces a
|
|
327
|
+
* worse failure than the one we are fixing. In particular a BARE COMMAND NAME
|
|
328
|
+
* (e.g. the string 'claude', which getClaudeBin() returns as its last resort)
|
|
329
|
+
* is a guaranteed ENOENT under spawn(shell:false) — it must be rejected here.
|
|
330
|
+
*
|
|
331
|
+
* @returns {{path: string|null, kind: 'script'|'native'|null, ok: boolean, reason: string|null, repaired?: boolean, state?: string}}
|
|
332
|
+
*/
|
|
333
|
+
export function resolveClaudeExecutableOverride(spec, {
|
|
334
|
+
platform = process.platform,
|
|
335
|
+
exists = existsSync,
|
|
336
|
+
stat,
|
|
337
|
+
chmod,
|
|
338
|
+
access,
|
|
339
|
+
} = {}) {
|
|
340
|
+
const value = typeof spec === 'string' ? spec.trim() : '';
|
|
341
|
+
if (!value) return { path: null, kind: null, ok: true, reason: null };
|
|
342
|
+
|
|
343
|
+
const pathImpl = platform === 'win32' ? path.win32 : path.posix;
|
|
344
|
+
const hasSeparator = value.includes('/') || (platform === 'win32' && value.includes('\\'));
|
|
345
|
+
if (!hasSeparator) {
|
|
346
|
+
return {
|
|
347
|
+
path: null,
|
|
348
|
+
kind: null,
|
|
349
|
+
ok: false,
|
|
350
|
+
reason: `"${value}" is a bare command name; the SDK spawns with shell:false and cannot resolve it from PATH`,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (isScriptPath(value)) {
|
|
355
|
+
// Launched via node/bun — needs to exist, but not to be executable.
|
|
356
|
+
if (!exists(value)) {
|
|
357
|
+
return { path: null, kind: 'script', ok: false, reason: `Script not found: ${value}` };
|
|
358
|
+
}
|
|
359
|
+
return { path: pathImpl.normalize(value), kind: 'script', ok: true, reason: null };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Everything else — including .cjs, which the SDK spawns DIRECTLY — must be
|
|
363
|
+
// executable in its own right.
|
|
364
|
+
const verdict = ensureExecutable(value, { platform, stat, chmod, access });
|
|
365
|
+
if (!verdict.ok) {
|
|
366
|
+
return { path: null, kind: 'native', ok: false, state: verdict.state, reason: verdict.reason || `Not launchable: ${value}` };
|
|
367
|
+
}
|
|
368
|
+
return {
|
|
369
|
+
path: pathImpl.normalize(value),
|
|
370
|
+
kind: 'native',
|
|
371
|
+
ok: true,
|
|
372
|
+
reason: null,
|
|
373
|
+
repaired: verdict.repaired,
|
|
374
|
+
state: verdict.state,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
379
|
+
// Vendored binary sweep
|
|
380
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
381
|
+
|
|
382
|
+
const CODEX_VENDOR_BIN_DIRS = ['bin', 'codex', 'path'];
|
|
383
|
+
const CODEX_VENDOR_BIN_NAMES = ['codex', 'codex.exe', 'rg', 'rg.exe'];
|
|
384
|
+
|
|
385
|
+
function safeReaddir(readdir, dir) {
|
|
386
|
+
try {
|
|
387
|
+
return readdir(dir, { withFileTypes: false }) || [];
|
|
388
|
+
} catch {
|
|
389
|
+
return [];
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Enumerate every vendored binary that must be executable, by walking the tree
|
|
395
|
+
* rather than hardcoding names. This is what keeps the sweep correct as
|
|
396
|
+
* platform packages and node-pty prebuild directories come and go — the old
|
|
397
|
+
* hardcoded list in scripts/rebuild-pty.js named linux dirs that do not exist
|
|
398
|
+
* here while missing ones that do.
|
|
399
|
+
*
|
|
400
|
+
* Every target is optional: a given checkout has only a subset installed.
|
|
401
|
+
*/
|
|
402
|
+
export function vendoredBinaryTargets({
|
|
403
|
+
root,
|
|
404
|
+
platform = process.platform,
|
|
405
|
+
exists = existsSync,
|
|
406
|
+
readdir = readdirSync,
|
|
407
|
+
} = {}) {
|
|
408
|
+
if (!root) return [];
|
|
409
|
+
const nm = path.join(root, 'node_modules');
|
|
410
|
+
const targets = [];
|
|
411
|
+
const add = (id, p, kind) => { if (exists(p)) targets.push({ id, path: p, kind }); };
|
|
412
|
+
|
|
413
|
+
// 1. Claude Agent SDK platform packages (all of them, not just this host's —
|
|
414
|
+
// a vendored checkout can carry several).
|
|
415
|
+
const anthropicDir = path.join(nm, '@anthropic-ai');
|
|
416
|
+
for (const entry of safeReaddir(readdir, anthropicDir)) {
|
|
417
|
+
const name = String(entry);
|
|
418
|
+
if (!name.startsWith('claude-agent-sdk-')) continue;
|
|
419
|
+
const pkgDir = path.join(anthropicDir, name);
|
|
420
|
+
add(`sdk:${name}`, path.join(pkgDir, 'claude'), 'sdk-claude');
|
|
421
|
+
add(`sdk:${name}:exe`, path.join(pkgDir, 'claude.exe'), 'sdk-claude');
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// 2. node-pty spawn-helper, for every prebuild directory present.
|
|
425
|
+
const ptyDir = path.join(nm, 'node-pty');
|
|
426
|
+
const prebuildsDir = path.join(ptyDir, 'prebuilds');
|
|
427
|
+
for (const entry of safeReaddir(readdir, prebuildsDir)) {
|
|
428
|
+
add(`pty:${entry}`, path.join(prebuildsDir, String(entry), 'spawn-helper'), 'pty-spawn-helper');
|
|
429
|
+
}
|
|
430
|
+
add('pty:build', path.join(ptyDir, 'build', 'Release', 'spawn-helper'), 'pty-spawn-helper');
|
|
431
|
+
|
|
432
|
+
// 3. Codex platform packages: vendor/<triple>/{bin,codex,path}/{codex,rg}.
|
|
433
|
+
// Hygiene only — server.js deliberately never falls back to the bundled
|
|
434
|
+
// Codex binary (macOS quarantine); repairing the bit must not be read as
|
|
435
|
+
// enabling that fallback.
|
|
436
|
+
const openaiDir = path.join(nm, '@openai');
|
|
437
|
+
for (const entry of safeReaddir(readdir, openaiDir)) {
|
|
438
|
+
const name = String(entry);
|
|
439
|
+
if (!name.startsWith('codex')) continue;
|
|
440
|
+
const vendorDir = path.join(openaiDir, name, 'vendor');
|
|
441
|
+
for (const triple of safeReaddir(readdir, vendorDir)) {
|
|
442
|
+
for (const binDir of CODEX_VENDOR_BIN_DIRS) {
|
|
443
|
+
for (const binName of CODEX_VENDOR_BIN_NAMES) {
|
|
444
|
+
const p = path.join(vendorDir, String(triple), binDir, binName);
|
|
445
|
+
add(`codex:${name}:${triple}:${binDir}:${binName}`, p, binName.startsWith('rg') ? 'ripgrep' : 'codex');
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
add(`codex:${name}:bin-rg`, path.join(openaiDir, name, 'bin', 'rg'), 'ripgrep');
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return targets;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Repair the execute bit on every vendored binary that needs it.
|
|
457
|
+
* No-op on Windows (no execute bit), but still enumerates for diagnostics.
|
|
458
|
+
*/
|
|
459
|
+
export function ensureVendoredExecutables({
|
|
460
|
+
root,
|
|
461
|
+
platform = process.platform,
|
|
462
|
+
exists,
|
|
463
|
+
readdir,
|
|
464
|
+
stat,
|
|
465
|
+
chmod,
|
|
466
|
+
access,
|
|
467
|
+
log = null,
|
|
468
|
+
} = {}) {
|
|
469
|
+
const targets = vendoredBinaryTargets({ root, platform, exists, readdir });
|
|
470
|
+
const result = { repaired: [], failed: [], checked: targets.length, skipped: null };
|
|
471
|
+
|
|
472
|
+
if (platform === 'win32') {
|
|
473
|
+
result.skipped = 'win32';
|
|
474
|
+
return result;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
for (const target of targets) {
|
|
478
|
+
const verdict = ensureExecutable(target.path, { platform, stat, chmod, access });
|
|
479
|
+
if (verdict.repaired) {
|
|
480
|
+
result.repaired.push(target.path);
|
|
481
|
+
if (log) log(`[binaries] restored execute permission on ${target.path}`);
|
|
482
|
+
} else if (!verdict.ok) {
|
|
483
|
+
result.failed.push({ path: target.path, state: verdict.state, error: verdict.error, repairCommand: verdict.repairCommand });
|
|
484
|
+
if (log) log(`[binaries] ${target.path} is not launchable (${verdict.state})`);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
return result;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
491
|
+
// Failure diagnosis
|
|
492
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Does this error mean the native binary could not be launched?
|
|
496
|
+
*
|
|
497
|
+
* The SDK signals it two ways: a ReferenceError (not Error — it literally does
|
|
498
|
+
* `this.exitError = ReferenceError(msg)`) carrying the libc text, or a raw spawn
|
|
499
|
+
* error whose code is in its bucket. "No conversation found" must NOT match —
|
|
500
|
+
* that path belongs to the bridge's session-recovery logic.
|
|
501
|
+
*/
|
|
502
|
+
export function isNativeBinaryLaunchFailure(err) {
|
|
503
|
+
if (!err) return false;
|
|
504
|
+
const message = typeof err === 'string' ? err : (err.message || '');
|
|
505
|
+
if (/No conversation found/i.test(message)) return false;
|
|
506
|
+
if (SDK_LIBC_MESSAGE_RE.test(message)) return true;
|
|
507
|
+
if (/executable (?:at .+ )?(?:exists but failed to launch|not found)/i.test(message)) return true;
|
|
508
|
+
if (/Native CLI binary for .+ not found/i.test(message)) return true;
|
|
509
|
+
if (err.code && LAUNCH_ERROR_CODES.has(err.code)) return true;
|
|
510
|
+
for (const code of LAUNCH_ERROR_CODES) {
|
|
511
|
+
if (message.includes(code)) return true;
|
|
512
|
+
}
|
|
513
|
+
return false;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Turn a launch failure into a platform-accurate explanation, replacing the
|
|
518
|
+
* SDK's hardcoded musl/glibc story (which is only correct on Linux).
|
|
519
|
+
*
|
|
520
|
+
* @returns {{kind: string, message: string, repairCommand: string|null, canRetryAfterRepair: boolean}}
|
|
521
|
+
*/
|
|
522
|
+
export function diagnoseLaunchFailure({
|
|
523
|
+
err,
|
|
524
|
+
runtime,
|
|
525
|
+
platform = process.platform,
|
|
526
|
+
arch = process.arch,
|
|
527
|
+
} = {}) {
|
|
528
|
+
const rt = runtime || {};
|
|
529
|
+
const p = rt.path || '(unresolved)';
|
|
530
|
+
const raw = (typeof err === 'string' ? err : err?.message) || '';
|
|
531
|
+
|
|
532
|
+
switch (rt.state) {
|
|
533
|
+
case 'repaired':
|
|
534
|
+
return {
|
|
535
|
+
kind: 'exec-bit',
|
|
536
|
+
repairCommand: rt.repairCommand,
|
|
537
|
+
canRetryAfterRepair: true,
|
|
538
|
+
message: [
|
|
539
|
+
`The bundled Claude binary was not executable (mode ${(rt.mode & 0o777).toString(8).padStart(3, '0')}).`,
|
|
540
|
+
'This is a file permission problem, not a libc mismatch — the SDK always blames libc here.',
|
|
541
|
+
'Execute permission has been restored; retrying.',
|
|
542
|
+
].join(' '),
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
case 'chmod-failed':
|
|
546
|
+
case 'access-denied':
|
|
547
|
+
return {
|
|
548
|
+
kind: 'exec-bit',
|
|
549
|
+
repairCommand: rt.repairCommand,
|
|
550
|
+
canRetryAfterRepair: false,
|
|
551
|
+
message: [
|
|
552
|
+
`The bundled Claude binary at ${p} is not executable and the permission could not be repaired`,
|
|
553
|
+
`(${rt.error || rt.state}).`,
|
|
554
|
+
'This is a file permission problem, not a libc mismatch.',
|
|
555
|
+
`The install may be read-only or owned by another user. Fix it with: sudo ${rt.repairCommand || `chmod +x ${quoteForShell(p)}`}`,
|
|
556
|
+
].join(' '),
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
case 'windows-bad-name':
|
|
560
|
+
return {
|
|
561
|
+
kind: 'shim',
|
|
562
|
+
repairCommand: null,
|
|
563
|
+
canRetryAfterRepair: false,
|
|
564
|
+
message: [
|
|
565
|
+
`${p} cannot be launched: the Claude Agent SDK spawns with shell:false,`,
|
|
566
|
+
'so npm .cmd/.bat/.ps1 shims will not run. Point the override at the real claude.exe instead.',
|
|
567
|
+
].join(' '),
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
case 'not-installed':
|
|
571
|
+
case 'missing':
|
|
572
|
+
case 'not-a-file':
|
|
573
|
+
return {
|
|
574
|
+
kind: 'missing',
|
|
575
|
+
repairCommand: null,
|
|
576
|
+
canRetryAfterRepair: false,
|
|
577
|
+
// The literal "Claude CLI not found" keeps the panel's existing
|
|
578
|
+
// install-CTA regex (ui-claude-panel.js) firing without a frontend change.
|
|
579
|
+
message: [
|
|
580
|
+
`Claude CLI not found for ${platform}-${arch}.`,
|
|
581
|
+
'The SDK ships its native binary as a per-platform optional dependency.',
|
|
582
|
+
'Reinstall without --omit=optional (npm install --include=optional), or install Claude Code globally.',
|
|
583
|
+
].join(' '),
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
default:
|
|
587
|
+
break;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Resolved, executable, and it still would not launch.
|
|
591
|
+
if (platform === 'darwin') {
|
|
592
|
+
return {
|
|
593
|
+
kind: 'gatekeeper',
|
|
594
|
+
repairCommand: `xattr -d com.apple.quarantine ${quoteForShell(p)}`,
|
|
595
|
+
canRetryAfterRepair: false,
|
|
596
|
+
message: [
|
|
597
|
+
`The Claude binary at ${p} is executable but macOS refused to launch it.`,
|
|
598
|
+
`Usually Gatekeeper quarantine, or a binary built for another architecture (this host is ${arch}).`,
|
|
599
|
+
`Try: xattr -d com.apple.quarantine ${quoteForShell(p)}`,
|
|
600
|
+
].join(' '),
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
if (platform === 'linux') {
|
|
604
|
+
return {
|
|
605
|
+
kind: 'libc',
|
|
606
|
+
repairCommand: null,
|
|
607
|
+
canRetryAfterRepair: false,
|
|
608
|
+
message: [
|
|
609
|
+
`The Claude binary at ${p} is executable but failed to launch.`,
|
|
610
|
+
`This host looks like ${detectMusl({ platform }) ? 'musl' : 'glibc'}.`,
|
|
611
|
+
'A musl-linked binary on a glibc host (or the reverse) fails because the matching dynamic loader is missing.',
|
|
612
|
+
'Reinstall so the correct linux variant is selected.',
|
|
613
|
+
].join(' '),
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
return {
|
|
617
|
+
kind: 'unknown',
|
|
618
|
+
repairCommand: null,
|
|
619
|
+
canRetryAfterRepair: false,
|
|
620
|
+
message: raw || `The Claude binary at ${p} failed to launch.`,
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Decide what to do about a launch failure. Pure, so the bridge's recovery
|
|
626
|
+
* behaviour is unit-testable without a live WebSocket.
|
|
627
|
+
*
|
|
628
|
+
* @returns {{action: 'retry'|'fallback'|'fail', message: string, kind: string, repairCommand: string|null}}
|
|
629
|
+
*/
|
|
630
|
+
export function planRuntimeRecovery({
|
|
631
|
+
err,
|
|
632
|
+
runtime,
|
|
633
|
+
fallbackBin = null,
|
|
634
|
+
alreadyTried = false,
|
|
635
|
+
platform = process.platform,
|
|
636
|
+
arch = process.arch,
|
|
637
|
+
} = {}) {
|
|
638
|
+
const dx = diagnoseLaunchFailure({ err, runtime, platform, arch });
|
|
639
|
+
const base = { message: dx.message, kind: dx.kind, repairCommand: dx.repairCommand };
|
|
640
|
+
|
|
641
|
+
if (dx.canRetryAfterRepair && !alreadyTried) return { ...base, action: 'retry' };
|
|
642
|
+
if (fallbackBin) {
|
|
643
|
+
return {
|
|
644
|
+
...base,
|
|
645
|
+
action: 'fallback',
|
|
646
|
+
message: `${dx.message}\nFalling back to your installed Claude CLI: ${fallbackBin}`,
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
return { ...base, action: 'fail' };
|
|
650
|
+
}
|