diff --git a/docs/USAGE.md b/docs/USAGE.md index bee41ee5..d36ea83e 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -452,6 +452,8 @@ Every command exits with a stable code, so scripts can branch without parsing ou A fail-fast `batch` exits with the failed step's code (for example `11` for a wait timeout); `--keep-going` exits `1` if any step failed. +A live session host answers at most 67,108,864 characters per command, counted as JavaScript string length (UTF-16 code units, not bytes: non-ASCII text takes more bytes on the wire). A larger response, such as `snapshot --include-cells` of a screen with more than about 1.2 million fully styled cells, fails with `PROTOCOL_ERROR` (exit `9`, `retryable: false`) and `details.maxResponseChars`. + `agent-tty help` and `agent-tty help ` exit `0`. Without a command, the CLI prints the help on stderr and exits `2`. An unreadable Home `config.json` gives `STORAGE_READ_ERROR` (exit `8`). Invalid JSON or an invalid field in it gives `INVALID_INPUT` (exit `2`). Both errors carry `details.configPath`. diff --git a/src/host/README.md b/src/host/README.md index ad4895bc..695fec5f 100644 --- a/src/host/README.md +++ b/src/host/README.md @@ -62,7 +62,7 @@ - `allocateSession` throws `INVALID_DIMENSIONS` for non-positive `cols` or `rows`, `STORAGE_READ_ERROR` when `cwd` is not a directory and `INVALID_INPUT` when the shell path is not a file. An empty `command` defaults to the shell path. It creates the session directory and calls `launch` only after these checks pass. [lifecycle.ts:allocateSession] - `buildReplayInput` validates the manifest with `SessionRecordSchema` and requires its `sessionId` to match. It validates the events with `validateEventRecords` unless `trustValidated` is set. `targetSeq` is -1 for no events, else from 0 to the last `seq`. [replay.ts:buildReplayInput] - `RpcServer.listen` removes a stale socket file (no answer within 1,000 ms, `ECONNREFUSED` or `ENOENT`) and throws when another server is listening. It asserts a socket path of at most 104 characters. [rpcServer.ts:RpcServer] -- `RpcServer` caps a request at 1,048,576 characters. `sendRpc` caps a response at `MAX_RPC_RESPONSE_CHARS` (67,108,864 characters, from `src/protocol/messages.ts`), so a structured snapshot with cells of a 500x200 screen (about 5.6 million characters when every cell is styled) fits. The server answers a success response above that cap with `PROTOCOL_ERROR` "RPC response takes N characters, over the 67108864-character limit.", with `responseChars` and `maxResponseChars` in `details`. The server validates the request with `RpcRequestSchema`, the params and result with `RpcMethodSchemas`, and the response with `RpcResponseSchema` (all from `src/protocol/messages.ts`). A handler that throws a `CliError` gets an error response with its code, message and, when it has any, its `details`. It leaves `details` out when they do not serialize to a JSON object (a `BigInt`, a circular object, a `toJSON` that returns something else) or take more than 65,536 characters. It cuts an error message to 65,536 characters, without splitting a surrogate pair, and ends it with `[message truncated]`. A request id over 128 characters fails `RpcRequestSchema`, and the error response uses the id `unknown`. So an error response stays under 1,048,576 characters, which a CLI from before the larger response cap also reads, and keeps the error's code. `sendRpc` rebuilds that `CliError` with the same details. Any other thrown error becomes `RPC_ERROR` without details. If building or sending the response throws, the server logs the error and answers `RPC_ERROR` "RPC request handling failed unexpectedly.", so the host keeps running. Unknown methods get an error response. [rpcServer.ts:RpcServer] [rpcClient.ts:sendRpc] +- `RpcServer` caps a request at 1,048,576 characters. `sendRpc` caps a response at `MAX_RPC_RESPONSE_CHARS` (67,108,864 characters, from `src/protocol/messages.ts`), so a structured snapshot with cells of a 500x200 screen (about 5.6 million characters when every cell is styled) fits. The server answers a success response above that cap with `PROTOCOL_ERROR` "RPC response takes N characters, over the 67108864-character limit.", with `responseChars` and `maxResponseChars` in `details`. `sendRpc` stops reading a response from an older host, which has no such check, at the cap and reports `PROTOCOL_ERROR` "RPC response is over the 67108864-character limit." with `maxResponseChars` in `details`. The server validates the request with `RpcRequestSchema`, the params and result with `RpcMethodSchemas`, and the response with `RpcResponseSchema` (all from `src/protocol/messages.ts`). A handler that throws a `CliError` gets an error response with its code, message and, when it has any, its `details`. It leaves `details` out when they do not serialize to a JSON object (a `BigInt`, a circular object, a `toJSON` that returns something else) or take more than 65,536 characters. It cuts an error message to 65,536 characters, without splitting a surrogate pair, and ends it with `[message truncated]`. A request id over 128 characters fails `RpcRequestSchema`, and the error response uses the id `unknown`. So an error response stays under 1,048,576 characters, which a CLI from before the larger response cap also reads, and keeps the error's code. `sendRpc` rebuilds that `CliError` with the same details. Any other thrown error becomes `RPC_ERROR` without details. If building or sending the response throws, the server logs the error and answers `RPC_ERROR` "RPC request handling failed unexpectedly.", so the host keeps running. Unknown methods get an error response. [rpcServer.ts:RpcServer] [rpcClient.ts:sendRpc] - `sendRpc` waits 5,000 ms by default, as a socket inactivity timeout. The `snapshot` command passes 60,000 ms when it asks for cells, because the host sends nothing until it has built every cell. `sendRpc` scans each response chunk once for the newline. It requires the response `id` to match the request. It maps `ECONNREFUSED`, `ENOENT` and `ECONNRESET` to `HOST_UNREACHABLE`, a socket timeout to `HOST_TIMEOUT` and other transport errors to `RPC_ERROR`. [rpcClient.ts:sendRpc] - `HostRendererManager` runs backend operations one at a time. `withBackend` requires the replay input `sessionId` to match, and recreates the backend when the initial size changes. [renderer.ts:HostRendererManager] - A waited `run` calls `EventLog.startAppend`, `registerWaitedRun` and `pty.write` with no `await` in between, then awaits `written`. When `registerWaitedRun` or `pty.write` throws, it waits for `written` to settle and rethrows that error, so a failed write never becomes an unhandled rejection. So no other input reaches the PTY or the log between the command's `seq` and its write, and the marker is registered before the command can print. [hostMain.ts:runHost] diff --git a/src/host/rpcClient.ts b/src/host/rpcClient.ts index 4322cb90..29d16a00 100644 --- a/src/host/rpcClient.ts +++ b/src/host/rpcClient.ts @@ -234,10 +234,16 @@ async function sendRpcAt( } if (buffer.length + chunk.length > MAX_RPC_RESPONSE_CHARS) { + // Only an older host, without the server's response cap, gets here. + // A retry gets the same size, so this is not RPC_ERROR (retryable). settlers.reject( - makeCliError(ERROR_CODES.RPC_ERROR, { - message: 'RPC response exceeds maximum buffer size.', - details: { method, socketPath }, + makeCliError(ERROR_CODES.PROTOCOL_ERROR, { + message: `RPC response is over the ${String(MAX_RPC_RESPONSE_CHARS)}-character limit.`, + details: { + method, + socketPath, + maxResponseChars: MAX_RPC_RESPONSE_CHARS, + }, }), ); return; diff --git a/src/protocol/messages.ts b/src/protocol/messages.ts index fed77655..38accc9e 100644 --- a/src/protocol/messages.ts +++ b/src/protocol/messages.ts @@ -75,8 +75,10 @@ export const MAX_RPC_REQUEST_ID_CHARS = 128; // A fully styled cell takes about 56 characters in a structured snapshot with // cells, so a 500x200 snapshot takes about 5.6 million. This cap fits about -// 1.2 million styled cells (a 1,000x1,000 screen) and still bounds the memory -// that one response can take. +// 1.2 million styled cells and still bounds the memory that one response can +// take. A 1,000x1,000 styled snapshot (56,051,020 characters) raised the CLI's +// peak RSS from 227 MiB (an 80x24 snapshot) to 819 to 963 MiB: about 14 bytes +// per response character. So a response at the cap takes about 1.1 GiB. export const MAX_RPC_RESPONSE_CHARS = 67_108_864; export const RpcRequestSchema = z diff --git a/test/unit/host/rpcClient.test.ts b/test/unit/host/rpcClient.test.ts index fd15b2f8..1a7d79a7 100644 --- a/test/unit/host/rpcClient.test.ts +++ b/test/unit/host/rpcClient.test.ts @@ -127,6 +127,65 @@ describe('sendRpc abort handling', () => { }); }); +describe('sendRpc response cap', () => { + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'agent-tty-rpc-client-')); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + tempDir = ''; + }); + + it( + 'refuses a response over 64 MiB from an older host with PROTOCOL_ERROR', + { timeout: 30_000 }, + async () => { + const socketFile = join(tempDir, 'rpc.sock'); + // An older host sends a success response of any size. + const server = net.createServer((socket) => { + socket.on('error', () => undefined); + socket.once('data', () => { + const piece = 'x'.repeat(1_048_576); + for (let index = 0; index <= 64; index += 1) { + socket.write(piece); + } + }); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(socketFile, () => { + server.off('error', reject); + resolve(); + }); + }); + + try { + const error = await sendRpc(socketFile, 'inspect', {}, 20_000).catch( + (rejection: unknown) => rejection, + ); + + expect(error).toMatchObject({ + code: 'PROTOCOL_ERROR', + retryable: false, + message: 'RPC response is over the 67108864-character limit.', + details: { method: 'inspect', maxResponseChars: 67_108_864 }, + }); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error !== undefined) { + reject(error); + return; + } + resolve(); + }); + }); + } + }, + ); +}); + describe.skipIf(process.platform === 'win32')( 'sendRpc to a host that an older version started', () => {