UNPKG

@openai/agents-core

Version:

The OpenAI Agents SDK is a lightweight yet powerful framework for building multi-agent workflows.

1,103 lines • 47.7 kB
import { getRunStateSessionTrustedConfig } from "../internal/sessionStateTrust.mjs"; import { UserError } from "../../errors.mjs"; import { applyDiff } from "../../utils/applyDiff.mjs"; import { mkdtemp, rm } from 'node:fs/promises'; import { lstatSync, realpathSync } from 'node:fs'; import { spawn } from 'node:child_process'; import { basename, dirname, isAbsolute, join, relative, resolve, } from 'node:path'; import { tmpdir, userInfo } from 'node:os'; import { normalizeSandboxClientCreateArgs, validateSandboxArchiveLimits, } from "../client.mjs"; import { normalizeExposedPort, recordExposedPortEndpoint, } from "../session.mjs"; import { cloneManifest, normalizeRelativePath } from "../manifest.mjs"; import { SandboxConfigurationError, SandboxUnsupportedFeatureError, } from "../errors.mjs"; import { WorkspacePathPolicy, } from "../workspacePaths.mjs"; import { applyOwnershipRecursive, assertLocalWorkspaceManifestMetadataSupported, materializeLocalWorkspaceManifest, materializeLocalWorkspaceManifestEntry, materializeLocalWorkspaceManifestMounts, restoreLocalWorkspaceManifestStickyPermissions, pathExists, } from "./shared/localWorkspace.mjs"; import { assertMountCredentialsRebound, assertHostPathGrantsRebound, mergeManifestEntryDelta, mergeManifestDelta, sanitizeEnvironmentForPersistence, serializeMountCredentialRedactionMetadata, serializeManifest, } from "./shared/manifestPersistence.mjs"; import { assertExistingMountTopologyPreserved, assertSandboxSessionStateUsable, assertSandboxStateGenerationUnchanged, captureLiveMountCredentialAuthority, captureSandboxStateGeneration, validateMountCredentialBoundaries, withExclusiveSandboxManifestMutation, } from "../mountSecurity.mjs"; import { isHostPathWithinRoot, relativeHostPathEscapesRoot, } from "../shared/hostPath.mjs"; import { MAX_VIEW_IMAGE_BYTES, imageOutputFromBytes } from "../shared/media.mjs"; import { UnixLocalFiles, preparedFileIO, withUnixLocalFileAccess, } from "./shared/unixLocalFiles.mjs"; import { elapsedSeconds, formatExecResponse, truncateOutput, } from "../shared/output.mjs"; import { canReuseLocalSnapshotWorkspace, localSnapshotIsRestorable, persistLocalSnapshot, restoreLocalSnapshotToWorkspace, serializeLocalSnapshotSpec, } from "./shared/localSnapshots.mjs"; import { createWorkspaceArchive, restoreWorkspaceArchive, } from "./shared/workspaceArchive.mjs"; import { spawnInPseudoTerminal } from "./shared/pty.mjs"; import { runSandboxProcess } from "./shared/runProcess.mjs"; import { resolveLocalShellCommand } from "./shared/shellCommand.mjs"; import { deserializeLocalSandboxSessionStateValues, normalizeExposedPorts, } from "./shared/sessionStateValues.mjs"; const DEFAULT_EXEC_YIELD_TIME_MS = 10_000; const DEFAULT_WRITE_STDIN_YIELD_TIME_MS = 250; const MAX_ACTIVE_PROCESS_OUTPUT_CHARS = 1024 * 1024; const RUN_AS_LOOKUP_TIMEOUT_MS = 10_000; const DEFAULT_SANDBOX_COMMAND_PATH = '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin'; export class UnixLocalSandboxSession { state; defaultShell; archiveLimits; activeProcesses = new Map(); nextSessionId = 1; closePromise; stopPromise; fileOperationGeneration = 0; hostFiles; get files() { if (!this.hostFiles) { throw new UserError('Host file operations are unavailable for this sandbox.'); } return this.hostFiles; } filesystemRoots = new Map(); constructor(args) { this.hostFiles = args[preparedFileIO] === null ? null : (args[preparedFileIO] ?? new UnixLocalFiles(args.fileIOProtection)); this.state = args.state; this.defaultShell = args.defaultShell; this.setArchiveLimits(args.archiveLimits); this.captureFilesystemRoots(); } /** Selected once at creation or resume; Docker subclasses use the container backend. */ get fileIOBackend() { return 'python'; } setArchiveLimits(limits) { validateSandboxArchiveLimits(limits); this.archiveLimits = limits; } createEditor(runAs) { this.assertSessionUsable(); return new UnixLocalSandboxEditor(this, (operation) => this.applyFileOperation(operation, runAs)); } supportsPty() { return true; } async resolveExposedPort(port) { this.assertSessionUsable(); const exposedPort = normalizeExposedPort(port); const configuredPorts = this.state.configuredExposedPorts ?? []; if (configuredPorts.length > 0 && !configuredPorts.includes(exposedPort)) { throw new SandboxConfigurationError(`UnixLocalSandboxClient was not configured to expose port ${exposedPort}.`, { provider: 'UnixLocalSandboxClient', port: exposedPort, configuredPorts, }); } return recordExposedPortEndpoint(this.state, { host: '127.0.0.1', port: exposedPort, tls: false, }); } async execCommand(args) { return formatExecResponse(await this.exec(args)); } async exec(args) { this.assertSessionUsable(); const start = Date.now(); const cwd = this.resolveCommandWorkdir(args.workdir); const logicalCwd = this.logicalWorkdirForPath(args.workdir); const child = await this.spawnShellCommand(this.translateCommandInput(args.cmd), { cwd, logicalCwd, shell: args.shell, login: args.login ?? true, runAs: args.runAs, tty: args.tty ?? false, }); const activeProcess = this.trackChildProcess(child, { tty: args.tty ?? false, }); if (!args.tty) { await waitForProcessOrTimeout(activeProcess, args.yieldTimeMs ?? DEFAULT_EXEC_YIELD_TIME_MS); const processOutput = consumeActiveProcessOutput(activeProcess); if (activeProcess.done) { const output = truncateOutput(this.translateCommandOutput(processOutput.output), args.maxOutputTokens); return { output: output.text, stdout: this.translateCommandOutput(processOutput.stdout), stderr: this.translateCommandOutput(processOutput.stderr), wallTimeSeconds: elapsedSeconds(start), exitCode: activeProcess.exitCode ?? 1, originalTokenCount: output.originalTokenCount, }; } const output = truncateOutput(this.translateCommandOutput(processOutput.output), args.maxOutputTokens); const sessionId = this.allocateProcessId(activeProcess); return { output: output.text, stdout: this.translateCommandOutput(processOutput.stdout), stderr: this.translateCommandOutput(processOutput.stderr), wallTimeSeconds: elapsedSeconds(start), sessionId, originalTokenCount: output.originalTokenCount, }; } const yieldTimeMs = args.yieldTimeMs ?? DEFAULT_EXEC_YIELD_TIME_MS; await waitForProcessOrTimeout(activeProcess, yieldTimeMs); if (activeProcess.done) { const processOutput = consumeActiveProcessOutput(activeProcess); const output = truncateOutput(this.translateCommandOutput(processOutput.output), args.maxOutputTokens); return { output: output.text, stdout: this.translateCommandOutput(processOutput.stdout), stderr: this.translateCommandOutput(processOutput.stderr), wallTimeSeconds: elapsedSeconds(start), exitCode: activeProcess.exitCode ?? 1, originalTokenCount: output.originalTokenCount, }; } const sessionId = this.allocateProcessId(activeProcess); const processOutput = consumeActiveProcessOutput(activeProcess); const output = truncateOutput(this.translateCommandOutput(processOutput.output), args.maxOutputTokens); return { output: output.text, stdout: this.translateCommandOutput(processOutput.stdout), stderr: this.translateCommandOutput(processOutput.stderr), wallTimeSeconds: elapsedSeconds(start), sessionId, originalTokenCount: output.originalTokenCount, }; } async writeStdin(args) { this.assertSessionUsable(); const activeProcess = this.activeProcesses.get(args.sessionId); if (!activeProcess) { return formatExecResponse({ output: `write_stdin failed: session not found: ${args.sessionId}`, wallTimeSeconds: 0, exitCode: 1, }); } const start = Date.now(); const chars = args.chars ?? ''; if (!activeProcess.tty && chars.includes('\u0003')) { activeProcess.child.kill('SIGINT'); } const remainingChars = activeProcess.tty ? chars : chars.split('\u0003').join(''); if (remainingChars.length > 0) { activeProcess.child.stdin.write(remainingChars); } await waitForProcessOrTimeout(activeProcess, args.yieldTimeMs ?? DEFAULT_WRITE_STDIN_YIELD_TIME_MS); const processOutput = consumeActiveProcessOutput(activeProcess); const output = truncateOutput(this.translateCommandOutput(processOutput.output), args.maxOutputTokens); if (activeProcess.done) { this.activeProcesses.delete(args.sessionId); return formatExecResponse({ output: output.text, wallTimeSeconds: elapsedSeconds(start), exitCode: activeProcess.exitCode ?? 1, originalTokenCount: output.originalTokenCount, }); } return formatExecResponse({ output: output.text, wallTimeSeconds: elapsedSeconds(start), sessionId: args.sessionId, originalTokenCount: output.originalTokenCount, }); } async viewImage(args) { this.assertSessionUsable(); const identity = await this.resolveFilesystemRunAs(args.runAs); const filePath = this.resolveFilesystemPath(args.path); const bytes = await this.files .run({ operation: 'image', path: filePath, limit: MAX_VIEW_IMAGE_BYTES }, { identity }) .catch((error) => { if (error.code === 'ENOENT') throw new UserError(`Image file not found: ${args.path}`); if (error.code === 'EINVAL') throw new UserError(`Image path is not a file: ${args.path}`); if (error.code === 'EFBIG') throw new UserError(`Image file exceeds the 10 MB limit: ${args.path}`); throw error; }); return imageOutputFromBytes(args.path, bytes); } async pathExists(path, runAs) { this.assertSessionUsable(); const identity = await this.resolveFilesystemRunAs(runAs); return JSON.parse((await this.files.run({ operation: 'exists', path: this.resolveFilesystemPath(path), }, { identity })).toString('utf8')); } async directoryExists(path, runAs) { this.assertSessionUsable(); const identity = await this.resolveCommandRunAs(runAs); const resolvedPath = this.resolveFilesystemPath(path); return JSON.parse((await this.files.run({ operation: 'directoryExists', path: resolvedPath }, { identity })).toString('utf8')); } async readFile(args) { this.assertSessionUsable(); const identity = await this.resolveFilesystemRunAs(args.runAs); const bytes = await this.files.run({ operation: 'read', path: this.resolveFilesystemPath(args.path), }, { identity }); if (typeof args.maxBytes === 'number' && bytes.byteLength > args.maxBytes) { return bytes.subarray(0, args.maxBytes); } return bytes; } async listDir(args) { this.assertSessionUsable(); const identity = await this.resolveFilesystemRunAs(args.runAs); const resolved = this.resolveSandboxPathTarget(args.path); const logicalPath = resolved.workspaceRelativePath ?? resolved.path; const entries = JSON.parse((await this.files.run({ operation: 'list', path: this.resolveFilesystemPath(args.path), }, { identity })).toString('utf8')); return entries.map((entry) => ({ name: entry.name, path: logicalPath ? `${logicalPath}/${entry.name}` : entry.name, type: entry.type, })); } async materializeEntry(args) { const entry = structuredClone(args.entry); return await withExclusiveSandboxManifestMutation(this.state, async () => this.materializeEntryExclusive({ ...args, entry })); } async materializeEntryExclusive(args) { const logicalPath = this.resolveLogicalPath(args.path); const nextManifest = mergeManifestEntryDelta(this.state.manifest, logicalPath, args.entry); assertExistingMountTopologyPreserved(this.state.manifest, nextManifest); validateMountCredentialBoundaries(nextManifest); const runAs = await this.resolveFilesystemRunAs(args.runAs); await materializeLocalWorkspaceManifestEntry(this.state.workspaceRootPath, logicalPath, args.entry, { localSourceGrants: this.state.manifest.extraPathGrants, }); if (runAs) { await applyOwnershipRecursive(this.resolveSandboxPath(args.path), runAs.uid, runAs.gid); } this.state.manifest = mergeManifestEntryDelta(this.state.manifest, logicalPath, args.entry); this.captureFilesystemRoots(); captureLiveMountCredentialAuthority(this.state.manifest); } async applyManifest(manifest, runAs) { const manifestSnapshot = cloneManifest(manifest); return await withExclusiveSandboxManifestMutation(this.state, async () => this.applyManifestExclusive(manifestSnapshot, runAs)); } async applyManifestExclusive(manifest, runAs) { const nextManifest = mergeManifestDelta(this.state.manifest, manifest); assertExistingMountTopologyPreserved(this.state.manifest, nextManifest); validateMountCredentialBoundaries(nextManifest); assertUnixLocalHostPathGrantsUnsupported(manifest); assertLocalWorkspaceManifestMetadataSupported('UnixLocalSandboxClient', manifest); const identity = await this.resolveFilesystemRunAs(runAs); await materializeLocalWorkspaceManifest(manifest, this.state.workspaceRootPath); const environment = await manifest.resolveEnvironment(); if (identity) { for (const path of Object.keys(manifest.entries)) { await applyOwnershipRecursive(this.resolveSandboxPath(path), identity.uid, identity.gid); } } this.state.environment = { ...this.state.environment, ...environment, }; this.state.manifest = mergeManifestDelta(this.state.manifest, manifest); this.captureFilesystemRoots(); captureLiveMountCredentialAuthority(this.state.manifest); } async persistWorkspace() { return await withUnixLocalFileAccess(this.state, async () => { this.assertSessionUsable(); return await createWorkspaceArchive(this.state.workspaceRootPath, this.state.manifest.ephemeralPersistencePaths()); }); } async hydrateWorkspace(data, options = {}) { this.assertSessionUsable(); await restoreWorkspaceArchive(data, this.state.workspaceRootPath, { archiveLimits: options.archiveLimits === undefined ? this.archiveLimits : options.archiveLimits, }); await this.materializeRestoredWorkspaceMounts(); } async stop() { this.stopPromise ??= this.stopResources().finally(() => { this.stopPromise = undefined; }); await this.stopPromise; } async stopResources() { this.fileOperationGeneration++; if (this.hostFiles) { await this.hostFiles.stop(); await withUnixLocalFileAccess(this.state, async () => { }); } await this.stopActiveProcesses(); } async shutdown() { await this.close(); } async delete() { await this.close(); } async close() { this.closePromise ??= this.closeResources(); await this.closePromise; } async closeResources() { await this.hostFiles?.close(); await this.stop(); if (this.state.workspaceRootOwned) { await rm(this.state.workspaceRootPath, { recursive: true, force: true }); } } async stopActiveProcesses() { await Promise.all([...this.activeProcesses.values()].map(async (activeProcess) => { if (!activeProcess.done) { activeProcess.child.kill('SIGTERM'); await waitForProcessOrTimeout(activeProcess, 250); } if (!activeProcess.done) { activeProcess.child.kill('SIGKILL'); await activeProcess.donePromise.catch(() => { }); } })); this.activeProcesses.clear(); } resolveSandboxPath(path, options = {}) { return this.resolveHostPath(path, options, 'content').accessPath; } resolveFilesystemPath(path, options = {}, mode = 'content') { // Preserve provider overrides that reject container-only paths before host I/O. if (mode === 'content') { this.resolveSandboxPath(path, options); } return this.resolveHostPath(path, options, mode); } resolveHostPath(path, options, mode) { this.assertSessionUsable(); const resolved = this.resolveSandboxPathTarget(path, options); const workspaceRelativePath = resolved.workspaceRelativePath ?? ''; if (resolved.grant) { const authority = this.filesystemRoot(resolved.grant.path); const root = authority.content; return validateResolvedHostPath({ path, accessPath: resolved.path, resolvedPath: resolve(root, relative(resolved.grant.path, resolved.path)), allowedRoot: root, entryRoot: authority.entry, preserveLeaf: mode === 'entry', }); } const mountPath = this.resolveLocalBindMountPath(workspaceRelativePath, path, options, mode); if (mountPath) { return mountPath; } const authority = this.filesystemRoot(this.state.workspaceRootPath); const root = authority.content; const resolvedPath = resolve(root, workspaceRelativePath); const relativeToRoot = relative(root, resolvedPath); if (relativeHostPathEscapesRoot(relativeToRoot)) { throw new UserError(`Sandbox path "${path}" escapes the workspace root.`); } return validateResolvedHostPath({ path, accessPath: resolve(this.state.workspaceRootPath, workspaceRelativePath), resolvedPath, allowedRoot: root, entryRoot: authority.entry, preserveLeaf: mode === 'entry', }); } resolveCommandWorkdir(path) { return this.resolveSandboxPath(path); } assertSessionUsable() { assertSandboxSessionStateUsable(this.state); } captureFilesystemRoots() { if (!this.hostFiles) return; const paths = [ this.state.workspaceRootPath, ...this.state.manifest.extraPathGrants.map((grant) => grant.path), ...this.state.manifest.mountTargets().flatMap(({ entry }) => { const source = localBindMountSource(entry); return source ? [source] : []; }), ]; for (const path of paths) { if (!this.filesystemRoots.has(path)) { try { this.filesystemRoots.set(path, { content: realpathSync(path), entry: resolve(realpathSync(dirname(path)), basename(path)), }); } catch { this.filesystemRoots.set(path, new UserError(`Sandbox filesystem root is unavailable: ${path}`)); } } } } filesystemRoot(path) { if (!this.hostFiles) { return { content: realpathForValidation(path), entry: path }; } this.captureFilesystemRoots(); const root = this.filesystemRoots.get(path); if (root instanceof Error) throw root; return root; } async applyFileOperation(operation, runAs) { const generation = this.fileOperationGeneration; const assertCurrent = () => { if (this.closePromise) { throw new UserError('UnixLocal file operations are closed.'); } if (this.stopPromise || generation !== this.fileOperationGeneration) { throw new UserError('UnixLocal file operation cancelled.'); } }; assertCurrent(); return await withUnixLocalFileAccess(this.state, async () => { assertCurrent(); this.assertSessionUsable(); const identity = await this.resolveFilesystemRunAs(runAs); assertCurrent(); const path = this.resolveFilesystemPath(operation.path, { forWrite: true }, operation.type === 'delete_file' ? 'entry' : 'content'); if (operation.type === 'create_file') { const input = applyDiff('', operation.diff, 'create'); await this.files.run({ operation: 'create', path }, { input, identity }); } else if (operation.type === 'update_file') { const destination = operation.moveTo ? this.resolveFilesystemPath(operation.moveTo, { forWrite: true }) : path; const unlinkPath = operation.moveTo && this.resolveSandboxPath(operation.moveTo, { forWrite: true }) !== this.resolveSandboxPath(operation.path, { forWrite: true }) ? this.resolveFilesystemPath(operation.path, { forWrite: true }, 'entry') : undefined; await this.files.run({ operation: 'update', path, destination, unlinkPath }, { identity, update: (current) => applyDiff(current, operation.diff) }); } else { await this.files.run({ operation: 'delete', path }, { identity }); } }); } resolveSandboxPathTarget(path, options = {}) { return new WorkspacePathPolicy({ root: this.state.manifest.root, extraPathGrants: this.state.manifest.extraPathGrants, }).resolve(path, options); } resolveLocalBindMountPath(workspaceRelativePath, path, options, mode) { for (const { entry, mountPath } of this.state.manifest.mountTargets()) { const source = localBindMountSource(entry); if (!source) { continue; } const mountRelativePath = new WorkspacePathPolicy({ root: this.state.manifest.root, }).resolve(mountPath).workspaceRelativePath; if (typeof mountRelativePath !== 'string') { continue; } if (!pathWithinLogicalRoot(workspaceRelativePath, mountRelativePath)) { continue; } if (options.forWrite && entry.readOnly !== false) { throw new UserError(`Sandbox path "${path}" is inside a read-only local bind mount.`); } const childPath = workspaceRelativePath === mountRelativePath ? '' : workspaceRelativePath.slice(mountRelativePath.length + 1); const authority = this.filesystemRoot(source); const root = authority.content; const resolvedPath = childPath ? resolve(root, childPath) : root; return validateResolvedHostPath({ path, accessPath: childPath ? resolve(source, childPath) : source, resolvedPath, allowedRoot: root, entryRoot: authority.entry, preserveLeaf: mode === 'entry', }); } return undefined; } resolveLogicalPath(path) { if (!path || path.trim().length === 0) { return ''; } const logicalRoot = this.state.manifest.root; const trimmed = path.trim(); if (trimmed === logicalRoot) { return ''; } if (logicalRoot === '/') { return normalizeRelativePath(trimmed.startsWith('/') ? trimmed.slice(1) : trimmed); } if (trimmed.startsWith(`${logicalRoot}/`)) { return normalizeRelativePath(trimmed.slice(logicalRoot.length + 1)); } if (trimmed.startsWith('/')) { throw new UserError(`Sandbox path "${path}" must stay within ${logicalRoot}.`); } return normalizeRelativePath(trimmed); } async spawnShellCommand(command, args) { const { shellPath, flag } = resolveLocalShellCommand({ shell: args.shell, defaultShell: this.defaultShell, envShell: process.env.SHELL, login: args.login, }); const runAs = await this.resolveCommandRunAs(args.runAs); const env = buildCommandEnvironment({ environment: this.state.environment, shellPath, home: this.state.workspaceRootPath, pwd: args.logicalCwd, }); if (args.tty) { return spawnInPseudoTerminal(shellPath, [flag, command], { cwd: args.cwd, env, ...(runAs ? { uid: runAs.uid, gid: runAs.gid, } : {}), }); } return spawn(shellPath, [flag, command], { cwd: args.cwd, env, ...(runAs ? { uid: runAs.uid, gid: runAs.gid, } : {}), stdio: 'pipe', }); } translateCommandInput(command) { // Unix-local is not a chroot; command rewriting preserves manifest-root UX while // filesystem APIs continue to enforce host-path containment separately. if (this.state.manifest.root === '/') { return translateRootManifestCommandInput(command, this.state.workspaceRootPath); } return translateManifestRootCommandInput(command, this.state.manifest.root, this.state.workspaceRootPath); } translateCommandOutput(output) { if (this.state.manifest.root === '/') { return translateRootManifestCommandOutput(output, this.state.workspaceRootPath); } return translateManifestRootCommandOutput(output, this.state.manifest.root, this.state.workspaceRootPath); } async materializeRestoredWorkspaceMounts() { await materializeLocalWorkspaceManifestMounts(this.state.manifest, this.state.workspaceRootPath); } logicalWorkdirForPath(path) { return this.resolveSandboxPathTarget(path).path; } async resolveCommandRunAs(runAs) { const identity = await this.resolveRunAsIdentity(runAs); if (!identity || identity.isCurrentUser) { return identity; } if (typeof process.getuid !== 'function' || process.getuid() !== 0) { throw new UserError(`Unix-local sandbox runAs="${runAs}" requires the host process to run as root or the requested user.`); } return identity; } async resolveFilesystemRunAs(runAs) { const identity = await this.resolveRunAsIdentity(runAs); if (!identity || identity.isCurrentUser) { return identity; } if (typeof process.getuid !== 'function' || process.getuid() !== 0) { throw new UserError(`Unix-local sandbox filesystem operations cannot honor runAs="${runAs}" without root privileges.`); } return identity; } async resolveRunAsIdentity(runAs) { if (!runAs || runAs.trim().length === 0) { return undefined; } const requestedUser = runAs.trim(); const currentUser = userInfo().username; const currentUid = typeof process.getuid === 'function' ? process.getuid() : undefined; const currentGid = typeof process.getgid === 'function' ? process.getgid() : undefined; if (requestedUser === currentUser && currentUid !== undefined && currentGid !== undefined) { return { username: currentUser, uid: currentUid, gid: currentGid, isCurrentUser: true, }; } const [uidResult, gidResult] = await Promise.all([ runSandboxProcess('id', ['-u', requestedUser], { timeoutMs: RUN_AS_LOOKUP_TIMEOUT_MS, }), runSandboxProcess('id', ['-g', requestedUser], { timeoutMs: RUN_AS_LOOKUP_TIMEOUT_MS, }), ]); if (uidResult.status !== 0 || gidResult.status !== 0) { throw new UserError(`Sandbox runAs user "${requestedUser}" could not be resolved on this host.`); } const uid = Number(uidResult.stdout.trim()); const gid = Number(gidResult.stdout.trim()); return { username: requestedUser, uid, gid, isCurrentUser: currentUid !== undefined && currentGid !== undefined && uid === currentUid && gid === currentGid, }; } trackChildProcess(child, options = {}) { let resolveDone = () => { }; const donePromise = new Promise((resolve) => { resolveDone = resolve; }); let resolveOutputClosed = () => { }; const outputClosedPromise = new Promise((resolve) => { resolveOutputClosed = resolve; }); const activeProcess = { child, tty: options.tty ?? false, output: '', stdout: '', stderr: '', droppedOutputChars: 0, droppedStdoutChars: 0, droppedStderrChars: 0, exitCode: null, done: false, donePromise, resolveDone, outputClosedPromise, resolveOutputClosed, }; let openOutputStreams = 2; const markOutputStreamClosed = () => { openOutputStreams -= 1; if (openOutputStreams === 0) { activeProcess.resolveOutputClosed(); } }; child.stdout.setEncoding('utf8'); child.stderr.setEncoding('utf8'); child.stdout.on('data', (chunk) => { appendActiveProcessOutput(activeProcess, chunk, 'stdout'); }); child.stderr.on('data', (chunk) => { appendActiveProcessOutput(activeProcess, chunk, 'stderr'); }); child.stdout.once('close', markOutputStreamClosed); child.stderr.once('close', markOutputStreamClosed); child.on('close', (code, signal) => { activeProcess.exitCode = code ?? signalToExitCode(signal); activeProcess.done = true; activeProcess.resolveDone(); }); child.on('error', (error) => { appendActiveProcessOutput(activeProcess, `${error.message}\n`, 'stderr'); activeProcess.exitCode = 1; activeProcess.done = true; activeProcess.resolveOutputClosed(); activeProcess.resolveDone(); }); return activeProcess; } allocateProcessId(activeProcess) { const sessionId = this.nextSessionId++; this.activeProcesses.set(sessionId, activeProcess); return sessionId; } } /** * Local sandbox client for Unix hosts. * Host file operations require trusted Python 3 with descriptor-relative * filesystem support. Set the host OPENAI_AGENTS_PYTHON to an absolute trusted * executable to override discovery. Unavailable protection and fileIOProtection: * 'off' fail during preparation. Resumed sessions use current client configuration. * File tools honor runAs using host permissions. Editor updates replace files, * require writable parent directories, and leave other hardlinks unchanged. */ export class UnixLocalSandboxClient { backendId = 'unix_local'; supportsDefaultOptions = true; options; constructor(options = {}) { this.options = options; } async create(args, manifestOptions) { const createArgs = normalizeSandboxClientCreateArgs(args, manifestOptions); const manifest = createArgs.manifest; const resolvedOptions = { ...this.options, ...createArgs.options, ...(createArgs.snapshot ? { snapshot: createArgs.snapshot } : {}), ...(createArgs.concurrencyLimits ? { concurrencyLimits: createArgs.concurrencyLimits } : {}), ...(createArgs.archiveLimits !== undefined ? { archiveLimits: createArgs.archiveLimits } : {}), }; validateMountCredentialBoundaries(manifest); assertUnixLocalHostPathGrantsUnsupported(manifest); assertLocalWorkspaceManifestMetadataSupported('UnixLocalSandboxClient', manifest); const files = new UnixLocalFiles(resolvedOptions.fileIOProtection); const workspaceRootPath = await mkdtemp(join(resolvedOptions.workspaceBaseDir ?? tmpdir(), 'openai-agents-sandbox-')); await materializeLocalWorkspaceManifest(manifest, workspaceRootPath, { concurrencyLimits: resolvedOptions.concurrencyLimits, }); const environment = await manifest.resolveEnvironment(); const configuredExposedPorts = normalizeExposedPorts(resolvedOptions.exposedPorts); return new UnixLocalSandboxSession({ [preparedFileIO]: files, state: { manifest, workspaceRootPath, workspaceRootOwned: true, environment, snapshotSpec: resolvedOptions.snapshot ?? null, snapshot: null, configuredExposedPorts, }, defaultShell: resolvedOptions.defaultShell, archiveLimits: resolvedOptions.archiveLimits, }); } async resume(state, options = {}) { assertMountCredentialsRebound(state); validateMountCredentialBoundaries(state.manifest); assertHostPathGrantsRebound(state); assertUnixLocalHostPathGrantsUnsupported(state.manifest); const archiveLimits = options.archiveLimits === undefined ? this.options.archiveLimits : options.archiveLimits; const currentOptions = (getRunStateSessionTrustedConfig(state) ?.clientOptions ?? options.clientOptions); const files = new UnixLocalFiles(currentOptions?.fileIOProtection ?? this.options.fileIOProtection); const restoredState = await this.restoreIfNeeded(state, archiveLimits); return new UnixLocalSandboxSession({ [preparedFileIO]: files, state: restoredState, defaultShell: this.options.defaultShell, archiveLimits, }); } async serializeSessionState(state) { assertSandboxSessionStateUsable(state); const stateGeneration = captureSandboxStateGeneration(state); assertUnixLocalHostPathGrantsUnsupported(state.manifest); const snapshotSpec = state.snapshotSpec ?? this.options.snapshot ?? null; const snapshot = await persistLocalSnapshot('UnixLocalSandboxClient', state, snapshotSpec, (capture) => withUnixLocalFileAccess(state, capture)); state.snapshotSpec = snapshotSpec; const serialized = { ...serializeMountCredentialRedactionMetadata(state), manifest: serializeManifest(state.manifest), workspaceRootPath: state.workspaceRootPath, workspaceRootOwned: state.workspaceRootOwned, environment: sanitizeEnvironmentForPersistence(state), snapshotSpec: serializeLocalSnapshotSpec(snapshotSpec), snapshot, snapshotFingerprint: state.snapshotFingerprint ?? null, snapshotFingerprintVersion: state.snapshotFingerprintVersion ?? null, configuredExposedPorts: state.configuredExposedPorts ?? [], exposedPorts: state.exposedPorts ?? null, }; assertSandboxStateGenerationUnchanged(state, stateGeneration); return serialized; } async deserializeSessionState(state) { return deserializeLocalSandboxSessionStateValues(state, this.options.snapshot); } async restoreIfNeeded(state, archiveLimits) { if (await pathExists(state.workspaceRootPath)) { if (await canReuseLocalSnapshotWorkspace(state)) { return state; } if (await localSnapshotIsRestorable(state)) { return await restoreSnapshotAndMounts(state, state.workspaceRootPath, archiveLimits); } return state; } if (!(await localSnapshotIsRestorable(state))) { throw new UserError('UnixLocal sandbox workspace is unavailable and no local snapshot could be restored.'); } const workspaceRootPath = await mkdtemp(join(this.options.workspaceBaseDir ?? tmpdir(), 'openai-agents-sandbox-')); return await restoreSnapshotAndMounts({ ...state, workspaceRootPath, workspaceRootOwned: true, }, workspaceRootPath, archiveLimits); } } async function restoreSnapshotAndMounts(state, workspaceRootPath, archiveLimits) { const restoredState = await restoreLocalSnapshotToWorkspace(state, workspaceRootPath, { archiveLimits }); // Restore sticky intent before mounts can expose host-owned directories. await restoreLocalWorkspaceManifestStickyPermissions(restoredState.manifest, restoredState.workspaceRootPath); await materializeLocalWorkspaceManifestMounts(restoredState.manifest, restoredState.workspaceRootPath); return restoredState; } function localBindMountSource(entry) { if (entry.type !== 'mount' || typeof entry.source !== 'string') { return undefined; } if (entry.mountStrategy !== undefined && entry.mountStrategy.type !== 'local_bind') { return undefined; } return isAbsolute(entry.source) ? entry.source : undefined; } function pathWithinLogicalRoot(path, root) { return path === root || path.startsWith(`${root}/`); } function assertUnixLocalHostPathGrantsUnsupported(manifest) { const grant = manifest.extraPathGrants.find(({ hostPath }) => hostPath !== undefined); if (!grant) { return; } throw new SandboxUnsupportedFeatureError(`UnixLocalSandboxClient does not support path grant hostPath for "${grant.path}". Omit hostPath when the host and sandbox paths are the same, or use DockerSandboxClient when they differ.`, { provider: 'UnixLocalSandboxClient', feature: 'manifest.extraPathGrants.hostPath', path: grant.path, }); } function validateResolvedHostPath(args) { const allowedRootRealPath = args.allowedRoot; if (args.preserveLeaf && args.resolvedPath !== allowedRootRealPath) { // Unlink owns the leaf entry, so only its parent needs target validation. const parent = realpathForValidation(dirname(args.resolvedPath), args.path); if (!isHostPathWithinRoot(allowedRootRealPath, parent)) { throw new UserError(`Sandbox path "${args.path}" escapes the workspace root.`); } return { path: resolve(parent, basename(args.resolvedPath)), accessPath: args.accessPath, preserveLeaf: true, }; } const existingPath = nearestExistingPath(args.resolvedPath); if (!existingPath) { throw new UserError(`Sandbox path "${args.path}" escapes the workspace root.`); } const realPath = realpathForValidation(existingPath, args.path); if (!isHostPathWithinRoot(allowedRootRealPath, realPath)) { throw new UserError(`Sandbox path "${args.path}" escapes the workspace root.`); } if (args.preserveLeaf) { return { path: args.entryRoot, accessPath: args.accessPath, preserveLeaf: true, rootTarget: allowedRootRealPath, }; } return { path: resolve(realPath, relative(existingPath, args.resolvedPath)), accessPath: args.accessPath, preserveLeaf: false, }; } function nearestExistingPath(path) { let current = path; while (true) { try { lstatSync(current); return current; } catch (error) { if (error.code !== 'ENOENT') { throw error; } } const parent = dirname(current); if (parent === current) { return undefined; } current = parent; } } function realpathForValidation(path, originalPath) { try { return realpathSync(path); } catch { throw new UserError(`Sandbox path "${originalPath}" escapes the workspace root.`); } } class UnixLocalSandboxEditor { session; apply; constructor(session, apply) { this.session = session; this.apply = apply; } canAccessPathForEdit(path) { try { this.session.resolveSandboxPath(path, { forWrite: true }); return true; } catch { return false; } } async createFile(operation) { await this.apply(operation); return {}; } async updateFile(operation) { await this.apply(operation); return {}; } async deleteFile(operation) { await this.apply(operation); return {}; } } function buildCommandEnvironment(args) { return { PATH: DEFAULT_SANDBOX_COMMAND_PATH, HOME: args.home, USER: 'sandbox', LOGNAME: 'sandbox', SHELL: args.shellPath, TMPDIR: '/tmp', ...args.environment, PWD: args.pwd, }; } function translateRootManifestCommandInput(command, workspaceRootPath) { return command.replace(/(^|[\s"'=<>])\/([^\s"'|&;<>(){}]*)/g, (_match, prefix, pathSuffix) => `${prefix}${workspaceRootPath}/${pathSuffix}`); } function translateManifestRootCommandInput(command, manifestRoot, workspaceRootPath) { const escapedManifestRoot = escapeRegExp(manifestRoot); const pathPrefix = String.raw `(^|[\s"'=<>])`; const pathSuffix = String.raw `(?=$|[\/\s"'|&;<>(){}])`; return command.replace(new RegExp(`${pathPrefix}${escapedManifestRoot}${pathSuffix}`, 'g'), (_match, prefix) => `${prefix}${workspaceRootPath}`); } function translateRootManifestCommandOutput(output, workspaceRootPath) { return translateWorkspaceRootCommandOutput(output, '/', workspaceRootPath); } function translateManifestRootCommandOutput(output, manifestRoot, workspaceRootPath) { return translateWorkspaceRootCommandOutput(output, manifestRoot, workspaceRootPath); } function translateWorkspaceRootCommandOutput(output, manifestRoot, workspaceRootPath) { const nestedPathReplacement = manifestRoot === '/' ? '/' : `${manifestRoot}/`; let translated = output; for (const path of workspaceRootOutputPaths(workspaceRootPath)) { translated = translated .split(`${path}/`) .join(nestedPathReplacement) .split(path) .join(manifestRoot); } return translated; } function workspaceRootOutputPaths(workspaceRootPath) { const paths = [workspaceRootPath]; try { paths.push(realpathSync(workspaceRootPath)); } catch { return paths; } return Array.from(new Set(paths)).sort((a, b) => b.length - a.length); } function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function appendActiveProcessOutput(activeProcess, chunk, stream) { if (chunk.length === 0) { return; } const combined = appendBoundedOutput(activeProcess.output, activeProcess.droppedOutputChars, chunk); activeProcess.output = combined.output; activeProcess.droppedOutputChars = combined.droppedChars; if (stream === 'stdout') { const stdout = appendBoundedOutput(activeProcess.stdout, activeProcess.droppedStdoutChars, chunk); activeProcess.stdout = stdout.output; activeProcess.droppedStdoutChars = stdout.droppedChars; return; } const stderr = appendBoundedOutput(activeProcess.stderr, activeProcess.droppedStderrChars, chunk); activeProcess.stderr = stderr.output; activeProcess.droppedStderrChars = stderr.droppedChars; } function appendBoundedOutput(output, droppedChars, chunk) { const nextLength = output.length + chunk.length; if (nextLength <= MAX_ACTIVE_PROCESS_OUTPUT_CHARS) { return { output: output + chunk, droppedChars, }; } const nextDroppedChars = droppedChars + nextLength - MAX_ACTIVE_PROCESS_OUTPUT_CHARS; if (chunk.length >= MAX_ACTIVE_PROCESS_OUTPUT_CHARS) { return { output: chunk.slice(-MAX_ACTIVE_PROCESS_OUTPUT_CHARS), droppedChars: nextDroppedChars, }; } const existingCharsToKeep = MAX_ACTIVE_PROCESS_OUTPUT_CHARS - chunk.length; return { output: output.slice(-existingCharsToKeep) + chunk, droppedChars: nextDroppedChars, }; } function consumeActiveProcessOutput(activeProcess) { const output = withDroppedOutputPrefix(activeProcess.output, activeProcess.droppedOutputChars); const stdout = withDroppedOutputPrefix(activeProcess.stdout, activeProcess.droppedStdoutChars); const stderr = withDroppedOutputPrefix(activeProcess.stderr, activeProcess.droppedStderrChars); activeProcess.output = ''; activeProcess.stdout = ''; activeProcess.stderr = ''; activeProcess.droppedOutputChars = 0; activeProcess.droppedStdoutChars = 0; activeProcess.droppedStderrChars = 0; return { output, stdout, stderr }; } function withDroppedOutputPrefix(output, droppedChars) { if (droppedChars === 0) { return output; } return `[...${droppedChars} characters truncated from process output...]\n${output}`; } async function waitForProcessOrTimeout(activeProcess, timeoutMs) { if (activeProcess.done) { await activeProcess.outputClosedPromise; return; } await Promise.race([ activeProcess.donePromise, new Promise((resolve) => setTimeout(resolve, timeoutMs)), ]); if (activeProcess.done) { await activeProcess.outputClosedPromise; } } function signalToExitCode(signal) { if (signal === 'SIGINT') { return 130; } return 1; } //# sourceMappingURL=unixLocal.mjs.map