mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-18 13:04:05 +00:00
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:
parent
6c91323ca6
commit
636eefb553
11 changed files with 700 additions and 512 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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?: {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue