chore: recover wellington workspace state

This commit is contained in:
Nathan Flurry 2026-03-09 19:59:55 -07:00
parent 5d65013aa5
commit c294ca85be
366 changed files with 1265 additions and 53395 deletions

View file

@ -1,6 +1,6 @@
{
"name": "sandbox-agent",
"version": "0.3.0",
"version": "0.2.1",
"description": "Universal API for automatic coding agents in sandboxes. Supports Claude Code, Codex, OpenCode, and Amp.",
"license": "Apache-2.0",
"repository": {
@ -17,8 +17,8 @@
}
},
"dependencies": {
"@sandbox-agent/cli-shared": "workspace:*",
"acp-http-client": "workspace:*"
"acp-http-client": "workspace:*",
"@sandbox-agent/cli-shared": "workspace:*"
},
"files": [
"dist"
@ -34,12 +34,10 @@
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/ws": "^8.18.1",
"openapi-typescript": "^6.7.0",
"tsup": "^8.0.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0",
"ws": "^8.19.0"
"vitest": "^3.0.0"
},
"optionalDependencies": {
"@sandbox-agent/cli": "workspace:*"

File diff suppressed because it is too large Load diff

View file

@ -57,108 +57,6 @@ export interface paths {
"/v1/health": {
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": {
/**
* Resize a process terminal.
* @description Sets the PTY window size (columns and rows) for a tty-mode process and
* sends SIGWINCH so the child process can adapt.
*/
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). Streams raw PTY output as binary frames and accepts
* JSON control frames for input, resize, and close.
*/
get: operations["get_v1_process_terminal_ws"];
};
}
export type webhooks = Record<string, never>;
@ -235,7 +133,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" | "not_found" | "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" | "session_not_found" | "session_already_exists" | "mode_not_supported" | "stream_error" | "timeout";
FsActionResponse: {
path: string;
};
@ -332,116 +230,6 @@ export interface components {
type: string;
[key: string]: unknown;
};
ProcessConfig: {
/** Format: int64 */
defaultRunTimeoutMs: number;
maxConcurrentProcesses: number;
maxInputBytesPerRequest: number;
maxLogBytesPerProcess: number;
maxOutputBytes: number;
/** Format: int64 */
maxRunTimeoutMs: number;
};
ProcessCreateRequest: {
args?: string[];
command: string;
cwd?: string | null;
env?: {
[key: string]: string;
};
interactive?: boolean;
tty?: boolean;
};
ProcessInfo: {
args: string[];
command: string;
/** Format: int64 */
createdAtMs: number;
cwd?: string | null;
/** Format: int32 */
exitCode?: number | null;
/** Format: int64 */
exitedAtMs?: number | null;
id: string;
interactive: boolean;
/** Format: int32 */
pid?: number | null;
status: components["schemas"]["ProcessState"];
tty: boolean;
};
ProcessInputRequest: {
data: string;
encoding?: string | null;
};
ProcessInputResponse: {
bytesWritten: number;
};
ProcessListResponse: {
processes: components["schemas"]["ProcessInfo"][];
};
ProcessLogEntry: {
data: string;
encoding: string;
/** Format: int64 */
sequence: number;
stream: components["schemas"]["ProcessLogsStream"];
/** Format: int64 */
timestampMs: number;
};
ProcessLogsQuery: {
follow?: boolean | null;
/** Format: int64 */
since?: number | null;
stream?: components["schemas"]["ProcessLogsStream"] | null;
tail?: number | null;
};
ProcessLogsResponse: {
entries: components["schemas"]["ProcessLogEntry"][];
processId: string;
stream: components["schemas"]["ProcessLogsStream"];
};
/** @enum {string} */
ProcessLogsStream: "stdout" | "stderr" | "combined" | "pty";
ProcessRunRequest: {
args?: string[];
command: string;
cwd?: string | null;
env?: {
[key: string]: string;
};
maxOutputBytes?: number | null;
/** Format: int64 */
timeoutMs?: number | null;
};
ProcessRunResponse: {
/** Format: int64 */
durationMs: number;
/** Format: int32 */
exitCode?: number | null;
stderr: string;
stderrTruncated: boolean;
stdout: string;
stdoutTruncated: boolean;
timedOut: boolean;
};
ProcessSignalQuery: {
/** Format: int64 */
waitMs?: number | null;
};
/** @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: {
@ -960,486 +748,4 @@ 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 */
200: {
content: {
"application/json": components["schemas"]["ProcessListResponse"];
};
};
/** @description Process API unsupported on this platform */
501: {
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
};
};
/**
* 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: {
"application/json": components["schemas"]["ProcessCreateRequest"];
};
};
responses: {
/** @description Started process */
200: {
content: {
"application/json": components["schemas"]["ProcessInfo"];
};
};
/** @description Invalid request */
400: {
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Process limit or state conflict */
409: {
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Process API unsupported on this platform */
501: {
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
};
};
/**
* 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 */
200: {
content: {
"application/json": components["schemas"]["ProcessConfig"];
};
};
/** @description Process API unsupported on this platform */
501: {
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
};
};
/**
* 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: {
"application/json": components["schemas"]["ProcessConfig"];
};
};
responses: {
/** @description Updated runtime process config */
200: {
content: {
"application/json": components["schemas"]["ProcessConfig"];
};
};
/** @description Invalid config */
400: {
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Process API unsupported on this platform */
501: {
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
};
};
/**
* 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: {
"application/json": components["schemas"]["ProcessRunRequest"];
};
};
responses: {
/** @description One-off command result */
200: {
content: {
"application/json": components["schemas"]["ProcessRunResponse"];
};
};
/** @description Invalid request */
400: {
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Process API unsupported on this platform */
501: {
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
};
};
/**
* 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: {
/** @description Process ID */
id: string;
};
};
responses: {
/** @description Process details */
200: {
content: {
"application/json": components["schemas"]["ProcessInfo"];
};
};
/** @description Unknown process */
404: {
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Process API unsupported on this platform */
501: {
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
};
};
/**
* 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: {
/** @description Process ID */
id: string;
};
};
responses: {
/** @description Process deleted */
204: {
content: never;
};
/** @description Unknown process */
404: {
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Process is still running */
409: {
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Process API unsupported on this platform */
501: {
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
};
};
/**
* 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: {
/** @description Process ID */
id: string;
};
};
requestBody: {
content: {
"application/json": components["schemas"]["ProcessInputRequest"];
};
};
responses: {
/** @description Input accepted */
200: {
content: {
"application/json": components["schemas"]["ProcessInputResponse"];
};
};
/** @description Invalid request */
400: {
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Process not writable */
409: {
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Input exceeds configured limit */
413: {
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Process API unsupported on this platform */
501: {
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
};
};
/**
* 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?: {
/** @description Wait up to N ms for process to exit */
waitMs?: number | null;
};
path: {
/** @description Process ID */
id: string;
};
};
responses: {
/** @description Kill signal sent */
200: {
content: {
"application/json": components["schemas"]["ProcessInfo"];
};
};
/** @description Unknown process */
404: {
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Process API unsupported on this platform */
501: {
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
};
};
/**
* 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?: {
/** @description stdout|stderr|combined|pty */
stream?: components["schemas"]["ProcessLogsStream"] | null;
/** @description Tail N entries */
tail?: number | null;
/** @description Follow via SSE */
follow?: boolean | null;
/** @description Only entries with sequence greater than this */
since?: number | null;
};
path: {
/** @description Process ID */
id: string;
};
};
responses: {
/** @description Process logs */
200: {
content: {
"application/json": components["schemas"]["ProcessLogsResponse"];
};
};
/** @description Unknown process */
404: {
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Process API unsupported on this platform */
501: {
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
};
};
/**
* 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?: {
/** @description Wait up to N ms for process to exit */
waitMs?: number | null;
};
path: {
/** @description Process ID */
id: string;
};
};
responses: {
/** @description Stop signal sent */
200: {
content: {
"application/json": components["schemas"]["ProcessInfo"];
};
};
/** @description Unknown process */
404: {
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Process API unsupported on this platform */
501: {
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
};
};
/**
* Resize a process terminal.
* @description Sets the PTY window size (columns and rows) for a tty-mode process and
* sends SIGWINCH so the child process can adapt.
*/
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). Streams raw PTY output as binary frames and accepts
* JSON control frames for input, resize, and close.
*/
get_v1_process_terminal_ws: {
parameters: {
query?: {
/** @description Bearer token alternative for WS auth */
access_token?: string | null;
};
path: {
/** @description Process ID */
id: string;
};
};
responses: {
/** @description WebSocket upgraded */
101: {
content: never;
};
/** @description Invalid websocket frame or upgrade 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"];
};
};
};
};
}

View file

@ -1,12 +1,8 @@
export {
LiveAcpConnection,
ProcessTerminalSession,
SandboxAgent,
SandboxAgentError,
Session,
UnsupportedSessionCategoryError,
UnsupportedSessionConfigOptionError,
UnsupportedSessionValueError,
} from "./client.ts";
export { AcpRpcError } from "acp-http-client";
@ -14,14 +10,6 @@ export { AcpRpcError } from "acp-http-client";
export { buildInspectorUrl } from "./inspector.ts";
export type {
SandboxAgentHealthWaitOptions,
AgentQueryOptions,
ProcessLogFollowQuery,
ProcessLogListener,
ProcessLogSubscription,
ProcessTerminalConnectOptions,
ProcessTerminalSessionOptions,
ProcessTerminalWebSocketUrlOptions,
SandboxAgentConnectOptions,
SandboxAgentStartOptions,
SessionCreateRequest,
@ -41,7 +29,6 @@ export type {
AcpServerInfo,
AcpServerListResponse,
AgentInfo,
AgentQuery,
AgentInstallRequest,
AgentInstallResponse,
AgentListResponse,
@ -64,37 +51,11 @@ export type {
McpConfigQuery,
McpServerConfig,
ProblemDetails,
ProcessConfig,
ProcessCreateRequest,
ProcessInfo,
ProcessInputRequest,
ProcessInputResponse,
ProcessListResponse,
ProcessLogEntry,
ProcessLogsQuery,
ProcessLogsResponse,
ProcessLogsStream,
ProcessRunRequest,
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

@ -1,9 +1,4 @@
import type {
AnyMessage,
NewSessionRequest,
SessionConfigOption,
SessionModeState,
} from "acp-http-client";
import type { AnyMessage, NewSessionRequest } from "acp-http-client";
import type { components, operations } from "./generated/openapi.ts";
export type ProblemDetails = components["schemas"]["ProblemDetails"];
@ -11,7 +6,6 @@ export type ProblemDetails = components["schemas"]["ProblemDetails"];
export type HealthResponse = JsonResponse<operations["get_v1_health"], 200>;
export type AgentListResponse = JsonResponse<operations["get_v1_agents"], 200>;
export type AgentInfo = components["schemas"]["AgentInfo"];
export type AgentQuery = QueryParams<operations["get_v1_agents"]>;
export type AgentInstallRequest = JsonRequestBody<operations["post_v1_agent_install"]>;
export type AgentInstallResponse = JsonResponse<operations["post_v1_agent_install"], 200>;
@ -37,68 +31,6 @@ export type McpServerConfig = components["schemas"]["McpServerConfig"];
export type SkillsConfigQuery = QueryParams<operations["get_v1_config_skills"]>;
export type SkillsConfig = components["schemas"]["SkillsConfig"];
export type ProcessConfig = JsonResponse<operations["get_v1_processes_config"], 200>;
export type ProcessCreateRequest = JsonRequestBody<operations["post_v1_processes"]>;
export type ProcessInfo = components["schemas"]["ProcessInfo"];
export type ProcessInputRequest = JsonRequestBody<operations["post_v1_process_input"]>;
export type ProcessInputResponse = JsonResponse<operations["post_v1_process_input"], 200>;
export type ProcessListResponse = JsonResponse<operations["get_v1_processes"], 200>;
export type ProcessLogEntry = components["schemas"]["ProcessLogEntry"];
export type ProcessLogsQuery = QueryParams<operations["get_v1_process_logs"]>;
export type ProcessLogsResponse = JsonResponse<operations["get_v1_process_logs"], 200>;
export type ProcessLogsStream = components["schemas"]["ProcessLogsStream"];
export type ProcessRunRequest = JsonRequestBody<operations["post_v1_processes_run"]>;
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 interface ProcessTerminalReadyFrame {
type: "ready";
processId: string;
}
export interface ProcessTerminalExitFrame {
type: "exit";
exitCode?: number | null;
}
export interface ProcessTerminalErrorFrame {
type: "error";
message: string;
}
export type ProcessTerminalServerFrame =
| ProcessTerminalReadyFrame
| ProcessTerminalExitFrame
| ProcessTerminalErrorFrame;
export type TerminalReadyStatus = ProcessTerminalReadyFrame;
export type TerminalExitStatus = ProcessTerminalExitFrame;
export type TerminalErrorStatus = ProcessTerminalErrorFrame;
export type TerminalStatusMessage = ProcessTerminalServerFrame;
export interface TerminalResizePayload {
cols: number;
rows: number;
}
export interface SessionRecord {
id: string;
agent: string;
@ -107,8 +39,6 @@ export interface SessionRecord {
createdAt: number;
destroyedAt?: number;
sessionInit?: Omit<NewSessionRequest, "_meta">;
configOptions?: SessionConfigOption[];
modes?: SessionModeState | null;
}
export type SessionEventSender = "client" | "agent";
@ -248,12 +178,6 @@ function cloneSessionRecord(session: SessionRecord): SessionRecord {
sessionInit: session.sessionInit
? (JSON.parse(JSON.stringify(session.sessionInit)) as SessionRecord["sessionInit"])
: undefined,
configOptions: session.configOptions
? (JSON.parse(JSON.stringify(session.configOptions)) as SessionRecord["configOptions"])
: undefined,
modes: session.modes
? (JSON.parse(JSON.stringify(session.modes)) as SessionRecord["modes"])
: session.modes,
};
}

View file

@ -1,29 +1,18 @@
import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
function candidateInstallDirs(dataHome: string): string[] {
const dirs = [join(dataHome, "sandbox-agent", "bin")];
if (process.platform === "darwin") {
dirs.push(join(dataHome, "Library", "Application Support", "sandbox-agent", "bin"));
} else if (process.platform === "win32") {
dirs.push(join(dataHome, "AppData", "Roaming", "sandbox-agent", "bin"));
}
return dirs;
}
export function prepareMockAgentDataHome(dataHome: string): void {
const installDir = join(dataHome, "sandbox-agent", "bin");
const processDir = join(installDir, "agent_processes");
mkdirSync(processDir, { recursive: true });
export function prepareMockAgentDataHome(dataHome: string): Record<string, string> {
const runtimeEnv: Record<string, string> = {};
if (process.platform === "darwin") {
runtimeEnv.HOME = dataHome;
runtimeEnv.XDG_DATA_HOME = join(dataHome, ".local", "share");
} else if (process.platform === "win32") {
runtimeEnv.USERPROFILE = dataHome;
runtimeEnv.APPDATA = join(dataHome, "AppData", "Roaming");
runtimeEnv.LOCALAPPDATA = join(dataHome, "AppData", "Local");
} else {
runtimeEnv.HOME = dataHome;
runtimeEnv.XDG_DATA_HOME = dataHome;
}
const runner = process.platform === "win32"
? join(processDir, "mock-acp.cmd")
: join(processDir, "mock-acp");
const scriptFile = process.platform === "win32"
? join(processDir, "mock-acp.js")
: runner;
const nodeScript = String.raw`#!/usr/bin/env node
const { createInterface } = require("node:readline");
@ -138,29 +127,14 @@ rl.on("line", (line) => {
});
`;
for (const installDir of candidateInstallDirs(dataHome)) {
const processDir = join(installDir, "agent_processes");
mkdirSync(processDir, { recursive: true });
writeFileSync(scriptFile, nodeScript);
const runner = process.platform === "win32"
? join(processDir, "mock-acp.cmd")
: join(processDir, "mock-acp");
const scriptFile = process.platform === "win32"
? join(processDir, "mock-acp.js")
: runner;
writeFileSync(scriptFile, nodeScript);
if (process.platform === "win32") {
writeFileSync(runner, `@echo off\r\nnode "${scriptFile}" %*\r\n`);
}
chmodSync(scriptFile, 0o755);
if (process.platform === "win32") {
chmodSync(runner, 0o755);
}
if (process.platform === "win32") {
writeFileSync(runner, `@echo off\r\nnode "${scriptFile}" %*\r\n`);
}
return runtimeEnv;
chmodSync(scriptFile, 0o755);
if (process.platform === "win32") {
chmodSync(runner, 0o755);
}
}

View file

@ -12,7 +12,6 @@ import {
} from "../src/index.ts";
import { spawnSandboxAgent, isNodeRuntime, type SandboxAgentSpawnHandle } from "../src/spawn.ts";
import { prepareMockAgentDataHome } from "./helpers/mock-agent.ts";
import WebSocket from "ws";
const __dirname = dirname(fileURLToPath(import.meta.url));
@ -65,91 +64,6 @@ async function waitFor<T>(
throw new Error("timed out waiting for condition");
}
async function waitForAsync<T>(
fn: () => Promise<T | undefined | null>,
timeoutMs = 6000,
stepMs = 30,
): Promise<T> {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
const value = await fn();
if (value !== undefined && value !== null) {
return value;
}
await sleep(stepMs);
}
throw new Error("timed out waiting for condition");
}
function buildTarArchive(entries: Array<{ name: string; content: string }>): Uint8Array {
const blocks: Buffer[] = [];
for (const entry of entries) {
const content = Buffer.from(entry.content, "utf8");
const header = Buffer.alloc(512, 0);
writeTarString(header, 0, 100, entry.name);
writeTarOctal(header, 100, 8, 0o644);
writeTarOctal(header, 108, 8, 0);
writeTarOctal(header, 116, 8, 0);
writeTarOctal(header, 124, 12, content.length);
writeTarOctal(header, 136, 12, Math.floor(Date.now() / 1000));
header.fill(0x20, 148, 156);
header[156] = "0".charCodeAt(0);
writeTarString(header, 257, 6, "ustar");
writeTarString(header, 263, 2, "00");
let checksum = 0;
for (const byte of header) {
checksum += byte;
}
writeTarChecksum(header, checksum);
blocks.push(header);
blocks.push(content);
const remainder = content.length % 512;
if (remainder !== 0) {
blocks.push(Buffer.alloc(512 - remainder, 0));
}
}
blocks.push(Buffer.alloc(1024, 0));
return Buffer.concat(blocks);
}
function writeTarString(buffer: Buffer, offset: number, length: number, value: string): void {
const bytes = Buffer.from(value, "utf8");
bytes.copy(buffer, offset, 0, Math.min(bytes.length, length));
}
function writeTarOctal(buffer: Buffer, offset: number, length: number, value: number): void {
const rendered = value.toString(8).padStart(length - 1, "0");
writeTarString(buffer, offset, length, rendered);
buffer[offset + length - 1] = 0;
}
function writeTarChecksum(buffer: Buffer, checksum: number): void {
const rendered = checksum.toString(8).padStart(6, "0");
writeTarString(buffer, 148, 6, rendered);
buffer[154] = 0;
buffer[155] = 0x20;
}
function decodeProcessLogData(data: string, encoding: string): string {
if (encoding === "base64") {
return Buffer.from(data, "base64").toString("utf8");
}
return data;
}
function nodeCommand(source: string): { command: string; args: string[] } {
return {
command: process.execPath,
args: ["-e", source],
};
}
describe("Integration: TypeScript SDK flat session API", () => {
let handle: SandboxAgentSpawnHandle;
let baseUrl: string;
@ -158,13 +72,15 @@ describe("Integration: TypeScript SDK flat session API", () => {
beforeAll(async () => {
dataHome = mkdtempSync(join(tmpdir(), "sdk-integration-"));
const agentEnv = prepareMockAgentDataHome(dataHome);
prepareMockAgentDataHome(dataHome);
handle = await spawnSandboxAgent({
enabled: true,
log: "silent",
timeoutMs: 30000,
env: agentEnv,
env: {
XDG_DATA_HOME: dataHome,
},
});
baseUrl = handle.baseUrl;
token = handle.token;
@ -206,9 +122,6 @@ describe("Integration: TypeScript SDK flat session API", () => {
const fetched = await sdk.getSession(session.id);
expect(fetched?.agent).toBe("mock");
const acpServers = await sdk.listAcpServers();
expect(acpServers.servers.some((server) => server.agent === "mock")).toBe(true);
const events = await sdk.getEvents({ sessionId: session.id, limit: 100 });
expect(events.items.length).toBeGreaterThan(0);
expect(events.items.some((event) => event.sender === "client")).toBe(true);
@ -224,64 +137,6 @@ describe("Integration: TypeScript SDK flat session API", () => {
await sdk.dispose();
});
it("covers agent query flags and filesystem HTTP helpers", async () => {
const sdk = await SandboxAgent.connect({
baseUrl,
token,
});
const directory = mkdtempSync(join(tmpdir(), "sdk-fs-"));
const nestedDir = join(directory, "nested");
const filePath = join(directory, "notes.txt");
const movedPath = join(directory, "notes-moved.txt");
const uploadDir = join(directory, "uploaded");
try {
const listedAgents = await sdk.listAgents({ config: true, noCache: true });
expect(listedAgents.agents.some((agent) => agent.id === "mock")).toBe(true);
const mockAgent = await sdk.getAgent("mock", { config: true, noCache: true });
expect(mockAgent.id).toBe("mock");
expect(Array.isArray(mockAgent.configOptions)).toBe(true);
await sdk.mkdirFs({ path: nestedDir });
await sdk.writeFsFile({ path: filePath }, "hello from sdk");
const bytes = await sdk.readFsFile({ path: filePath });
expect(new TextDecoder().decode(bytes)).toBe("hello from sdk");
const stat = await sdk.statFs({ path: filePath });
expect(stat.path).toBe(filePath);
expect(stat.size).toBe(bytes.byteLength);
const entries = await sdk.listFsEntries({ path: directory });
expect(entries.some((entry) => entry.path === nestedDir)).toBe(true);
expect(entries.some((entry) => entry.path === filePath)).toBe(true);
const moved = await sdk.moveFs({
from: filePath,
to: movedPath,
overwrite: true,
});
expect(moved.to).toBe(movedPath);
const uploadResult = await sdk.uploadFsBatch(
buildTarArchive([{ name: "batch.txt", content: "batch upload works" }]),
{ path: uploadDir },
);
expect(uploadResult.paths.some((path) => path.endsWith("batch.txt"))).toBe(true);
const uploaded = await sdk.readFsFile({ path: join(uploadDir, "batch.txt") });
expect(new TextDecoder().decode(uploaded)).toBe("batch upload works");
const deleted = await sdk.deleteFsEntry({ path: movedPath });
expect(deleted.path).toBe(movedPath);
} finally {
rmSync(directory, { recursive: true, force: true });
await sdk.dispose();
}
});
it("uses custom fetch for both HTTP helpers and ACP session traffic", async () => {
const defaultFetch = globalThis.fetch;
if (!defaultFetch) {
@ -313,7 +168,7 @@ describe("Integration: TypeScript SDK flat session API", () => {
expect(seenPaths.some((path) => path.startsWith("/v1/acp/"))).toBe(true);
await sdk.dispose();
}, 60_000);
});
it("requires baseUrl when fetch is not provided", async () => {
await expect(SandboxAgent.connect({ token } as any)).rejects.toThrow(
@ -321,111 +176,6 @@ describe("Integration: TypeScript SDK flat session API", () => {
);
});
it("waits for health before non-ACP HTTP helpers", async () => {
const defaultFetch = globalThis.fetch;
if (!defaultFetch) {
throw new Error("Global fetch is not available in this runtime.");
}
let healthAttempts = 0;
const seenPaths: string[] = [];
const customFetch: typeof fetch = async (input, init) => {
const outgoing = new Request(input, init);
const parsed = new URL(outgoing.url);
seenPaths.push(parsed.pathname);
if (parsed.pathname === "/v1/health") {
healthAttempts += 1;
if (healthAttempts < 3) {
return new Response("warming up", { status: 503 });
}
}
const forwardedUrl = new URL(`${parsed.pathname}${parsed.search}`, baseUrl);
const forwarded = new Request(forwardedUrl.toString(), outgoing);
return defaultFetch(forwarded);
};
const sdk = await SandboxAgent.connect({
token,
fetch: customFetch,
});
const agents = await sdk.listAgents();
expect(Array.isArray(agents.agents)).toBe(true);
expect(healthAttempts).toBe(3);
const firstAgentsRequest = seenPaths.indexOf("/v1/agents");
expect(firstAgentsRequest).toBeGreaterThanOrEqual(0);
expect(seenPaths.slice(0, firstAgentsRequest)).toEqual([
"/v1/health",
"/v1/health",
"/v1/health",
]);
await sdk.dispose();
});
it("surfaces health timeout when a request awaits readiness", async () => {
const customFetch: typeof fetch = async (input, init) => {
const outgoing = new Request(input, init);
const parsed = new URL(outgoing.url);
if (parsed.pathname === "/v1/health") {
return new Response("warming up", { status: 503 });
}
throw new Error(`Unexpected request path during timeout test: ${parsed.pathname}`);
};
const sdk = await SandboxAgent.connect({
token,
fetch: customFetch,
waitForHealth: { timeoutMs: 100 },
});
await expect(sdk.listAgents()).rejects.toThrow("Timed out waiting for sandbox-agent health");
await sdk.dispose();
});
it("aborts the shared health wait when connect signal is aborted", async () => {
const controller = new AbortController();
const customFetch: typeof fetch = async (input, init) => {
const outgoing = new Request(input, init);
const parsed = new URL(outgoing.url);
if (parsed.pathname !== "/v1/health") {
throw new Error(`Unexpected request path during abort test: ${parsed.pathname}`);
}
return new Promise<Response>((_resolve, reject) => {
const onAbort = () => {
outgoing.signal.removeEventListener("abort", onAbort);
reject(outgoing.signal.reason ?? new DOMException("Connect aborted", "AbortError"));
};
if (outgoing.signal.aborted) {
onAbort();
return;
}
outgoing.signal.addEventListener("abort", onAbort, { once: true });
});
};
const sdk = await SandboxAgent.connect({
token,
fetch: customFetch,
signal: controller.signal,
});
const pending = sdk.listAgents();
controller.abort(new DOMException("Connect aborted", "AbortError"));
await expect(pending).rejects.toThrow("Connect aborted");
await sdk.dispose();
});
it("restores a session on stale connection by recreating and replaying history on first prompt", async () => {
const persist = new InMemorySessionPersistDriver({
maxEventsPerSession: 200,
@ -504,127 +254,6 @@ describe("Integration: TypeScript SDK flat session API", () => {
await sdk.dispose();
});
it("blocks manual session/cancel and requires destroySession", async () => {
const sdk = await SandboxAgent.connect({
baseUrl,
token,
});
const session = await sdk.createSession({ agent: "mock" });
await expect(session.send("session/cancel")).rejects.toThrow(
"Use destroySession(sessionId) instead.",
);
await expect(sdk.sendSessionMethod(session.id, "session/cancel", {})).rejects.toThrow(
"Use destroySession(sessionId) instead.",
);
const destroyed = await sdk.destroySession(session.id);
expect(destroyed.destroyedAt).toBeDefined();
const reloaded = await sdk.getSession(session.id);
expect(reloaded?.destroyedAt).toBeDefined();
await sdk.dispose();
});
it("supports typed config helpers and createSession preconfiguration", async () => {
const sdk = await SandboxAgent.connect({
baseUrl,
token,
});
const session = await sdk.createSession({
agent: "mock",
model: "mock",
});
const options = await session.getConfigOptions();
expect(options.some((option) => option.category === "model")).toBe(true);
await expect(session.setModel("unknown-model")).rejects.toThrow("does not support value");
await sdk.dispose();
});
it("setModel happy path switches to a valid model", async () => {
const sdk = await SandboxAgent.connect({
baseUrl,
token,
});
const session = await sdk.createSession({ agent: "mock" });
await session.setModel("mock-fast");
const options = await session.getConfigOptions();
const modelOption = options.find((o) => o.category === "model");
expect(modelOption?.currentValue).toBe("mock-fast");
await sdk.dispose();
});
it("setMode happy path switches to a valid mode", async () => {
const sdk = await SandboxAgent.connect({
baseUrl,
token,
});
const session = await sdk.createSession({ agent: "mock" });
await session.setMode("plan");
const modes = await session.getModes();
expect(modes?.currentModeId).toBe("plan");
await sdk.dispose();
});
it("setThoughtLevel happy path switches to a valid thought level", async () => {
const sdk = await SandboxAgent.connect({
baseUrl,
token,
});
const session = await sdk.createSession({ agent: "mock" });
await session.setThoughtLevel("high");
const options = await session.getConfigOptions();
const thoughtOption = options.find((o) => o.category === "thought_level");
expect(thoughtOption?.currentValue).toBe("high");
await sdk.dispose();
});
it("setModel/setMode/setThoughtLevel can be changed multiple times", async () => {
const sdk = await SandboxAgent.connect({
baseUrl,
token,
});
const session = await sdk.createSession({ agent: "mock" });
// Model: mock → mock-fast → mock
await session.setModel("mock-fast");
expect((await session.getConfigOptions()).find((o) => o.category === "model")?.currentValue).toBe("mock-fast");
await session.setModel("mock");
expect((await session.getConfigOptions()).find((o) => o.category === "model")?.currentValue).toBe("mock");
// Mode: normal → plan → normal
await session.setMode("plan");
expect((await session.getModes())?.currentModeId).toBe("plan");
await session.setMode("normal");
expect((await session.getModes())?.currentModeId).toBe("normal");
// Thought level: low → high → medium → low
await session.setThoughtLevel("high");
expect((await session.getConfigOptions()).find((o) => o.category === "thought_level")?.currentValue).toBe("high");
await session.setThoughtLevel("medium");
expect((await session.getConfigOptions()).find((o) => o.category === "thought_level")?.currentValue).toBe("medium");
await session.setThoughtLevel("low");
expect((await session.getConfigOptions()).find((o) => o.category === "thought_level")?.currentValue).toBe("low");
await sdk.dispose();
});
it("supports MCP and skills config HTTP helpers", async () => {
const sdk = await SandboxAgent.connect({
baseUrl,
@ -691,195 +320,4 @@ describe("Integration: TypeScript SDK flat session API", () => {
await sdk.dispose();
rmSync(directory, { recursive: true, force: true });
});
it("covers process runtime HTTP helpers, log streaming, and terminal websocket access", async () => {
const sdk = await SandboxAgent.connect({
baseUrl,
token,
});
const originalConfig = await sdk.getProcessConfig();
const updatedConfig = await sdk.setProcessConfig({
...originalConfig,
maxOutputBytes: originalConfig.maxOutputBytes + 1,
});
expect(updatedConfig.maxOutputBytes).toBe(originalConfig.maxOutputBytes + 1);
const runResult = await sdk.runProcess({
...nodeCommand("process.stdout.write('run-stdout'); process.stderr.write('run-stderr');"),
timeoutMs: 5_000,
});
expect(runResult.stdout).toContain("run-stdout");
expect(runResult.stderr).toContain("run-stderr");
let interactiveProcessId: string | undefined;
let ttyProcessId: string | undefined;
let killProcessId: string | undefined;
try {
const interactiveProcess = await sdk.createProcess({
...nodeCommand(`
process.stdin.setEncoding("utf8");
process.stdout.write("ready\\n");
process.stdin.on("data", (chunk) => {
process.stdout.write("echo:" + chunk);
});
setInterval(() => {}, 1_000);
`),
interactive: true,
});
interactiveProcessId = interactiveProcess.id;
const listed = await sdk.listProcesses();
expect(listed.processes.some((process) => process.id === interactiveProcess.id)).toBe(true);
const fetched = await sdk.getProcess(interactiveProcess.id);
expect(fetched.status).toBe("running");
const initialLogs = await waitForAsync(async () => {
const logs = await sdk.getProcessLogs(interactiveProcess.id, { tail: 10 });
return logs.entries.some((entry) => decodeProcessLogData(entry.data, entry.encoding).includes("ready"))
? logs
: undefined;
});
expect(
initialLogs.entries.some((entry) => decodeProcessLogData(entry.data, entry.encoding).includes("ready")),
).toBe(true);
const followedLogs: string[] = [];
const subscription = await sdk.followProcessLogs(
interactiveProcess.id,
(entry) => {
followedLogs.push(decodeProcessLogData(entry.data, entry.encoding));
},
{ tail: 1 },
);
try {
const inputResult = await sdk.sendProcessInput(interactiveProcess.id, {
data: Buffer.from("hello over stdin\n", "utf8").toString("base64"),
encoding: "base64",
});
expect(inputResult.bytesWritten).toBeGreaterThan(0);
await waitFor(() => {
const joined = followedLogs.join("");
return joined.includes("echo:hello over stdin") ? joined : undefined;
});
} finally {
subscription.close();
await subscription.closed;
}
const stopped = await sdk.stopProcess(interactiveProcess.id, { waitMs: 5_000 });
expect(stopped.status).toBe("exited");
await sdk.deleteProcess(interactiveProcess.id);
interactiveProcessId = undefined;
const ttyProcess = await sdk.createProcess({
...nodeCommand(`
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
process.stdout.write(chunk);
});
setInterval(() => {}, 1_000);
`),
interactive: true,
tty: true,
});
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 session = sdk.connectProcessTerminal(ttyProcess.id, {
WebSocket: WebSocket as unknown as typeof globalThis.WebSocket,
});
const readyFrames: string[] = [];
const ttyOutput: string[] = [];
const exitFrames: Array<number | null | undefined> = [];
const terminalErrors: string[] = [];
let closeCount = 0;
session.onReady((frame) => {
readyFrames.push(frame.processId);
});
session.onData((bytes) => {
ttyOutput.push(Buffer.from(bytes).toString("utf8"));
});
session.onExit((frame) => {
exitFrames.push(frame.exitCode);
});
session.onError((error) => {
terminalErrors.push(error instanceof Error ? error.message : error.message);
});
session.onClose(() => {
closeCount += 1;
});
await waitFor(() => readyFrames[0]);
session.sendInput("hello tty\n");
await waitFor(() => {
const joined = ttyOutput.join("");
return joined.includes("hello tty") ? joined : undefined;
});
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;
});
const killedTty = await sdk.killProcess(ttyProcess.id, { waitMs: 5_000 });
expect(killedTty.status).toBe("exited");
await sdk.deleteProcess(ttyProcess.id);
ttyProcessId = undefined;
const killProcess = await sdk.createProcess({
...nodeCommand("setInterval(() => {}, 1_000);"),
});
killProcessId = killProcess.id;
const killed = await sdk.killProcess(killProcess.id, { waitMs: 5_000 });
expect(killed.status).toBe("exited");
await sdk.deleteProcess(killProcess.id);
killProcessId = undefined;
} finally {
await sdk.setProcessConfig(originalConfig);
if (interactiveProcessId) {
await sdk.killProcess(interactiveProcessId, { waitMs: 5_000 }).catch(() => {});
await sdk.deleteProcess(interactiveProcessId).catch(() => {});
}
if (ttyProcessId) {
await sdk.killProcess(ttyProcessId, { waitMs: 5_000 }).catch(() => {});
await sdk.deleteProcess(ttyProcessId).catch(() => {});
}
if (killProcessId) {
await sdk.killProcess(killProcessId, { waitMs: 5_000 }).catch(() => {});
await sdk.deleteProcess(killProcessId).catch(() => {});
}
await sdk.dispose();
}
});
});