Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions proposed-fixes/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
node_modules/
dist/
*.tgz
.env
.env.*
bun.lock
package-lock.json
87 changes: 87 additions & 0 deletions proposed-fixes/issue-296/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Proposed fix for issue #296

> Linux: musl binary preferred over glibc in v0.2.116 CLI auto-discovery
> https://github.com/anthropics/claude-agent-sdk-typescript/issues/296

This directory contains a self-contained reference implementation for
the libc-aware binary discovery fix requested by reporters in #296. It
is **not** a buildable change to the public repo — the SDK source lives
in the closed-source `@anthropic-ai/claude-agent-sdk` build (the public
repo here is issue-tracker-only). The intent is to give Anthropic a
drop-in TypeScript module + tests they can integrate into the bundled
`sdk.mjs` build.

## What the bug looks like in `sdk.mjs`

The current bundled resolver (function `N7` in 0.2.119; was `W7` when
the issue was filed) on Linux does:

```js
[
`@anthropic-ai/claude-agent-sdk-linux-${arch}-musl`, // tried first
`@anthropic-ai/claude-agent-sdk-linux-${arch}`,
]
```

and returns the first one that `require.resolve` accepts — without
checking the host libc and without verifying the resolved path actually
exists on disk.

This breaks two real scenarios reported in the thread:

1. **pnpm both-installed** (asafmor, jasoncrawford): both optional
packages land in `node_modules` because pnpm doesn't filter on the
`libc` field. resolver returns the musl path, spawn fails on glibc
hosts because the musl ld-linker is missing.
2. **npm glibc-only** (Number531): musl optional dep is correctly
skipped by npm's `libc` filter, but the resolver still hands back a
musl-shaped path. The error message says "binary not found" because
the file truly isn't there.

## What this fix does

1. **Probe libc** at runtime on Linux:
1. `process.report.getReport().header.glibcVersionRuntime` —
authoritative on glibc (Node leaves it undefined on musl).
2. `ldd --version` — parses for `musl` or `GNU libc`. Survives
musl's ldd exiting non-zero.
3. Filesystem probe for `/lib/ld-musl-*` vs glibc loader paths.
4. Default to glibc when nothing is conclusive (it's the common
Linux runtime).
2. **Verify file existence** after resolving each candidate. If a
candidate resolves but its `claude` binary isn't on disk, fall
through to the next one. Returns `null` only when nothing resolves
AND nothing is on disk.

## Files

- `src/resolveCliBinary.ts` — drop-in replacement for the bundled
resolver. Public API: `resolveBundledCliBinary`, `detectLinuxLibc`,
`platformPackageCandidates`.
- `test/resolveCliBinary.test.ts` — vitest unit tests covering the
detection heuristics, candidate ordering, existence fallback, and
null-when-truly-missing behaviour.

## Running the tests

```sh
cd proposed-fixes/issue-296
npm install
npm test
```

## Integration sketch

In `sdk.mjs`, replace the body of the current resolver function with a
call into `resolveBundledCliBinary(resolve, ...)`. The two existing
call sites in 0.2.119 (`new Promise(...).resolve` and the `Nc(...)`
require-shim) both pass a `require.resolve`-shaped function, which is
exactly the `Resolver` type this module expects, so no call-site
changes are needed beyond renaming the function.

The error message in the spawn-ENOENT branch should also be updated to
mention that the file is on disk but cannot execute (musl-on-glibc
case), e.g. _"Claude Code native binary at <path> failed to spawn —
this typically means the binary's libc does not match the host libc"_,
so users hitting case (1) above don't get the misleading "not found"
text.
17 changes: 17 additions & 0 deletions proposed-fixes/issue-296/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "issue-296-libc-aware-binary-discovery",
"version": "0.0.0",
"private": true,
"description": "Reference implementation + tests for issue #296 (libc-aware Linux CLI binary discovery).",
"type": "module",
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/node": "^22.10.2",
"typescript": "^5.6.3",
"vitest": "^2.1.8"
}
}
197 changes: 197 additions & 0 deletions proposed-fixes/issue-296/src/resolveCliBinary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
/**
* Reference fix for issue #296.
*
* In the published `sdk.mjs`, the bundled function (currently `N7` in
* 0.2.119; was `W7` when the issue was filed) resolves the CLI binary on
* Linux by trying the musl candidate first, then the glibc candidate:
*
* X === "linux"
* ? [`...-linux-${arch}-musl`, `...-linux-${arch}`]
* : [...]
*
* Two real-world failure modes:
*
* 1. pnpm installs both optional packages on Linux (no `libc` field
* filtering at install time, see #296). require.resolve succeeds for
* the musl candidate first, the SDK returns it, and spawning fails
* with ENOENT because the musl ld-linker is missing on glibc hosts.
* The error message says "binary not found" even though the file is
* right there on disk — only the loader is missing.
*
* 2. npm correctly skips the musl optional dep on glibc via the `libc`
* filter, but the resolver may still hand back a musl-looking path
* (Number531's report) — likely because some intermediate package
* directory is left in node_modules. Either way, the path returned
* points at a file that does not exist on disk.
*
* The fix has two parts:
*
* a. Probe the runtime libc and order Linux candidates accordingly.
* Heuristics, in priority order:
* 1. `process.report.getReport().header.glibcVersionRuntime` —
* authoritative when present (Node sets it on glibc, leaves
* it undefined on musl).
* 2. `ldd --version` — parses for "musl" or "GNU libc". Treat
* spawn failure or unrecognized output as "unknown".
* 3. Filesystem probe for `/lib/ld-musl-*` vs the glibc loader
* paths.
* If nothing is conclusive, prefer glibc (the common case).
*
* b. After candidate ordering, also verify the resolved binary path
* exists on disk; if not, fall through to the next candidate. This
* catches the npm-only case where require.resolve hands back a path
* that doesn't actually have the binary.
*/

import { existsSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import * as process from 'node:process';

export type Resolver = (specifier: string) => string;

export type LibcKind = 'glibc' | 'musl' | 'unknown';

/** Detect the runtime libc on a Linux host. Cheap, synchronous, and never throws. */
export function detectLinuxLibc(env: {
// Hooks for tests; production passes nothing and we use real syscalls.
getReport?: () => { header?: { glibcVersionRuntime?: string } } | undefined;
runLdd?: () => string | undefined;
fileExists?: (path: string) => boolean;
} = {}): LibcKind {
// 1. Node's own report — definitive when populated.
try {
const getReport = env.getReport ?? (() => (process as any).report?.getReport?.());
const report = getReport();
const glibcVersion = report?.header?.glibcVersionRuntime;
if (typeof glibcVersion === 'string' && glibcVersion.length > 0) {
return 'glibc';
}
// Node populates glibcVersionRuntime only on glibc hosts. Empty string
// or missing key on Linux is a strong (but not definitive) hint of musl.
} catch {
// fall through
}

// 2. Shell out to `ldd --version`. Output goes to stderr on glibc, stdout
// on musl (which exits non-zero). execFileSync needs both captured.
try {
const runLdd = env.runLdd ?? (() => {
try {
return execFileSync('ldd', ['--version'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 1000,
});
} catch (err: any) {
// musl's ldd exits 1 but still prints to stderr; capture it.
const stderr = err?.stderr;
if (typeof stderr === 'string' && stderr.length > 0) return stderr;
return undefined;
}
});
const out = runLdd();
if (typeof out === 'string' && out.length > 0) {
if (/musl/i.test(out)) return 'musl';
if (/glibc|GNU\s+libc/i.test(out)) return 'glibc';
}
} catch {
// fall through
}

// 3. Filesystem probe.
const fileExists = env.fileExists ?? existsSync;
const muslLoaders = [
'/lib/ld-musl-x86_64.so.1',
'/lib/ld-musl-aarch64.so.1',
'/lib/ld-musl-armhf.so.1',
];
const glibcLoaders = [
'/lib64/ld-linux-x86-64.so.2',
'/lib/ld-linux-x86-64.so.2',
'/lib/ld-linux-aarch64.so.1',
'/lib/ld-linux-armhf.so.3',
];
if (muslLoaders.some(fileExists)) return 'musl';
if (glibcLoaders.some(fileExists)) return 'glibc';

return 'unknown';
}

/**
* Build the ordered list of platform-package candidates. On Linux, the
* order depends on detected libc; everywhere else the list has one entry.
*/
export function platformPackageCandidates(
platform: NodeJS.Platform,
arch: string,
libc: LibcKind,
): string[] {
if (platform !== 'linux') {
return [`@anthropic-ai/claude-agent-sdk-${platform}-${arch}`];
}
const glibc = `@anthropic-ai/claude-agent-sdk-linux-${arch}`;
const musl = `@anthropic-ai/claude-agent-sdk-linux-${arch}-musl`;
switch (libc) {
case 'musl':
return [musl, glibc];
case 'glibc':
return [glibc, musl];
case 'unknown':
default:
// Glibc is the overwhelmingly common Linux runtime; prefer it when
// detection is inconclusive. The npm-installed musl variant is gated
// by a `libc: ["musl"]` field, so it should not even be present on
// glibc hosts under npm.
return [glibc, musl];
}
}

/**
* Resolver replacement for the bundled `N7` in sdk.mjs. Given a `resolve`
* (typically `createRequire(import.meta.url).resolve`), returns the
* absolute path to the bundled `claude` binary, or null if no candidate
* resolves AND points at a real file.
*
* @param resolve module specifier resolver (require.resolve-shaped)
* @param opts hooks for testing; production callers pass nothing
*/
export function resolveBundledCliBinary(
resolve: Resolver,
opts: {
platform?: NodeJS.Platform;
arch?: string;
libc?: LibcKind;
fileExists?: (path: string) => boolean;
detectLibc?: () => LibcKind;
} = {},
): string | null {
const platform = opts.platform ?? process.platform;
const arch = opts.arch ?? process.arch;
const libc =
opts.libc ??
(platform === 'linux'
? (opts.detectLibc ?? detectLinuxLibc)()
: 'unknown');
const fileExists = opts.fileExists ?? existsSync;
const exeSuffix = platform === 'win32' ? '.exe' : '';

const candidates = platformPackageCandidates(platform, arch, libc).map(
(pkg) => `${pkg}/claude${exeSuffix}`,
);

for (const specifier of candidates) {
let resolved: string;
try {
resolved = resolve(specifier);
} catch {
// Package isn't installed (or doesn't resolve from this location).
continue;
}
// Existence check: protects against the npm case where resolve
// succeeds against a stub directory but the binary file isn't there.
if (fileExists(resolved)) {
return resolved;
}
}
return null;
}
Loading