feat: refine process API — WebSocket binary protocol, SDK terminal session, updated tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nathan Flurry 2026-03-06 12:12:24 -08:00
parent 6c91323ca6
commit 636eefb553
11 changed files with 700 additions and 512 deletions

View file

@ -51,13 +51,17 @@ import {
type ProcessRunRequest,
type ProcessRunResponse,
type ProcessSignalQuery,
type ProcessTerminalResizeRequest,
type ProcessTerminalResizeResponse,
type SessionEvent,
type SessionPersistDriver,
type SessionRecord,
type SkillsConfig,
type SkillsConfigQuery,
TerminalChannel,
type TerminalErrorStatus,
type TerminalExitStatus,
type TerminalReadyStatus,
type TerminalResizePayload,
type TerminalStatusMessage,
} from "./types.ts";
const API_PREFIX = "/v1";
@ -134,6 +138,8 @@ export interface ProcessTerminalConnectOptions extends ProcessTerminalWebSocketU
WebSocket?: typeof WebSocket;
}
export type ProcessTerminalSessionOptions = ProcessTerminalConnectOptions;
export class SandboxAgentError extends Error {
readonly status: number;
readonly problem?: ProblemDetails;
@ -472,6 +478,188 @@ export class LiveAcpConnection {
}
}
export class ProcessTerminalSession {
readonly socket: WebSocket;
readonly closed: Promise<void>;
private readonly readyListeners = new Set<(status: TerminalReadyStatus) => void>();
private readonly dataListeners = new Set<(data: Uint8Array) => void>();
private readonly exitListeners = new Set<(status: TerminalExitStatus) => void>();
private readonly errorListeners = new Set<(error: TerminalErrorStatus | Error) => void>();
private readonly closeListeners = new Set<() => void>();
private readonly textEncoder = new TextEncoder();
private closeSignalSent = false;
private closedResolve!: () => void;
constructor(socket: WebSocket) {
this.socket = socket;
this.socket.binaryType = "arraybuffer";
this.closed = new Promise<void>((resolve) => {
this.closedResolve = resolve;
});
this.socket.addEventListener("message", (event) => {
void this.handleMessage(event.data);
});
this.socket.addEventListener("error", () => {
this.emitError(new Error("Terminal websocket connection failed."));
});
this.socket.addEventListener("close", () => {
this.closedResolve();
for (const listener of this.closeListeners) {
listener();
}
});
}
onReady(listener: (status: TerminalReadyStatus) => void): () => void {
this.readyListeners.add(listener);
return () => {
this.readyListeners.delete(listener);
};
}
onData(listener: (data: Uint8Array) => void): () => void {
this.dataListeners.add(listener);
return () => {
this.dataListeners.delete(listener);
};
}
onExit(listener: (status: TerminalExitStatus) => void): () => void {
this.exitListeners.add(listener);
return () => {
this.exitListeners.delete(listener);
};
}
onError(listener: (error: TerminalErrorStatus | Error) => void): () => void {
this.errorListeners.add(listener);
return () => {
this.errorListeners.delete(listener);
};
}
onClose(listener: () => void): () => void {
this.closeListeners.add(listener);
return () => {
this.closeListeners.delete(listener);
};
}
sendInput(data: string | ArrayBuffer | ArrayBufferView): void {
this.sendChannel(TerminalChannel.stdin, encodeTerminalBytes(data));
}
resize(payload: TerminalResizePayload): void {
this.sendChannel(
TerminalChannel.resize,
this.textEncoder.encode(JSON.stringify(payload)),
);
}
close(): void {
if (this.socket.readyState === WebSocket.CONNECTING) {
this.socket.addEventListener(
"open",
() => {
this.close();
},
{ once: true },
);
return;
}
if (this.socket.readyState === WebSocket.OPEN) {
if (!this.closeSignalSent) {
this.closeSignalSent = true;
this.sendChannel(TerminalChannel.close, new Uint8Array());
}
this.socket.close();
return;
}
if (this.socket.readyState !== WebSocket.CLOSED) {
this.socket.close();
}
}
private async handleMessage(data: unknown): Promise<void> {
try {
const bytes = await decodeTerminalBytes(data);
if (bytes.length === 0) {
this.emitError(new Error("Received terminal frame without a channel byte."));
return;
}
const channel = bytes[0];
const payload = bytes.subarray(1);
if (channel === TerminalChannel.stdout || channel === TerminalChannel.stderr) {
for (const listener of this.dataListeners) {
listener(payload);
}
return;
}
if (channel === TerminalChannel.status) {
const text = new TextDecoder().decode(payload);
const parsed = JSON.parse(text) as unknown;
if (!isTerminalStatusMessage(parsed)) {
this.emitError(new Error("Received invalid terminal status payload."));
return;
}
if (parsed.type === "ready") {
for (const listener of this.readyListeners) {
listener(parsed);
}
return;
}
if (parsed.type === "exit") {
for (const listener of this.exitListeners) {
listener(parsed);
}
return;
}
this.emitError(parsed);
return;
}
if (channel === TerminalChannel.close) {
if (this.socket.readyState === WebSocket.OPEN) {
this.socket.close();
}
return;
}
this.emitError(new Error(`Received unsupported terminal channel ${channel}.`));
} catch (error) {
this.emitError(error instanceof Error ? error : new Error(String(error)));
}
}
private sendChannel(channel: number, payload: Uint8Array): void {
if (this.socket.readyState !== WebSocket.OPEN) {
return;
}
const frame = new Uint8Array(payload.length + 1);
frame[0] = channel;
frame.set(payload, 1);
this.socket.send(frame);
}
private emitError(error: TerminalErrorStatus | Error): void {
for (const listener of this.errorListeners) {
listener(error);
}
}
}
export class SandboxAgent {
private readonly baseUrl: string;
private readonly token?: string;
@ -893,19 +1081,6 @@ export class SandboxAgent {
});
}
async resizeProcessTerminal(
id: string,
request: ProcessTerminalResizeRequest,
): Promise<ProcessTerminalResizeResponse> {
return this.requestJson(
"POST",
`${API_PREFIX}/processes/${encodeURIComponent(id)}/terminal/resize`,
{
body: request,
},
);
}
buildProcessTerminalWebSocketUrl(
id: string,
options: ProcessTerminalWebSocketUrlOptions = {},
@ -930,10 +1105,17 @@ export class SandboxAgent {
this.buildProcessTerminalWebSocketUrl(id, {
accessToken: options.accessToken,
}),
options.protocols,
options.protocols ?? "channel.k8s.io",
);
}
connectProcessTerminal(
id: string,
options: ProcessTerminalSessionOptions = {},
): ProcessTerminalSession {
return new ProcessTerminalSession(this.connectProcessTerminalWebSocket(id, options));
}
private async getLiveConnection(agent: string): Promise<LiveAcpConnection> {
const existing = this.liveConnections.get(agent);
if (existing) {
@ -1204,6 +1386,62 @@ type RequestOptions = {
signal?: AbortSignal;
};
function isTerminalStatusMessage(value: unknown): value is TerminalStatusMessage {
if (!isRecord(value) || typeof value.type !== "string") {
return false;
}
if (value.type === "ready") {
return typeof value.processId === "string";
}
if (value.type === "exit") {
return (
value.exitCode === undefined ||
value.exitCode === null ||
typeof value.exitCode === "number"
);
}
if (value.type === "error") {
return typeof value.message === "string";
}
return false;
}
function encodeTerminalBytes(data: string | ArrayBuffer | ArrayBufferView): Uint8Array {
if (typeof data === "string") {
return new TextEncoder().encode(data);
}
if (data instanceof ArrayBuffer) {
return new Uint8Array(data);
}
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength).slice();
}
async function decodeTerminalBytes(data: unknown): Promise<Uint8Array> {
if (data instanceof ArrayBuffer) {
return new Uint8Array(data);
}
if (ArrayBuffer.isView(data)) {
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength).slice();
}
if (typeof Blob !== "undefined" && data instanceof Blob) {
return new Uint8Array(await data.arrayBuffer());
}
if (typeof data === "string") {
throw new Error("Received text terminal frame; expected channel.k8s.io binary data.");
}
throw new Error(`Unsupported terminal frame payload: ${String(data)}`);
}
/**
* Auto-select and call `authenticate` based on the agent's advertised auth methods.
* Prefers env-var-based methods that the server process already has configured.

View file

@ -58,36 +58,98 @@ export interface paths {
get: operations["get_v1_health"];
};
"/v1/processes": {
/**
* List all managed processes.
* @description Returns a list of all processes (running and exited) currently tracked
* by the runtime, sorted by process ID.
*/
get: operations["get_v1_processes"];
/**
* Create a long-lived managed process.
* @description Spawns a new process with the given command and arguments. Supports both
* pipe-based and PTY (tty) modes. Returns the process descriptor on success.
*/
post: operations["post_v1_processes"];
};
"/v1/processes/config": {
/**
* Get process runtime configuration.
* @description Returns the current runtime configuration for the process management API,
* including limits for concurrency, timeouts, and buffer sizes.
*/
get: operations["get_v1_processes_config"];
/**
* Update process runtime configuration.
* @description Replaces the runtime configuration for the process management API.
* Validates that all values are non-zero and clamps default timeout to max.
*/
post: operations["post_v1_processes_config"];
};
"/v1/processes/run": {
/**
* Run a one-shot command.
* @description Executes a command to completion and returns its stdout, stderr, exit code,
* and duration. Supports configurable timeout and output size limits.
*/
post: operations["post_v1_processes_run"];
};
"/v1/processes/{id}": {
/**
* Get a single process by ID.
* @description Returns the current state of a managed process including its status,
* PID, exit code, and creation/exit timestamps.
*/
get: operations["get_v1_process"];
/**
* Delete a process record.
* @description Removes a stopped process from the runtime. Returns 409 if the process
* is still running; stop or kill it first.
*/
delete: operations["delete_v1_process"];
};
"/v1/processes/{id}/input": {
/**
* Write input to a process.
* @description Sends data to a process's stdin (pipe mode) or PTY writer (tty mode).
* Data can be encoded as base64, utf8, or text. Returns 413 if the decoded
* payload exceeds the configured `maxInputBytesPerRequest` limit.
*/
post: operations["post_v1_process_input"];
};
"/v1/processes/{id}/kill": {
/**
* Send SIGKILL to a process.
* @description Sends SIGKILL to the process and optionally waits up to `waitMs`
* milliseconds for the process to exit before returning.
*/
post: operations["post_v1_process_kill"];
};
"/v1/processes/{id}/logs": {
/**
* Fetch process logs.
* @description Returns buffered log entries for a process. Supports filtering by stream
* type, tail count, and sequence-based resumption. When `follow=true`,
* returns an SSE stream that replays buffered entries then streams live output.
*/
get: operations["get_v1_process_logs"];
};
"/v1/processes/{id}/stop": {
/**
* Send SIGTERM to a process.
* @description Sends SIGTERM to the process and optionally waits up to `waitMs`
* milliseconds for the process to exit before returning.
*/
post: operations["post_v1_process_stop"];
};
"/v1/processes/{id}/terminal/resize": {
post: operations["post_v1_process_terminal_resize"];
};
"/v1/processes/{id}/terminal/ws": {
/**
* Open an interactive WebSocket terminal session.
* @description Upgrades the connection to a WebSocket for bidirectional PTY I/O. Accepts
* `access_token` query param for browser-based auth (WebSocket API cannot
* send custom headers). Uses the `channel.k8s.io` binary subprotocol:
* channel 0 stdin, channel 1 stdout, channel 3 status JSON, channel 4 resize,
* and channel 255 close.
*/
get: operations["get_v1_process_terminal_ws"];
};
}
@ -166,7 +228,7 @@ export interface components {
agents: components["schemas"]["AgentInfo"][];
};
/** @enum {string} */
ErrorType: "invalid_request" | "conflict" | "unsupported_agent" | "agent_not_installed" | "install_failed" | "agent_process_exited" | "token_invalid" | "permission_denied" | "not_acceptable" | "unsupported_media_type" | "session_not_found" | "session_already_exists" | "mode_not_supported" | "stream_error" | "timeout";
ErrorType: "invalid_request" | "conflict" | "unsupported_agent" | "agent_not_installed" | "install_failed" | "agent_process_exited" | "token_invalid" | "permission_denied" | "not_acceptable" | "unsupported_media_type" | "not_found" | "session_not_found" | "session_already_exists" | "mode_not_supported" | "stream_error" | "timeout";
FsActionResponse: {
path: string;
};
@ -361,18 +423,6 @@ export interface components {
};
/** @enum {string} */
ProcessState: "running" | "exited";
ProcessTerminalResizeRequest: {
/** Format: int32 */
cols: number;
/** Format: int32 */
rows: number;
};
ProcessTerminalResizeResponse: {
/** Format: int32 */
cols: number;
/** Format: int32 */
rows: number;
};
/** @enum {string} */
ServerStatus: "running" | "stopped";
ServerStatusInfo: {
@ -891,6 +941,11 @@ export interface operations {
};
};
};
/**
* List all managed processes.
* @description Returns a list of all processes (running and exited) currently tracked
* by the runtime, sorted by process ID.
*/
get_v1_processes: {
responses: {
/** @description List processes */
@ -907,6 +962,11 @@ export interface operations {
};
};
};
/**
* Create a long-lived managed process.
* @description Spawns a new process with the given command and arguments. Supports both
* pipe-based and PTY (tty) modes. Returns the process descriptor on success.
*/
post_v1_processes: {
requestBody: {
content: {
@ -940,6 +1000,11 @@ export interface operations {
};
};
};
/**
* Get process runtime configuration.
* @description Returns the current runtime configuration for the process management API,
* including limits for concurrency, timeouts, and buffer sizes.
*/
get_v1_processes_config: {
responses: {
/** @description Current runtime process config */
@ -956,6 +1021,11 @@ export interface operations {
};
};
};
/**
* Update process runtime configuration.
* @description Replaces the runtime configuration for the process management API.
* Validates that all values are non-zero and clamps default timeout to max.
*/
post_v1_processes_config: {
requestBody: {
content: {
@ -983,6 +1053,11 @@ export interface operations {
};
};
};
/**
* Run a one-shot command.
* @description Executes a command to completion and returns its stdout, stderr, exit code,
* and duration. Supports configurable timeout and output size limits.
*/
post_v1_processes_run: {
requestBody: {
content: {
@ -1010,6 +1085,11 @@ export interface operations {
};
};
};
/**
* Get a single process by ID.
* @description Returns the current state of a managed process including its status,
* PID, exit code, and creation/exit timestamps.
*/
get_v1_process: {
parameters: {
path: {
@ -1038,6 +1118,11 @@ export interface operations {
};
};
};
/**
* Delete a process record.
* @description Removes a stopped process from the runtime. Returns 409 if the process
* is still running; stop or kill it first.
*/
delete_v1_process: {
parameters: {
path: {
@ -1070,6 +1155,12 @@ export interface operations {
};
};
};
/**
* Write input to a process.
* @description Sends data to a process's stdin (pipe mode) or PTY writer (tty mode).
* Data can be encoded as base64, utf8, or text. Returns 413 if the decoded
* payload exceeds the configured `maxInputBytesPerRequest` limit.
*/
post_v1_process_input: {
parameters: {
path: {
@ -1115,6 +1206,11 @@ export interface operations {
};
};
};
/**
* Send SIGKILL to a process.
* @description Sends SIGKILL to the process and optionally waits up to `waitMs`
* milliseconds for the process to exit before returning.
*/
post_v1_process_kill: {
parameters: {
query?: {
@ -1147,6 +1243,12 @@ export interface operations {
};
};
};
/**
* Fetch process logs.
* @description Returns buffered log entries for a process. Supports filtering by stream
* type, tail count, and sequence-based resumption. When `follow=true`,
* returns an SSE stream that replays buffered entries then streams live output.
*/
get_v1_process_logs: {
parameters: {
query?: {
@ -1185,6 +1287,11 @@ export interface operations {
};
};
};
/**
* Send SIGTERM to a process.
* @description Sends SIGTERM to the process and optionally waits up to `waitMs`
* milliseconds for the process to exit before returning.
*/
post_v1_process_stop: {
parameters: {
query?: {
@ -1217,51 +1324,14 @@ export interface operations {
};
};
};
post_v1_process_terminal_resize: {
parameters: {
path: {
/** @description Process ID */
id: string;
};
};
requestBody: {
content: {
"application/json": components["schemas"]["ProcessTerminalResizeRequest"];
};
};
responses: {
/** @description Resize accepted */
200: {
content: {
"application/json": components["schemas"]["ProcessTerminalResizeResponse"];
};
};
/** @description Invalid request */
400: {
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Unknown process */
404: {
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Not a terminal process */
409: {
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Process API unsupported on this platform */
501: {
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
};
};
/**
* Open an interactive WebSocket terminal session.
* @description Upgrades the connection to a WebSocket for bidirectional PTY I/O. Accepts
* `access_token` query param for browser-based auth (WebSocket API cannot
* send custom headers). Uses the `channel.k8s.io` binary subprotocol:
* channel 0 stdin, channel 1 stdout, channel 3 status JSON, channel 4 resize,
* and channel 255 close.
*/
get_v1_process_terminal_ws: {
parameters: {
query?: {

View file

@ -1,5 +1,6 @@
export {
LiveAcpConnection,
ProcessTerminalSession,
SandboxAgent,
SandboxAgentError,
Session,
@ -15,6 +16,7 @@ export type {
ProcessLogListener,
ProcessLogSubscription,
ProcessTerminalConnectOptions,
ProcessTerminalSessionOptions,
ProcessTerminalWebSocketUrlOptions,
SandboxAgentConnectOptions,
SandboxAgentStartOptions,
@ -28,6 +30,7 @@ export type { InspectorUrlOptions } from "./inspector.ts";
export {
InMemorySessionPersistDriver,
TerminalChannel,
} from "./types.ts";
export type {
@ -72,18 +75,16 @@ export type {
ProcessRunResponse,
ProcessSignalQuery,
ProcessState,
ProcessTerminalClientFrame,
ProcessTerminalErrorFrame,
ProcessTerminalExitFrame,
ProcessTerminalReadyFrame,
ProcessTerminalResizeRequest,
ProcessTerminalResizeResponse,
ProcessTerminalServerFrame,
SessionEvent,
SessionPersistDriver,
SessionRecord,
SkillsConfig,
SkillsConfigQuery,
TerminalErrorStatus,
TerminalExitStatus,
TerminalReadyStatus,
TerminalResizePayload,
TerminalStatusMessage,
} from "./types.ts";
export type {

View file

@ -46,43 +46,40 @@ export type ProcessRunRequest = JsonRequestBody<operations["post_v1_processes_ru
export type ProcessRunResponse = JsonResponse<operations["post_v1_processes_run"], 200>;
export type ProcessSignalQuery = QueryParams<operations["post_v1_process_stop"]>;
export type ProcessState = components["schemas"]["ProcessState"];
export type ProcessTerminalResizeRequest = JsonRequestBody<operations["post_v1_process_terminal_resize"]>;
export type ProcessTerminalResizeResponse = JsonResponse<operations["post_v1_process_terminal_resize"], 200>;
export type ProcessTerminalClientFrame =
| {
type: "input";
data: string;
encoding?: string;
}
| {
type: "resize";
cols: number;
rows: number;
}
| {
type: "close";
};
export const TerminalChannel = {
stdin: 0,
stdout: 1,
stderr: 2,
status: 3,
resize: 4,
close: 255,
} as const;
export interface ProcessTerminalReadyFrame {
export interface TerminalReadyStatus {
type: "ready";
processId: string;
}
export interface ProcessTerminalExitFrame {
export interface TerminalExitStatus {
type: "exit";
exitCode?: number | null;
}
export interface ProcessTerminalErrorFrame {
export interface TerminalErrorStatus {
type: "error";
message: string;
}
export type ProcessTerminalServerFrame =
| ProcessTerminalReadyFrame
| ProcessTerminalExitFrame
| ProcessTerminalErrorFrame;
export type TerminalStatusMessage =
| TerminalReadyStatus
| TerminalExitStatus
| TerminalErrorStatus;
export interface TerminalResizePayload {
cols: number;
rows: number;
}
export interface SessionRecord {
id: string;

View file

@ -136,22 +136,6 @@ function writeTarChecksum(buffer: Buffer, checksum: number): void {
buffer[155] = 0x20;
}
function decodeSocketPayload(data: unknown): string {
if (typeof data === "string") {
return data;
}
if (data instanceof ArrayBuffer) {
return Buffer.from(data).toString("utf8");
}
if (ArrayBuffer.isView(data)) {
return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf8");
}
if (typeof Blob !== "undefined" && data instanceof Blob) {
throw new Error("Blob socket payloads are not supported in this test");
}
throw new Error(`Unsupported socket payload type: ${typeof data}`);
}
function decodeProcessLogData(data: string, encoding: string): string {
if (encoding === "base64") {
return Buffer.from(data, "base64").toString("utf8");
@ -582,47 +566,53 @@ describe("Integration: TypeScript SDK flat session API", () => {
});
ttyProcessId = ttyProcess.id;
const resized = await sdk.resizeProcessTerminal(ttyProcess.id, {
cols: 120,
rows: 40,
});
expect(resized.cols).toBe(120);
expect(resized.rows).toBe(40);
const wsUrl = sdk.buildProcessTerminalWebSocketUrl(ttyProcess.id);
expect(wsUrl.startsWith("ws://") || wsUrl.startsWith("wss://")).toBe(true);
const ws = sdk.connectProcessTerminalWebSocket(ttyProcess.id, {
const session = sdk.connectProcessTerminal(ttyProcess.id, {
WebSocket: WebSocket as unknown as typeof globalThis.WebSocket,
});
ws.binaryType = "arraybuffer";
const readyFrames: string[] = [];
const ttyOutput: string[] = [];
const exitFrames: Array<number | null | undefined> = [];
const terminalErrors: string[] = [];
let closeCount = 0;
const socketTextFrames: string[] = [];
const socketBinaryFrames: string[] = [];
ws.addEventListener("message", (event) => {
if (typeof event.data === "string") {
socketTextFrames.push(event.data);
return;
}
socketBinaryFrames.push(decodeSocketPayload(event.data));
session.onReady((status) => {
readyFrames.push(status.processId);
});
session.onData((bytes) => {
ttyOutput.push(Buffer.from(bytes).toString("utf8"));
});
session.onExit((status) => {
exitFrames.push(status.exitCode);
});
session.onError((error) => {
terminalErrors.push(error instanceof Error ? error.message : error.message);
});
session.onClose(() => {
closeCount += 1;
});
await waitFor(() => {
const ready = socketTextFrames.find((frame) => frame.includes('"type":"ready"'));
return ready;
await waitFor(() => readyFrames[0]);
session.resize({
cols: 120,
rows: 40,
});
ws.send(JSON.stringify({
type: "input",
data: "hello tty\n",
}));
session.sendInput("hello tty\n");
await waitFor(() => {
const joined = socketBinaryFrames.join("");
const joined = ttyOutput.join("");
return joined.includes("hello tty") ? joined : undefined;
});
ws.close();
session.close();
await session.closed;
expect(closeCount).toBeGreaterThan(0);
expect(exitFrames).toHaveLength(0);
expect(terminalErrors).toEqual([]);
await waitForAsync(async () => {
const processInfo = await sdk.getProcess(ttyProcess.id);
return processInfo.status === "running" ? processInfo : undefined;