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.
@@ -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
+ }