@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
JavaScript
/* 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
}
}