diff --git a/src/cli/bounded-stdin-text.test.ts b/src/cli/bounded-stdin-text.test.ts new file mode 100644 index 000000000000..6c5301308881 --- /dev/null +++ b/src/cli/bounded-stdin-text.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import { readStreamBytesWithinLimit, readTextStreamWithinLimit } from './bounded-stdin-text' + +const tooLarge = (): Error => new Error('too large') + +async function* stream( + chunks: (Buffer | Uint8Array | string)[] +): AsyncGenerator { + for (const chunk of chunks) { + yield chunk + } +} + +describe('bounded stream reading', () => { + it('preserves the bytes of a plain Uint8Array chunk', async () => { + const bytes = new Uint8Array(Buffer.from('segundo café', 'utf8')) + + await expect(readTextStreamWithinLimit(stream([bytes]), 64, tooLarge)).resolves.toBe( + 'segundo café' + ) + }) + + it('counts a Uint8Array chunk by its byte length, not its stringified form', async () => { + // 3 bytes stringify to "226,130,172", so a length-based limit must not see 11. + const euro = new Uint8Array(Buffer.from('€', 'utf8')) + + await expect(readStreamBytesWithinLimit(stream([euro]), 3, tooLarge)).resolves.toEqual( + Buffer.from('€', 'utf8') + ) + await expect(readStreamBytesWithinLimit(stream([euro]), 2, tooLarge)).rejects.toThrow( + 'too large' + ) + }) + + it('reads a Uint8Array view without dragging in the rest of its backing buffer', async () => { + const backing = Buffer.from('prefix-payload-suffix', 'utf8') + const view = new Uint8Array(backing.buffer, backing.byteOffset + 7, 7) + + await expect(readTextStreamWithinLimit(stream([view]), 64, tooLarge)).resolves.toBe('payload') + }) + + it('accepts string chunks and joins multi-byte characters split across chunks', async () => { + const cafe = Buffer.from('café', 'utf8') + + await expect(readTextStreamWithinLimit(stream(['ab', 'cd']), 64, tooLarge)).resolves.toBe( + 'abcd' + ) + await expect( + readTextStreamWithinLimit(stream([cafe.subarray(0, -1), cafe.subarray(-1)]), 64, tooLarge) + ).resolves.toBe('café') + }) +}) diff --git a/src/cli/bounded-stdin-text.ts b/src/cli/bounded-stdin-text.ts new file mode 100644 index 000000000000..a7e4c1e4dd97 --- /dev/null +++ b/src/cli/bounded-stdin-text.ts @@ -0,0 +1,46 @@ +type BoundedStreamChunk = Buffer | Uint8Array | string + +export async function readStreamBytesWithinLimit( + input: AsyncIterable, + maxBytes: number, + createTooLargeError: () => Error +): Promise { + const chunks: Buffer[] = [] + let bytes = 0 + for await (const chunk of input) { + // Why: a plain Uint8Array has to be wrapped, not stringified, or its bytes become decimal text. + const buffer = Buffer.isBuffer(chunk) + ? chunk + : typeof chunk === 'string' + ? Buffer.from(chunk, 'utf8') + : Buffer.from(chunk) + bytes += buffer.length + if (bytes > maxBytes) { + throw createTooLargeError() + } + chunks.push(buffer) + } + return Buffer.concat(chunks) +} + +export async function readTextStreamWithinLimit( + input: AsyncIterable, + maxBytes: number, + createTooLargeError: () => Error +): Promise { + return (await readStreamBytesWithinLimit(input, maxBytes, createTooLargeError)).toString('utf8') +} + +export function readStdinBytesWithinLimit( + maxBytes: number, + createTooLargeError: () => Error +): Promise { + return readStreamBytesWithinLimit(process.stdin, maxBytes, createTooLargeError) +} + +export function readStdinTextWithinLimit( + maxBytes: number, + createTooLargeError: () => Error +): Promise { + return readTextStreamWithinLimit(process.stdin, maxBytes, createTooLargeError) +} diff --git a/src/cli/handlers/artifacts.ts b/src/cli/handlers/artifacts.ts index 8546d7997f34..e2432b4ef858 100644 --- a/src/cli/handlers/artifacts.ts +++ b/src/cli/handlers/artifacts.ts @@ -21,6 +21,7 @@ import type { CommandHandler, HandlerContext } from '../dispatch' import { rejectRemoteSelectionFlags } from '../remote-selection-flag-rejection' import { RuntimeClientError } from '../runtime-client' import { formatArtifactListPage, formatArtifactShared } from '../artifact-format' +import { readStdinTextWithinLimit } from '../bounded-stdin-text' import { printResult } from '../format' function stringFlag(ctx: HandlerContext, name: string): string | undefined { @@ -61,23 +62,6 @@ function artifactContentType(path: string): ArtifactWriteRequest['contentType'] : null } -async function readStdinWithinLimit(maxBytes: number): Promise { - const chunks: Buffer[] = [] - let bytes = 0 - for await (const chunk of process.stdin) { - const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)) - bytes += buffer.length - if (bytes > maxBytes) { - throw new RuntimeClientError( - 'invalid_argument', - 'Artifact is too large for the Orca CLI transport. Use the browser upload page instead.' - ) - } - chunks.push(buffer) - } - return Buffer.concat(chunks).toString('utf8') -} - /** * Publishing is off by default, so denial is the common outcome. A tiny `settings.get` read * answers it before we load (or pipe in) up to the full RPC byte budget the host would reject. @@ -127,7 +111,14 @@ async function readArtifactRequest(ctx: HandlerContext): Promise + new RuntimeClientError( + 'invalid_argument', + 'Artifact is too large for the Orca CLI transport. Use the browser upload page instead.' + ) + ) : localRead?.status === 'ok' ? localRead.content : '' diff --git a/src/cli/handlers/terminal-send-text-file.test.ts b/src/cli/handlers/terminal-send-text-file.test.ts new file mode 100644 index 000000000000..9a7c9b66f9dd --- /dev/null +++ b/src/cli/handlers/terminal-send-text-file.test.ts @@ -0,0 +1,472 @@ +import { mkdtemp, open, rm, writeFile } from 'node:fs/promises' +import type { createReadStream as NodeCreateReadStream } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { TERMINAL_PROMPT_DELIVERY_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import { + TERMINAL_INPUT_MAX_BYTES, + TERMINAL_INPUT_TOO_LARGE_ERROR +} from '../../shared/terminal-input' +import type { RuntimeClient } from '../runtime-client' +import { printHelp } from '../help' +import { COMMAND_SPECS } from '../specs' +import { TERMINAL_HANDLERS } from './terminal' + +const { createReadStreamMock } = vi.hoisted(() => ({ createReadStreamMock: vi.fn() })) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal< + Record & { createReadStream: typeof NodeCreateReadStream } + >() + createReadStreamMock.mockImplementation(actual.createReadStream) + return { ...actual, createReadStream: createReadStreamMock } +}) + +const ORIGINAL_EXIT_CODE = process.exitCode + +describe('terminal send --text-file CLI', () => { + const tempDirectories: string[] = [] + + const promptClient = (call: ReturnType, getCliStatus: ReturnType) => + ({ call, getCliStatus }) as unknown as RuntimeClient + + const supportedCliStatus = () => + vi.fn().mockResolvedValue({ + result: { + runtime: { + reachable: true, + runtimeId: 'runtime-current', + capabilities: [TERMINAL_PROMPT_DELIVERY_RUNTIME_CAPABILITY] + } + } + }) + + afterEach(() => { + vi.restoreAllMocks() + process.exitCode = ORIGINAL_EXIT_CODE + return Promise.all( + tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })) + ) + }) + + async function createTempDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'orca-terminal-send-')) + tempDirectories.push(directory) + return directory + } + + it('reads multi-line UTF-8 text from a relative file', async () => { + const cwd = await createTempDirectory() + await writeFile(join(cwd, 'prompt.txt'), 'first line\nsegundo café', 'utf8') + const call = vi.fn().mockResolvedValue({ + result: { send: { handle: 'term-1', accepted: true, bytesWritten: 24 } } + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await TERMINAL_HANDLERS['terminal send']({ + flags: new Map([ + ['terminal', 'term-1'], + ['text-file', 'prompt.txt'] + ]), + client: { call } as unknown as RuntimeClient, + cwd, + json: true + }) + + expect(call).toHaveBeenCalledWith('terminal.send', { + terminal: 'term-1', + text: 'first line\nsegundo café', + enter: false, + interrupt: false, + client: { id: 'orca-cli', type: 'desktop' } + }) + }) + + it('reads text from stdin when --text-file is -', async () => { + const stdin = mockStdin(['line one\n', 'line two']) + const call = vi.fn().mockResolvedValue({ + result: { send: { handle: 'term-1', accepted: true, bytesWritten: 17 } } + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + try { + await TERMINAL_HANDLERS['terminal send']({ + flags: new Map([ + ['terminal', 'term-1'], + ['text-file', '-'] + ]), + client: { call } as unknown as RuntimeClient, + cwd: '/tmp/worktree', + json: true + }) + } finally { + stdin.restore() + } + + expect(call).toHaveBeenCalledWith( + 'terminal.send', + expect.objectContaining({ text: 'line one\nline two' }) + ) + }) + + it('rejects --text and --text-file together', async () => { + const call = vi.fn() + + await expect( + TERMINAL_HANDLERS['terminal send']({ + flags: new Map([ + ['terminal', 'term-1'], + ['text', 'inline'], + ['text-file', 'prompt.txt'] + ]), + client: { call } as unknown as RuntimeClient, + cwd: '/tmp/worktree', + json: true + }) + ).rejects.toThrow('--text and --text-file cannot be used together') + expect(call).not.toHaveBeenCalled() + }) + + it('names a missing text file in the error', async () => { + const cwd = await createTempDirectory() + const call = vi.fn() + + await expect( + TERMINAL_HANDLERS['terminal send']({ + flags: new Map([ + ['terminal', 'term-1'], + ['text-file', 'missing-prompt.txt'] + ]), + client: { call } as unknown as RuntimeClient, + cwd, + json: true + }) + ).rejects.toThrow('missing-prompt.txt') + expect(call).not.toHaveBeenCalled() + }) + + it('rejects an empty text file with an error naming the path', async () => { + const cwd = await createTempDirectory() + await writeFile(join(cwd, 'empty-prompt.txt'), '', 'utf8') + const call = vi.fn() + + await expect( + TERMINAL_HANDLERS['terminal send']({ + flags: new Map([ + ['terminal', 'term-1'], + ['text-file', 'empty-prompt.txt'] + ]), + client: { call } as unknown as RuntimeClient, + cwd, + json: true + }) + ).rejects.toMatchObject({ + code: 'invalid_argument', + message: 'Text file "empty-prompt.txt" is empty.' + }) + expect(call).not.toHaveBeenCalled() + }) + + it('rejects empty stdin as an empty text file', async () => { + const stdin = mockStdin([]) + const call = vi.fn() + + try { + await expect( + TERMINAL_HANDLERS['terminal send']({ + flags: new Map([ + ['terminal', 'term-1'], + ['text-file', '-'] + ]), + client: { call } as unknown as RuntimeClient, + cwd: '/tmp/worktree', + json: true + }) + ).rejects.toMatchObject({ + code: 'invalid_argument', + message: 'Text file "-" is empty.' + }) + } finally { + stdin.restore() + } + expect(call).not.toHaveBeenCalled() + }) + + it('rejects oversized text files before opening a read stream', async () => { + const cwd = await createTempDirectory() + const path = join(cwd, 'oversized.txt') + const handle = await open(path, 'w') + await handle.truncate(TERMINAL_INPUT_MAX_BYTES + 1) + await handle.close() + createReadStreamMock.mockClear() + const call = vi.fn() + + await expect( + TERMINAL_HANDLERS['terminal send']({ + flags: new Map([ + ['terminal', 'term-1'], + ['text-file', path] + ]), + client: { call } as unknown as RuntimeClient, + cwd, + json: true + }) + ).rejects.toMatchObject({ + code: 'invalid_argument', + message: TERMINAL_INPUT_TOO_LARGE_ERROR + }) + expect(createReadStreamMock).not.toHaveBeenCalled() + expect(call).not.toHaveBeenCalled() + }) + + it('reports oversized stdin with the same JSON error code', async () => { + const stdin = mockStdin(['x'.repeat(TERMINAL_INPUT_MAX_BYTES), 'x']) + const call = vi.fn() + + try { + await expect( + TERMINAL_HANDLERS['terminal send']({ + flags: new Map([ + ['terminal', 'term-1'], + ['text-file', '-'] + ]), + client: { call } as unknown as RuntimeClient, + cwd: '/tmp/worktree', + json: true + }) + ).rejects.toMatchObject({ + code: 'invalid_argument', + message: TERMINAL_INPUT_TOO_LARGE_ERROR + }) + } finally { + stdin.restore() + } + expect(call).not.toHaveBeenCalled() + }) + + it('preserves agent prompt semantics for text-file sends', async () => { + const cwd = await createTempDirectory() + await writeFile(join(cwd, 'prompt.txt'), 'review', 'utf8') + const call = vi.fn().mockResolvedValue({ + result: { + send: { + handle: 'term-1', + accepted: true, + bytesWritten: 7, + prompt: { + requestId: '11111111-1111-4111-8111-111111111111', + stages: ['input_accepted'], + provider: 'codex', + observation: 'supported', + processIncarnation: 'inc-1', + generation: 1, + baselineWorkingSequence: 0 + } + } + } + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await TERMINAL_HANDLERS['terminal send']({ + flags: new Map([ + ['terminal', 'term-1'], + ['text-file', 'prompt.txt'], + ['enter', true] + ]), + client: promptClient(call, supportedCliStatus()), + cwd, + json: true + }) + + expect(call).toHaveBeenCalledWith( + 'terminal.send', + expect.objectContaining({ text: 'review', enter: true, interrupt: false, agentPrompt: true }), + { terminalPromptPreflight: { runtimeId: 'runtime-current' } } + ) + }) + + it('binds a retry request to the payload resolved from the file', async () => { + const cwd = await createTempDirectory() + await writeFile(join(cwd, 'prompt.txt'), 'retry me', 'utf8') + const call = vi.fn().mockResolvedValue({ + result: { + send: { + handle: 'term-1', + accepted: true, + bytesWritten: 9, + prompt: { + requestId: '22222222-2222-4222-8222-222222222222', + stages: ['input_accepted'], + provider: 'codex', + observation: 'supported', + processIncarnation: 'inc-1', + generation: 1, + baselineWorkingSequence: 0 + } + } + } + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await TERMINAL_HANDLERS['terminal send']({ + flags: new Map([ + ['terminal', 'term-1'], + ['text-file', 'prompt.txt'], + ['enter', true], + ['retry-request', '22222222-2222-4222-8222-222222222222'] + ]), + client: promptClient(call, supportedCliStatus()), + cwd, + json: true + }) + + expect(call).toHaveBeenCalledWith( + 'terminal.send', + expect.objectContaining({ text: 'retry me', agentPrompt: true }), + expect.objectContaining({ + orchestrationRequestId: '22222222-2222-4222-8222-222222222222' + }) + ) + }) + + it('fails an unreadable text file before contacting the host', async () => { + const cwd = await createTempDirectory() + const call = vi.fn() + const getCliStatus = supportedCliStatus() + + await expect( + TERMINAL_HANDLERS['terminal send']({ + flags: new Map([ + ['terminal', 'term-1'], + ['text-file', 'missing-prompt.txt'], + ['enter', true], + ['retry-request', '33333333-3333-4333-8333-333333333333'] + ]), + client: promptClient(call, getCliStatus), + cwd, + json: true + }) + ).rejects.toThrow('missing-prompt.txt') + expect(getCliStatus).not.toHaveBeenCalled() + expect(call).not.toHaveBeenCalled() + }) + + it('refuses a text file that is not valid UTF-8 instead of sending replacement characters', async () => { + const cwd = await createTempDirectory() + await writeFile(join(cwd, 'latin1-prompt.txt'), Buffer.from([0x68, 0x69, 0xff, 0x21])) + const call = vi.fn() + + await expect( + TERMINAL_HANDLERS['terminal send']({ + flags: new Map([ + ['terminal', 'term-1'], + ['text-file', 'latin1-prompt.txt'] + ]), + client: { call } as unknown as RuntimeClient, + cwd, + json: true + }) + ).rejects.toMatchObject({ + code: 'invalid_argument', + message: expect.stringContaining('"latin1-prompt.txt" is not valid UTF-8') + }) + expect(call).not.toHaveBeenCalled() + }) + + it('refuses stdin that is not valid UTF-8, including a split multi-byte character', async () => { + const cafe = Buffer.from('café', 'utf8') + const stdin = mockStdin([cafe.subarray(0, -1)]) + const call = vi.fn() + + try { + await expect( + TERMINAL_HANDLERS['terminal send']({ + flags: new Map([ + ['terminal', 'term-1'], + ['text-file', '-'] + ]), + client: { call } as unknown as RuntimeClient, + cwd: '/tmp/worktree', + json: true + }) + ).rejects.toMatchObject({ + code: 'invalid_argument', + message: expect.stringContaining('"-" is not valid UTF-8') + }) + } finally { + stdin.restore() + } + expect(call).not.toHaveBeenCalled() + }) + + it('joins a multi-byte character split across stdin chunks', async () => { + const cafe = Buffer.from('café', 'utf8') + const stdin = mockStdin([cafe.subarray(0, -1), cafe.subarray(-1)]) + const call = vi.fn().mockResolvedValue({ + result: { send: { handle: 'term-1', accepted: true, bytesWritten: 5 } } + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + try { + await TERMINAL_HANDLERS['terminal send']({ + flags: new Map([ + ['terminal', 'term-1'], + ['text-file', '-'] + ]), + client: { call } as unknown as RuntimeClient, + cwd: '/tmp/worktree', + json: true + }) + } finally { + stdin.restore() + } + + expect(call).toHaveBeenCalledWith('terminal.send', expect.objectContaining({ text: 'café' })) + }) + + it('names --text-file in the prompt-only flag requirement', async () => { + const call = vi.fn() + + await expect( + TERMINAL_HANDLERS['terminal send']({ + flags: new Map([ + ['terminal', 'term-1'], + ['wait-submit', '5'] + ]), + client: { call } as unknown as RuntimeClient, + cwd: '/tmp/worktree', + json: true + }) + ).rejects.toThrow('--text or --text-file') + expect(call).not.toHaveBeenCalled() + }) + + it('documents file and stdin terminal input', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + + printHelp(COMMAND_SPECS, ['terminal', 'send']) + + const help = String(log.mock.calls[0]?.[0]) + expect(help).toContain('--text-file ') + }) +}) + +function mockStdin(chunks: (string | Uint8Array)[]): { restore: () => void } { + const stdin = process.stdin + const previousAsyncIterator = stdin[Symbol.asyncIterator] + ;(stdin as unknown as Record)[Symbol.asyncIterator] = async function* () { + for (const chunk of chunks) { + yield typeof chunk === 'string' ? Buffer.from(chunk) : chunk + } + } + return { + restore: () => { + if (previousAsyncIterator) { + ;(stdin as unknown as Record)[Symbol.asyncIterator] = previousAsyncIterator + } else { + Reflect.deleteProperty(stdin, Symbol.asyncIterator) + } + } + } +} diff --git a/src/cli/handlers/terminal-send.ts b/src/cli/handlers/terminal-send.ts index 2a92bfa5ea6b..446bb0df8951 100644 --- a/src/cli/handlers/terminal-send.ts +++ b/src/cli/handlers/terminal-send.ts @@ -2,16 +2,19 @@ import type { RuntimeTerminalSend } from '../../shared/runtime-types' import { TERMINAL_PROMPT_DELIVERY_RUNTIME_CAPABILITY } from '../../shared/protocol-version' import type { CommandHandler } from '../dispatch' import { formatTerminalSend, printResult, terminalSendWarnings } from '../format' -import { getOptionalPositiveIntegerFlag, getOptionalStringFlag } from '../flags' +import { getOptionalPositiveIntegerFlag } from '../flags' import { readRetryRequestFlag } from '../retry-request-flag' import { RuntimeClientError } from '../runtime-client' import { attachUnverifiedTerminalPromptRecovery } from '../runtime/terminal-prompt-mutation-recovery' import { getTerminalHandle } from '../selectors' +import { getTerminalSendText } from '../terminal-send-text-source' type TerminalSendResult = { send: RuntimeTerminalSend; warnings?: string[] } export const terminalSendHandler: CommandHandler = async ({ flags, client, cwd, json }) => { - const text = getOptionalStringFlag(flags, 'text') + // Why: file and stdin input resolve before prompt candidacy and the --retry-request preflight, + // so the retry ID stays bound to the payload actually sent. + const text = await getTerminalSendText(flags, cwd) const enter = flags.get('enter') === true const interrupt = flags.get('interrupt') === true const promptCandidate = !!text && enter && !interrupt @@ -20,7 +23,7 @@ export const terminalSendHandler: CommandHandler = async ({ flags, client, cwd, if ((retryRequest || waitSubmitSeconds) && !promptCandidate) { throw new RuntimeClientError( 'invalid_argument', - '--retry-request and --wait-submit require --text with --enter and without --interrupt.' + '--retry-request and --wait-submit require --text or --text-file with --enter and without --interrupt.' ) } if (waitSubmitSeconds && waitSubmitSeconds > 3600) { diff --git a/src/cli/specs/terminal-send.ts b/src/cli/specs/terminal-send.ts index 96f57d87305d..084bf5438d0e 100644 --- a/src/cli/specs/terminal-send.ts +++ b/src/cli/specs/terminal-send.ts @@ -5,17 +5,19 @@ export const TERMINAL_SEND_COMMAND_SPEC: CommandSpec = { path: ['terminal', 'send'], summary: 'Send input to a live terminal', usage: - 'orca terminal send [--terminal ] [--text ] [--enter] [--interrupt] [--wait-submit ] [--retry-request ] [--json]', + 'orca terminal send [--terminal ] [--text | --text-file ] [--enter] [--interrupt] [--wait-submit ] [--retry-request ] [--json]', allowedFlags: [ ...GLOBAL_FLAGS, 'terminal', 'text', + 'text-file', 'enter', 'interrupt', 'wait-submit', 'retry-request' ], notes: [ + '--text-file reads the input from a file, or from stdin when the path is -, so multi-line briefings survive shell quoting. It cannot be combined with --text, and an empty source or one that is not valid UTF-8 is refused rather than sent with replacement characters.', 'For a text-plus-Enter agent prompt, the result separates input acceptance from observed submission and turn start.', '--wait-submit only observes the accepted prompt for the requested duration; timeout returns the queued/input-accepted receipt and never resends.', 'After an ambiguous transport failure, reissue the exact command with the reported --retry-request ID. The ID is bound to the prompt payload and exact terminal process incarnation.', diff --git a/src/cli/terminal-send-text-source.ts b/src/cli/terminal-send-text-source.ts new file mode 100644 index 000000000000..c117043ec7cd --- /dev/null +++ b/src/cli/terminal-send-text-source.ts @@ -0,0 +1,79 @@ +import { createReadStream } from 'node:fs' +import { stat } from 'node:fs/promises' +import { resolve } from 'node:path' +import { + assertTerminalInputWithinLimit, + TERMINAL_INPUT_MAX_BYTES, + TERMINAL_INPUT_TOO_LARGE_ERROR +} from '../shared/terminal-input' +import { readStdinBytesWithinLimit, readStreamBytesWithinLimit } from './bounded-stdin-text' +import { getOptionalStringFlag, getRequiredStringFlag } from './flags' +import { RuntimeClientError } from './runtime-client' + +async function readTerminalSendTextBytes(path: string, cwd: string): Promise { + const createTooLargeError = (): RuntimeClientError => + new RuntimeClientError('invalid_argument', TERMINAL_INPUT_TOO_LARGE_ERROR) + if (path === '-') { + return readStdinBytesWithinLimit(TERMINAL_INPUT_MAX_BYTES, createTooLargeError) + } + const resolvedPath = resolve(cwd, path) + try { + // Why: size first, so an oversized file is refused without streaming it into memory. + if ((await stat(resolvedPath)).size > TERMINAL_INPUT_MAX_BYTES) { + throw createTooLargeError() + } + return await readStreamBytesWithinLimit( + createReadStream(resolvedPath), + TERMINAL_INPUT_MAX_BYTES, + createTooLargeError + ) + } catch (error) { + if (error instanceof RuntimeClientError) { + throw error + } + const detail = error instanceof Error ? `: ${error.message}` : '' + throw new RuntimeClientError('invalid_argument', `Unable to read text file "${path}"${detail}`) + } +} + +/** + * Decodes both the file and the stdin branch. Lossy UTF-8 would replace undecodable bytes with + * U+FFFD and hand the agent a prompt that silently differs from the source, so it is refused. + */ +function decodeTerminalSendText(bytes: Buffer, path: string): string { + try { + return new TextDecoder('utf-8', { fatal: true }).decode(bytes) + } catch { + throw new RuntimeClientError( + 'invalid_argument', + `Text file "${path}" is not valid UTF-8, so no input was sent. Re-encode it as UTF-8 and retry.` + ) + } +} + +/** + * Resolves `terminal send` input from `--text` or `--text-file`. The text is fully materialized + * here, before the caller derives prompt candidacy or runs the `--retry-request` preflight, so a + * retry ID stays bound to the exact payload that was sent. + */ +export async function getTerminalSendText( + flags: Map, + cwd: string +): Promise { + if (flags.has('text') && flags.has('text-file')) { + throw new RuntimeClientError( + 'invalid_argument', + '--text and --text-file cannot be used together' + ) + } + if (!flags.has('text-file')) { + return getOptionalStringFlag(flags, 'text') + } + const path = getRequiredStringFlag(flags, 'text-file') + const text = decodeTerminalSendText(await readTerminalSendTextBytes(path, cwd), path) + if (text.length === 0) { + throw new RuntimeClientError('invalid_argument', `Text file "${path}" is empty.`) + } + assertTerminalInputWithinLimit(text) + return text +}