import { constants as fsConstants } from 'fs'import {type FileHandle,mkdir,open,stat,symlink,unlink,} from 'fs/promises'import { join } from 'path'import { getSessionId } from '../../bootstrap/state.js'import { getErrnoCode } from '../errors.js'import { readFileRange, tailFile } from '../fsOperations.js'import { logError } from '../log.js'import { getProjectTempDir } from '../permissions/filesystem.js'// SECURITY: O_NOFOLLOW prevents following symlinks when opening task output files.// Without this, an attacker in the sandbox could create symlinks in the tasks directory// pointing to arbitrary files, causing Claude Code on the host to write to those files.// O_NOFOLLOW is not available on Windows, but the sandbox attack vector is Unix-only.const O_NOFOLLOW = fsConstants.O_NOFOLLOW ?? 0const DEFAULT_MAX_READ_BYTES = 8 * 1024 * 1024 // 8MB/*** Disk cap for task output files. In file mode (bash), a watchdog polls* file size and kills the process. In pipe mode (hooks), DiskTaskOutput* drops chunks past this limit. Shared so both caps stay in sync.*/export const MAX_TASK_OUTPUT_BYTES = 5 * 1024 * 1024 * 1024export const MAX_TASK_OUTPUT_BYTES_DISPLAY = '5GB'/*** Get the task output directory for this session.* Uses project temp directory so reads are auto-allowed by checkReadableInternalPath.** The session ID is included so concurrent sessions in the same project don't* clobber each other's output files. Startup cleanup in one session previously* unlinked in-flight output files from other sessions — the writing process's fd* keeps the inode alive but reads via path fail ENOENT, and getStdout() returned* empty string (inc-4586 / boris-20260309-060423).** The session ID is captured at FIRST CALL, not re-read on every invocation.* /clear calls regenerateSessionId(), which would otherwise cause* ensureOutputDir() to create a new-session path while existing TaskOutput* instances still hold old-session paths — open() would ENOENT. Background* bash tasks surviving /clear need their output files to stay reachable.*/let _taskOutputDir: string | undefinedexport function getTaskOutputDir(): string {if (_taskOutputDir === undefined) {_taskOutputDir = join(getProjectTempDir(), getSessionId(), 'tasks')}return _taskOutputDir}/** Test helper — clears the memoized dir. */export function _resetTaskOutputDirForTest(): void {_taskOutputDir = undefined}/*** Ensure the task output directory exists*/async function ensureOutputDir(): Promise<void> {await mkdir(getTaskOutputDir(), { recursive: true })}/*** Get the output file path for a task*/export function getTaskOutputPath(taskId: string): string {return join(getTaskOutputDir(), `${taskId}.output`)}// Tracks fire-and-forget promises (initTaskOutput, initTaskOutputAsSymlink,// evictTaskOutput, #drain) so tests can drain before teardown. Prevents the// async-ENOENT-after-teardown flake class (#24957, #25065): a voided async// resumes after preload's afterEach nuked the temp dir → ENOENT → unhandled// rejection → flaky test failure. allSettled so a rejection doesn't short-// circuit the drain and leave other ops racing the rmSync.const _pendingOps = new Set<Promise<unknown>>()function track<T>(p: Promise<T>): Promise<T> {_pendingOps.add(p)void p.finally(() => _pendingOps.delete(p)).catch(() => {})return p}/*** Encapsulates async disk writes for a single task's output.** Uses a flat array as a write queue processed by a single drain loop,* so each chunk can be GC'd immediately after its write completes.* This avoids the memory retention problem of chained .then() closures* where every reaction captures its data until the whole chain resolves.*/export class DiskTaskOutput {#path: string#fileHandle: FileHandle | null = null#queue: string[] = []#bytesWritten = 0#capped = false#flushPromise: Promise<void> | null = null#flushResolve: (() => void) | null = nullconstructor(taskId: string) {this.#path = getTaskOutputPath(taskId)}append(content: string): void {if (this.#capped) {return}// content.length (UTF-16 code units) undercounts UTF-8 bytes by at most ×ばつ.// Acceptable for a coarse disk-fill guard — avoids re-scanning every chunk.this.#bytesWritten += content.lengthif (this.#bytesWritten > MAX_TASK_OUTPUT_BYTES) {this.#capped = truethis.#queue.push(`\n[output truncated: exceeded ${MAX_TASK_OUTPUT_BYTES_DISPLAY} disk cap]\n`,)} else {this.#queue.push(content)}if (!this.#flushPromise) {this.#flushPromise = new Promise<void>(resolve => {this.#flushResolve = resolve})void track(this.#drain())}}flush(): Promise<void> {return this.#flushPromise ?? Promise.resolve()}cancel(): void {this.#queue.length = 0}async #drainAllChunks(): Promise<void> {while (true) {try {if (!this.#fileHandle) {await ensureOutputDir()this.#fileHandle = await open(this.#path,process.platform === 'win32'? 'a': fsConstants.O_WRONLY |fsConstants.O_APPEND |fsConstants.O_CREAT |O_NOFOLLOW,)}while (true) {await this.#writeAllChunks()if (this.#queue.length === 0) {break}}} finally {if (this.#fileHandle) {const fileHandle = this.#fileHandlethis.#fileHandle = nullawait fileHandle.close()}}// you could have another .append() while we're waiting for the file to close, so we check the queue again before fully exitingif (this.#queue.length) {continue}break}}#writeAllChunks(): Promise<void> {// This code is extremely precise.// You **must not** add an await here!! That will cause memory to balloon as the queue grows.// It's okay to add an `await` to the caller of this method (e.g. #drainAllChunks) because that won't cause Buffer[] to be kept alive in memory.return this.#fileHandle!.appendFile(// This variable needs to get GC'd ASAP.this.#queueToBuffers(),)}/** Keep this in a separate method so that GC doesn't keep it alive for any longer than it should. */#queueToBuffers(): Buffer {// Use .splice to in-place mutate the array, informing the GC it can free it.const queue = this.#queue.splice(0, this.#queue.length)let totalLength = 0for (const str of queue) {totalLength += Buffer.byteLength(str, 'utf8')}const buffer = Buffer.allocUnsafe(totalLength)let offset = 0for (const str of queue) {offset += buffer.write(str, offset, 'utf8')}return buffer}async #drain(): Promise<void> {try {await this.#drainAllChunks()} catch (e) {// Transient fs errors (EMFILE on busy CI, EPERM on Windows pending-// delete) previously rode up through `void this.#drain()` as an// unhandled rejection while the flush promise resolved anyway — callers// saw an empty file with no error. Retry once for the transient case// (queue is intact if open() failed), then log and give up.logError(e)if (this.#queue.length > 0) {try {await this.#drainAllChunks()} catch (e2) {logError(e2)}}} finally {const resolve = this.#flushResolve!this.#flushPromise = nullthis.#flushResolve = nullresolve()}}}const outputs = new Map<string, DiskTaskOutput>()/*** Test helper — cancel pending writes, await in-flight ops, clear the map.* backgroundShells.test.ts and other task tests spawn real shells that* write through this module without afterEach cleanup; their entries* leak into diskOutput.test.ts on the same shard.** Awaits all tracked promises until the set stabilizes — a settling promise* may spawn another (initTaskOutputAsSymlink's catch → initTaskOutput).* Call this in afterEach BEFORE rmSync to avoid async-ENOENT-after-teardown.*/export async function _clearOutputsForTest(): Promise<void> {for (const output of outputs.values()) {output.cancel()}while (_pendingOps.size > 0) {await Promise.allSettled([..._pendingOps])}outputs.clear()}function getOrCreateOutput(taskId: string): DiskTaskOutput {let output = outputs.get(taskId)if (!output) {output = new DiskTaskOutput(taskId)outputs.set(taskId, output)}return output}/*** Append output to a task's disk file asynchronously.* Creates the file if it doesn't exist.*/export function appendTaskOutput(taskId: string, content: string): void {getOrCreateOutput(taskId).append(content)}/*** Wait for all pending writes for a task to complete.* Useful before reading output to ensure all data is flushed.*/export async function flushTaskOutput(taskId: string): Promise<void> {const output = outputs.get(taskId)if (output) {await output.flush()}}/*** Evict a task's DiskTaskOutput from the in-memory map after flushing.* Unlike cleanupTaskOutput, this does not delete the output file on disk.* Call this when a task completes and its output has been consumed.*/export function evictTaskOutput(taskId: string): Promise<void> {return track((async () => {const output = outputs.get(taskId)if (output) {await output.flush()outputs.delete(taskId)}})(),)}/*** Get delta (new content) since last read.* Reads only from the byte offset, up to maxBytes — never loads the full file.*/export async function getTaskOutputDelta(taskId: string,fromOffset: number,maxBytes: number = DEFAULT_MAX_READ_BYTES,): Promise<{ content: string; newOffset: number }> {try {const result = await readFileRange(getTaskOutputPath(taskId),fromOffset,maxBytes,)if (!result) {return { content: '', newOffset: fromOffset }}return {content: result.content,newOffset: fromOffset + result.bytesRead,}} catch (e) {const code = getErrnoCode(e)if (code === 'ENOENT') {return { content: '', newOffset: fromOffset }}logError(e)return { content: '', newOffset: fromOffset }}}/*** Get output for a task, reading the tail of the file.* Caps at maxBytes to avoid loading multi-GB files into memory.*/export async function getTaskOutput(taskId: string,maxBytes: number = DEFAULT_MAX_READ_BYTES,): Promise<string> {try {const { content, bytesTotal, bytesRead } = await tailFile(getTaskOutputPath(taskId),maxBytes,)if (bytesTotal > bytesRead) {return `[${Math.round((bytesTotal - bytesRead) / 1024)}KB of earlier output omitted]\n${content}`}return content} catch (e) {const code = getErrnoCode(e)if (code === 'ENOENT') {return ''}logError(e)return ''}}/*** Get the current size (offset) of a task's output file.*/export async function getTaskOutputSize(taskId: string): Promise<number> {try {return (await stat(getTaskOutputPath(taskId))).size} catch (e) {const code = getErrnoCode(e)if (code === 'ENOENT') {return 0}logError(e)return 0}}/*** Clean up a task's output file and write queue.*/export async function cleanupTaskOutput(taskId: string): Promise<void> {const output = outputs.get(taskId)if (output) {output.cancel()outputs.delete(taskId)}try {await unlink(getTaskOutputPath(taskId))} catch (e) {const code = getErrnoCode(e)if (code === 'ENOENT') {return}logError(e)}}/*** Initialize output file for a new task.* Creates an empty file to ensure the path exists.*/export function initTaskOutput(taskId: string): Promise<string> {return track((async () => {await ensureOutputDir()const outputPath = getTaskOutputPath(taskId)// SECURITY: O_NOFOLLOW prevents symlink-following attacks from the sandbox.// O_EXCL ensures we create a new file and fail if something already exists at this path.// On Windows, use string flags — numeric O_EXCL can produce EINVAL through libuv.const fh = await open(outputPath,process.platform === 'win32'? 'wx': fsConstants.O_WRONLY |fsConstants.O_CREAT |fsConstants.O_EXCL |O_NOFOLLOW,)await fh.close()return outputPath})(),)}/*** Initialize output file as a symlink to another file (e.g., agent transcript).* Tries to create the symlink first; if a file already exists, removes it and retries.*/export function initTaskOutputAsSymlink(taskId: string,targetPath: string,): Promise<string> {return track((async () => {try {await ensureOutputDir()const outputPath = getTaskOutputPath(taskId)try {await symlink(targetPath, outputPath)} catch {await unlink(outputPath)await symlink(targetPath, outputPath)}return outputPath} catch (error) {logError(error)return initTaskOutput(taskId)}})(),)}
此处可能存在不合适展示的内容,页面不予展示。您可通过相关编辑功能自查并修改。
如您确认内容无涉及 不当用语 / 纯广告导流 / 暴力 / 低俗色情 / 侵权 / 盗版 / 虚假 / 无价值内容或违法国家有关法律法规的内容,可点击提交进行申诉,我们将尽快为您处理。