From 7e36ceea0e8fbb2ff05501884d8b88d77a1408f7 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 4 Nov 2026 04:18:09 +0000 Subject: [PATCH 1/6] test: report PROTOCOL_ERROR when an older host sends a response over 64 MiB --- test/unit/host/rpcClient.test.ts | 59 ++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) 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', () => { From 0cc93a99d6ba095870d418d756b9d86ffc832de7 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 4 Nov 2026 04:18:25 +0000 Subject: [PATCH 2/6] fix: report PROTOCOL_ERROR when an older host sends a response over 64 MiB --- src/host/rpcClient.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) 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; From b457e10339a2d7afe6856b884020280c22a6c5fc Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 4 Nov 2026 04:19:50 +0000 Subject: [PATCH 3/6] docs: state the RPC response cap failure in USAGE and the CLI memory it allows --- docs/USAGE.md | 2 ++ src/protocol/messages.ts | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/USAGE.md b/docs/USAGE.md index bee41ee5..75d80886 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 (64 MiB) per command. 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/protocol/messages.ts b/src/protocol/messages.ts index fed77655..232aa7d6 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 232 MB (an 80x24 snapshot) to 0.84 to 0.99 GB: about 14 bytes +// per response character. So a response at the cap takes about 1.2 GB. export const MAX_RPC_RESPONSE_CHARS = 67_108_864; export const RpcRequestSchema = z From 80ec2fa8763129c8048ce25e8c16e630503b9d65 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 4 Nov 2026 04:20:03 +0000 Subject: [PATCH 4/6] docs: give the CLI memory numbers in MiB --- src/protocol/messages.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/protocol/messages.ts b/src/protocol/messages.ts index 232aa7d6..38accc9e 100644 --- a/src/protocol/messages.ts +++ b/src/protocol/messages.ts @@ -77,8 +77,8 @@ export const MAX_RPC_REQUEST_ID_CHARS = 128; // cells, so a 500x200 snapshot takes about 5.6 million. This cap fits about // 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 232 MB (an 80x24 snapshot) to 0.84 to 0.99 GB: about 14 bytes -// per response character. So a response at the cap takes about 1.2 GB. +// 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 From 031b2936fd17d0c9d3bb8a071820c8c9c9ec506f Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 4 Nov 2026 04:20:13 +0000 Subject: [PATCH 5/6] docs: describe the client's PROTOCOL_ERROR for an oversized response --- src/host/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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] From 344c5c16f4a0d5991ef6e48560e1c85cf627ca4d Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 4 Nov 2026 04:51:21 +0000 Subject: [PATCH 6/6] docs: count the RPC response cap in UTF-16 code units, not MiB --- docs/USAGE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/USAGE.md b/docs/USAGE.md index 75d80886..d36ea83e 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -452,7 +452,7 @@ 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 (64 MiB) per command. 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`. +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`.