UNPKG

@anthropic-ai/claude-agent-sdk

Version:

SDK for building AI agents with Claude Code's capabilities. Programmatically interact with Claude to build autonomous agents that can understand codebases, edit files, and execute workflows.

286 lines (278 loc) • 10.6 kB
/* oxlint-disable custom-rules/no-sync-fs */ // Extracts a file from Bun's $bunfs virtual filesystem to a real temp directory // so it can be spawned as a subprocess (child processes cannot access $bunfs). // // Ships verbatim (unbundled) as the @anthropic-ai/claude-agent-sdk `./extract` // export — keep it standalone and Node-importable (no Bun globals, no // local-relative imports). import { createHash, randomBytes } from 'crypto' import { chmodSync, closeSync, constants as fsConstants, fchmodSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, renameSync, unlinkSync, writeFileSync, } from 'fs' import { tmpdir as osTmpdir } from 'os' import { basename, join } from 'path' // Inlined from src/utils/tempfile.ts — this file ships verbatim (unbundled) as // the @anthropic-ai/claude-agent-sdk/extract export, so it must have zero // local-relative imports. Keep behavior in sync with tempfile.ts#tmpdir. function tmpdir() { if (process.env.CLAUDE_CODE_TMPDIR) { return process.env.CLAUDE_CODE_TMPDIR } if (process.platform === 'darwin') { // oxlint-disable-next-line custom-rules/no-hardcoded-tmp -- mirrors tempfile.ts: macOS /tmp works fine; os.tmpdir() below is for Android-on-Linux where /tmp isn't writable. return '/tmp' } return osTmpdir() } // Per-user extraction root: `{tmpdir()}/claude-agent-sdk-{uid}` on Unix, // `{tmpdir()}/claude-agent-sdk` on Windows (where %TEMP% is already per-user). // Deliberately NOT Claude Code's own temp root `{tmpdir()}/claude-{uid}`: that // is the writable $TMPDIR Claude Code gives the commands it runs inside the // Bash-tool sandbox, while the SDK application spawns the extracted CLI with // no sandbox, so it lives where the sandbox's default write roots do not reach. function safeTempBase() { const dir = join( tmpdir(), process.platform === 'win32' ? 'claude-agent-sdk' : `claude-agent-sdk-${process.getuid?.() ?? 0}`, ) // Create it 0700 and refuse anything but a directory this user owns: its // name is predictable, and recursive mkdir silently accepts one that another // local user pre-created in a shared /tmp. A same-user writer that reaches // the temp dir can race this check; the byte comparison in extractFromBunfs() // only stops it planting a file for a later start, not replacing one mid-run. // Gated on getuid rather than process.platform, which tests mock. if (typeof process.getuid === 'function') { mkdirSync(dir, { recursive: true, mode: 0o700 }) const uid = process.getuid() // An older, path-based copy of src/utils/tempfile.ts#verifyTempDirOwnership, // which now checks and tightens the directory through one fd. const st = lstatSync(dir) if (!st.isDirectory()) { throw new Error( `Temp directory ${dir} is not a directory (may be an attacker-planted symlink). Refusing to use it.`, ) } if (st.uid !== uid) { throw new Error( `Temp directory ${dir} is owned by uid ${st.uid}, expected ${uid}. Refusing to use it — another user may have pre-created it.`, ) } if ((st.mode & 0o777) !== 0o700) { // Owner matches, so this is our own dir from a prior run with a looser // umask rather than an attacker pre-create. Tighten it. chmodSync(dir, 0o700) } } else { // Windows: best-effort create; verification is skipped (%TEMP% is per-user). try { mkdirSync(dir, { recursive: true, mode: 0o700 }) } catch { // best-effort; see comment above } } return dir } // Copy of src/utils/noFileIdentity.ts#hasNoFileIdentity (this file ships // standalone and cannot import it): a stat file id that names no object. function isNoFileId(ino) { return ( ino === 0n || ino === -1n || ino === 0xffff_ffff_ffff_ffffn || ino === 1n << 64n ) } // True when `file` is a regular file (not a symlink, no second hard link) // whose bytes equal `content`. A symlink or a second link would let the bytes // change later through a path outside this directory. function isExtractedCopy(file, content) { let fd let inoAtPath try { if (typeof process.getuid === 'function') { // Judge the opened file itself: O_NOFOLLOW refuses a symlink at the path // and O_NONBLOCK keeps a planted FIFO from hanging the open. Gated on // getuid because Bun on Windows defines these flags with unusable values. fd = openSync( file, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK, ) } else { const atPath = lstatSync(file, { bigint: true }) if (!atPath.isFile()) { return false } inoAtPath = atPath.ino fd = openSync(file, 'r') } const st = fstatSync(fd, { bigint: true }) if ( !st.isFile() || Number(st.nlink) !== 1 || Number(st.size) !== content.length ) { return false } // Windows has no usable O_NOFOLLOW, so that open may have followed // something swapped in after the lstat: treat it as not our copy unless the // fd is the same file, by file id alone as in // src/utils/noFileIdentity.ts#openedAnotherFile; an id of 0 or all-ones // proves nothing. if ( inoAtPath !== undefined && !isNoFileId(inoAtPath) && !isNoFileId(st.ino) && st.ino !== inoAtPath ) { return false } const chunk = Buffer.allocUnsafe(1 << 20) let offset = 0 while (offset < content.length) { const n = readSync(fd, chunk, 0, chunk.length, offset) if ( n <= 0 || !chunk.subarray(0, n).equals(content.subarray(offset, offset + n)) ) { return false } offset += n } return true } catch { return false } finally { if (fd !== undefined) { closeSync(fd) } } } /** * If `embeddedPath` is inside Bun's $bunfs, extract it to a per-user directory * under the temp dir (CLAUDE_CODE_TMPDIR if set); otherwise return it as is. * * Uses a content hash for the directory name so that: * - Same binary version reuses the same extracted file (no accumulation) * - Different versions get separate directories (no collision) * - Concurrent instances are safe (atomic write via temp file + rename) * * @param {string} embeddedPath — path returned by `import ... with { type: 'file' }` * @returns {string} — a real filesystem path that can be spawned as a subprocess */ export function extractFromBunfs(embeddedPath) { // Bun single-file embed: `/$bunfs/root/...` (POSIX) or `B:\~BUN\root\...` // (Windows — Bun's StandaloneModuleGraph uses `~BUN`, not `$bunfs`, on Windows). if (!embeddedPath.includes('$bunfs') && !embeddedPath.includes('~BUN')) { return embeddedPath } try { const content = readFileSync(embeddedPath) const hash = createHash('sha256').update(content).digest('hex').slice(0, 16) const tmpDir = join(safeTempBase(), `claude-agent-sdk-${hash}`) // Preserve the embedded file's basename — ProcessTransport.isNativeBinary() // decides whether to spawn directly or via node/bun based on extension, so // a native binary must not come out named cli.js. const fileName = basename(embeddedPath) const tmpPath = join(tmpDir, fileName) mkdirSync(tmpDir, { recursive: true, mode: 0o700 }) // Recursive mkdir accepts an existing symlink to a directory; refuse it, or // every path below would resolve wherever the link points. if (!lstatSync(tmpDir).isDirectory()) { throw new Error( 'The claude-agent-sdk-<hash> folder under the temp directory is a symlink or not a directory, possibly planted; delete it and restart', ) } // Reuse an earlier extraction only after re-reading it: a plain file with // exactly these bytes. The owner-only root sits outside the Bash-tool // sandbox's default write roots, but a widened write grant can still reach // it; anything else found at the path is replaced below, or extraction // fails. On Windows reuse also avoids renaming over the running, // memory-mapped .exe, which fails. if (isExtractedCopy(tmpPath, content)) { return tmpPath } // Write to a temp file and atomically rename to avoid truncation races — // concurrent readers always see either the old complete file or the new one. // Unguessable name, exclusive create: a name planted in advance (say a // symlink to a file elsewhere) is never written through or renamed into // place. const tmpFile = join( tmpDir, `${fileName}.tmp.${process.pid}.${randomBytes(8).toString('hex')}`, ) const fd = openSync(tmpFile, 'wx', 0o700) let renamed = false try { try { writeFileSync(fd, content) // Through the fd, so a name swapped for a symlink mid-write cannot // redirect the mode change to another file. fchmodSync(fd, 0o755) } finally { closeSync(fd) } try { renameSync(tmpFile, tmpPath) } catch (e) { // Bun on Windows refuses to rename onto an existing file (EEXIST, or // EPERM/EBUSY while another process has that .exe mapped or a scanner // briefly holds our new file). An identical copy already there (a // concurrent first extraction won the race) is as good as ours; // anything else is removed and the rename retried once. if (e.code !== 'EEXIST' && e.code !== 'EPERM' && e.code !== 'EBUSY') { throw e } if (isExtractedCopy(tmpPath, content)) { return tmpPath } try { unlinkSync(tmpPath) } catch (unlinkError) { if (unlinkError.code !== 'ENOENT') { throw unlinkError } } try { renameSync(tmpFile, tmpPath) } catch { // A concurrent start may have put its own copy in place first. if (isExtractedCopy(tmpPath, content)) { return tmpPath } throw e } } renamed = true return tmpPath } finally { if (!renamed) { try { unlinkSync(tmpFile) } catch { // best-effort cleanup of our unused temp file } } } } catch (err) { // oxlint-disable-next-line no-console -- intentional user-facing warning in standalone SDK helper console.warn( `[claude-agent-sdk] Failed to extract CLI from $bunfs: ${err.message}. ` + `Child processes cannot access $bunfs paths — the CLI will likely fail to start.`, ) return embeddedPath } }