Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <command>` 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`.
Expand Down
2 changes: 1 addition & 1 deletion src/host/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
12 changes: 9 additions & 3 deletions src/host/rpcClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 4 additions & 2 deletions src/protocol/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions test/unit/host/rpcClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((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<void>((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',
() => {
Expand Down
Loading