diff --git a/sdks/acp-http-client/tests/smoke.test.ts b/sdks/acp-http-client/tests/smoke.test.ts index 2380010..8b92e6c 100644 --- a/sdks/acp-http-client/tests/smoke.test.ts +++ b/sdks/acp-http-client/tests/smoke.test.ts @@ -74,6 +74,10 @@ describe("AcpHttpClient integration", () => { timeoutMs: 30000, env: { XDG_DATA_HOME: dataHome, + HOME: dataHome, + USERPROFILE: dataHome, + APPDATA: join(dataHome, "AppData", "Roaming"), + LOCALAPPDATA: join(dataHome, "AppData", "Local"), }, }); baseUrl = handle.baseUrl; diff --git a/sdks/persist-indexeddb/tests/integration.test.ts b/sdks/persist-indexeddb/tests/integration.test.ts index a30e70e..064c83d 100644 --- a/sdks/persist-indexeddb/tests/integration.test.ts +++ b/sdks/persist-indexeddb/tests/integration.test.ts @@ -60,6 +60,10 @@ describe("IndexedDB persistence end-to-end", () => { timeoutMs: 30000, env: { XDG_DATA_HOME: dataHome, + HOME: dataHome, + USERPROFILE: dataHome, + APPDATA: join(dataHome, "AppData", "Roaming"), + LOCALAPPDATA: join(dataHome, "AppData", "Local"), }, }); baseUrl = handle.baseUrl; diff --git a/sdks/persist-postgres/tests/integration.test.ts b/sdks/persist-postgres/tests/integration.test.ts index f453021..9017775 100644 --- a/sdks/persist-postgres/tests/integration.test.ts +++ b/sdks/persist-postgres/tests/integration.test.ts @@ -64,6 +64,10 @@ describe("Postgres persistence driver", () => { timeoutMs: 30000, env: { XDG_DATA_HOME: dataHome, + HOME: dataHome, + USERPROFILE: dataHome, + APPDATA: join(dataHome, "AppData", "Roaming"), + LOCALAPPDATA: join(dataHome, "AppData", "Local"), }, }); baseUrl = handle.baseUrl; diff --git a/sdks/persist-sqlite/tests/integration.test.ts b/sdks/persist-sqlite/tests/integration.test.ts index fb4b99c..5c4948a 100644 --- a/sdks/persist-sqlite/tests/integration.test.ts +++ b/sdks/persist-sqlite/tests/integration.test.ts @@ -55,6 +55,10 @@ describe("SQLite persistence driver", () => { timeoutMs: 30000, env: { XDG_DATA_HOME: dataHome, + HOME: dataHome, + USERPROFILE: dataHome, + APPDATA: join(dataHome, "AppData", "Roaming"), + LOCALAPPDATA: join(dataHome, "AppData", "Local"), }, }); baseUrl = handle.baseUrl; diff --git a/sdks/typescript/src/client.ts b/sdks/typescript/src/client.ts index 77d3622..35d1691 100644 --- a/sdks/typescript/src/client.ts +++ b/sdks/typescript/src/client.ts @@ -39,6 +39,20 @@ import { type McpConfigQuery, type McpServerConfig, type ProblemDetails, + type ProcessConfig, + type ProcessCreateRequest, + type ProcessInfo, + type ProcessInputRequest, + type ProcessInputResponse, + type ProcessListResponse, + type ProcessLogEntry, + type ProcessLogsQuery, + type ProcessLogsResponse, + type ProcessRunRequest, + type ProcessRunResponse, + type ProcessSignalQuery, + type ProcessTerminalResizeRequest, + type ProcessTerminalResizeResponse, type SessionEvent, type SessionPersistDriver, type SessionRecord, @@ -98,6 +112,27 @@ export interface SessionSendOptions { } export type SessionEventListener = (event: SessionEvent) => void; +export type ProcessLogListener = (entry: ProcessLogEntry) => void; +export type ProcessLogFollowQuery = Omit; + +export interface AgentQueryOptions { + config?: boolean; + noCache?: boolean; +} + +export interface ProcessLogSubscription { + close(): void; + closed: Promise; +} + +export interface ProcessTerminalWebSocketUrlOptions { + accessToken?: string; +} + +export interface ProcessTerminalConnectOptions extends ProcessTerminalWebSocketUrlOptions { + protocols?: string | string[]; + WebSocket?: typeof WebSocket; +} export class SandboxAgentError extends Error { readonly status: number; @@ -674,15 +709,15 @@ export class SandboxAgent { return this.requestJson("GET", `${API_PREFIX}/health`); } - async listAgents(options?: { config?: boolean }): Promise { + async listAgents(options?: AgentQueryOptions): Promise { return this.requestJson("GET", `${API_PREFIX}/agents`, { - query: options?.config ? { config: "true" } : undefined, + query: toAgentQuery(options), }); } - async getAgent(agent: string, options?: { config?: boolean }): Promise { + async getAgent(agent: string, options?: AgentQueryOptions): Promise { return this.requestJson("GET", `${API_PREFIX}/agents/${encodeURIComponent(agent)}`, { - query: options?.config ? { config: "true" } : undefined, + query: toAgentQuery(options), }); } @@ -771,6 +806,134 @@ export class SandboxAgent { await this.requestRaw("DELETE", `${API_PREFIX}/config/skills`, { query }); } + async getProcessConfig(): Promise { + return this.requestJson("GET", `${API_PREFIX}/processes/config`); + } + + async setProcessConfig(config: ProcessConfig): Promise { + return this.requestJson("POST", `${API_PREFIX}/processes/config`, { + body: config, + }); + } + + async createProcess(request: ProcessCreateRequest): Promise { + return this.requestJson("POST", `${API_PREFIX}/processes`, { + body: request, + }); + } + + async runProcess(request: ProcessRunRequest): Promise { + return this.requestJson("POST", `${API_PREFIX}/processes/run`, { + body: request, + }); + } + + async listProcesses(): Promise { + return this.requestJson("GET", `${API_PREFIX}/processes`); + } + + async getProcess(id: string): Promise { + return this.requestJson("GET", `${API_PREFIX}/processes/${encodeURIComponent(id)}`); + } + + async stopProcess(id: string, query?: ProcessSignalQuery): Promise { + return this.requestJson("POST", `${API_PREFIX}/processes/${encodeURIComponent(id)}/stop`, { + query, + }); + } + + async killProcess(id: string, query?: ProcessSignalQuery): Promise { + return this.requestJson("POST", `${API_PREFIX}/processes/${encodeURIComponent(id)}/kill`, { + query, + }); + } + + async deleteProcess(id: string): Promise { + await this.requestRaw("DELETE", `${API_PREFIX}/processes/${encodeURIComponent(id)}`); + } + + async getProcessLogs(id: string, query: ProcessLogFollowQuery = {}): Promise { + return this.requestJson("GET", `${API_PREFIX}/processes/${encodeURIComponent(id)}/logs`, { + query, + }); + } + + async followProcessLogs( + id: string, + listener: ProcessLogListener, + query: ProcessLogFollowQuery = {}, + ): Promise { + const abortController = new AbortController(); + const response = await this.requestRaw( + "GET", + `${API_PREFIX}/processes/${encodeURIComponent(id)}/logs`, + { + query: { ...query, follow: true }, + accept: "text/event-stream", + signal: abortController.signal, + }, + ); + + if (!response.body) { + abortController.abort(); + throw new Error("SSE stream is not readable in this environment."); + } + + const closed = consumeProcessLogSse(response.body, listener, abortController.signal); + + return { + close: () => abortController.abort(), + closed, + }; + } + + async sendProcessInput(id: string, request: ProcessInputRequest): Promise { + return this.requestJson("POST", `${API_PREFIX}/processes/${encodeURIComponent(id)}/input`, { + body: request, + }); + } + + async resizeProcessTerminal( + id: string, + request: ProcessTerminalResizeRequest, + ): Promise { + return this.requestJson( + "POST", + `${API_PREFIX}/processes/${encodeURIComponent(id)}/terminal/resize`, + { + body: request, + }, + ); + } + + buildProcessTerminalWebSocketUrl( + id: string, + options: ProcessTerminalWebSocketUrlOptions = {}, + ): string { + return toWebSocketUrl( + this.buildUrl(`${API_PREFIX}/processes/${encodeURIComponent(id)}/terminal/ws`, { + access_token: options.accessToken ?? this.token, + }), + ); + } + + connectProcessTerminalWebSocket( + id: string, + options: ProcessTerminalConnectOptions = {}, + ): WebSocket { + const WebSocketCtor = options.WebSocket ?? globalThis.WebSocket; + if (!WebSocketCtor) { + throw new Error("WebSocket API is not available; provide a WebSocket implementation."); + } + + return new WebSocketCtor( + this.buildProcessTerminalWebSocketUrl(id, { + accessToken: options.accessToken, + }), + options.protocols, + ); + } + private async getLiveConnection(agent: string): Promise { const existing = this.liveConnections.get(agent); if (existing) { @@ -1068,6 +1231,17 @@ async function autoAuthenticate(acp: AcpHttpClient, methods: AuthMethod[]): Prom } } +function toAgentQuery(options: AgentQueryOptions | undefined): Record | undefined { + if (!options) { + return undefined; + } + + return { + config: options.config, + no_cache: options.noCache, + }; +} + function normalizeSessionInit( value: Omit | undefined, ): Omit { @@ -1230,3 +1404,93 @@ async function readProblem(response: Response): Promise, + listener: ProcessLogListener, + signal: AbortSignal, +): Promise { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + try { + while (!signal.aborted) { + const { done, value } = await reader.read(); + if (done) { + return; + } + + buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n"); + + let separatorIndex = buffer.indexOf("\n\n"); + while (separatorIndex !== -1) { + const chunk = buffer.slice(0, separatorIndex); + buffer = buffer.slice(separatorIndex + 2); + + const entry = parseProcessLogSseChunk(chunk); + if (entry) { + listener(entry); + } + + separatorIndex = buffer.indexOf("\n\n"); + } + } + } catch (error) { + if (signal.aborted || isAbortError(error)) { + return; + } + throw error; + } finally { + reader.releaseLock(); + } +} + +function parseProcessLogSseChunk(chunk: string): ProcessLogEntry | null { + if (!chunk.trim()) { + return null; + } + + let eventName = "message"; + const dataLines: string[] = []; + + for (const line of chunk.split("\n")) { + if (!line || line.startsWith(":")) { + continue; + } + + if (line.startsWith("event:")) { + eventName = line.slice(6).trim(); + continue; + } + + if (line.startsWith("data:")) { + dataLines.push(line.slice(5).trimStart()); + } + } + + if (eventName !== "log") { + return null; + } + + const data = dataLines.join("\n"); + if (!data.trim()) { + return null; + } + + return JSON.parse(data) as ProcessLogEntry; +} + +function toWebSocketUrl(url: string): string { + const parsed = new URL(url); + if (parsed.protocol === "http:") { + parsed.protocol = "ws:"; + } else if (parsed.protocol === "https:") { + parsed.protocol = "wss:"; + } + return parsed.toString(); +} + +function isAbortError(error: unknown): boolean { + return error instanceof Error && error.name === "AbortError"; +} diff --git a/sdks/typescript/src/generated/openapi.ts b/sdks/typescript/src/generated/openapi.ts index 91ab56b..a89d796 100644 --- a/sdks/typescript/src/generated/openapi.ts +++ b/sdks/typescript/src/generated/openapi.ts @@ -57,6 +57,39 @@ export interface paths { "/v1/health": { get: operations["get_v1_health"]; }; + "/v1/processes": { + get: operations["get_v1_processes"]; + post: operations["post_v1_processes"]; + }; + "/v1/processes/config": { + get: operations["get_v1_processes_config"]; + post: operations["post_v1_processes_config"]; + }; + "/v1/processes/run": { + post: operations["post_v1_processes_run"]; + }; + "/v1/processes/{id}": { + get: operations["get_v1_process"]; + delete: operations["delete_v1_process"]; + }; + "/v1/processes/{id}/input": { + post: operations["post_v1_process_input"]; + }; + "/v1/processes/{id}/kill": { + post: operations["post_v1_process_kill"]; + }; + "/v1/processes/{id}/logs": { + get: operations["get_v1_process_logs"]; + }; + "/v1/processes/{id}/stop": { + post: operations["post_v1_process_stop"]; + }; + "/v1/processes/{id}/terminal/resize": { + post: operations["post_v1_process_terminal_resize"]; + }; + "/v1/processes/{id}/terminal/ws": { + get: operations["get_v1_process_terminal_ws"]; + }; } export type webhooks = Record; @@ -230,6 +263,116 @@ 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: { @@ -748,4 +891,417 @@ export interface operations { }; }; }; + 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"]; + }; + }; + }; + }; + 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_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"]; + }; + }; + }; + }; + 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"]; + }; + }; + }; + }; + 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_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_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"]; + }; + }; + }; + }; + 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"]; + }; + }; + }; + }; + 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"]; + }; + }; + }; + }; + 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"]; + }; + }; + }; + }; + 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"]; + }; + }; + }; + }; + 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"]; + }; + }; + }; + }; + 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"]; + }; + }; + }; + }; } diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts index cb7d8cf..82b5791 100644 --- a/sdks/typescript/src/index.ts +++ b/sdks/typescript/src/index.ts @@ -10,6 +10,12 @@ export { AcpRpcError } from "acp-http-client"; export { buildInspectorUrl } from "./inspector.ts"; export type { + AgentQueryOptions, + ProcessLogFollowQuery, + ProcessLogListener, + ProcessLogSubscription, + ProcessTerminalConnectOptions, + ProcessTerminalWebSocketUrlOptions, SandboxAgentConnectOptions, SandboxAgentStartOptions, SessionCreateRequest, @@ -29,6 +35,7 @@ export type { AcpServerInfo, AcpServerListResponse, AgentInfo, + AgentQuery, AgentInstallRequest, AgentInstallResponse, AgentListResponse, @@ -51,6 +58,27 @@ 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, diff --git a/sdks/typescript/src/types.ts b/sdks/typescript/src/types.ts index 17b321e..aa7a73a 100644 --- a/sdks/typescript/src/types.ts +++ b/sdks/typescript/src/types.ts @@ -6,6 +6,7 @@ export type ProblemDetails = components["schemas"]["ProblemDetails"]; export type HealthResponse = JsonResponse; export type AgentListResponse = JsonResponse; export type AgentInfo = components["schemas"]["AgentInfo"]; +export type AgentQuery = QueryParams; export type AgentInstallRequest = JsonRequestBody; export type AgentInstallResponse = JsonResponse; @@ -31,6 +32,58 @@ export type McpServerConfig = components["schemas"]["McpServerConfig"]; export type SkillsConfigQuery = QueryParams; export type SkillsConfig = components["schemas"]["SkillsConfig"]; +export type ProcessConfig = JsonResponse; +export type ProcessCreateRequest = JsonRequestBody; +export type ProcessInfo = components["schemas"]["ProcessInfo"]; +export type ProcessInputRequest = JsonRequestBody; +export type ProcessInputResponse = JsonResponse; +export type ProcessListResponse = JsonResponse; +export type ProcessLogEntry = components["schemas"]["ProcessLogEntry"]; +export type ProcessLogsQuery = QueryParams; +export type ProcessLogsResponse = JsonResponse; +export type ProcessLogsStream = components["schemas"]["ProcessLogsStream"]; +export type ProcessRunRequest = JsonRequestBody; +export type ProcessRunResponse = JsonResponse; +export type ProcessSignalQuery = QueryParams; +export type ProcessState = components["schemas"]["ProcessState"]; +export type ProcessTerminalResizeRequest = JsonRequestBody; +export type ProcessTerminalResizeResponse = JsonResponse; + +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 interface SessionRecord { id: string; agent: string; diff --git a/sdks/typescript/tests/helpers/mock-agent.ts b/sdks/typescript/tests/helpers/mock-agent.ts index 3d5677b..c462add 100644 --- a/sdks/typescript/tests/helpers/mock-agent.ts +++ b/sdks/typescript/tests/helpers/mock-agent.ts @@ -2,18 +2,6 @@ import { chmodSync, mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; export function prepareMockAgentDataHome(dataHome: string): void { - const installDir = join(dataHome, "sandbox-agent", "bin"); - const processDir = join(installDir, "agent_processes"); - mkdirSync(processDir, { recursive: true }); - - 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"); @@ -127,14 +115,43 @@ rl.on("line", (line) => { }); `; - writeFileSync(scriptFile, nodeScript); + for (const installDir of installDirsForDataHome(dataHome)) { + const processDir = join(installDir, "agent_processes"); + mkdirSync(processDir, { recursive: true }); - if (process.platform === "win32") { - writeFileSync(runner, `@echo off\r\nnode "${scriptFile}" %*\r\n`); - } + const runner = process.platform === "win32" + ? join(processDir, "mock-acp.cmd") + : join(processDir, "mock-acp"); - chmodSync(scriptFile, 0o755); - if (process.platform === "win32") { - chmodSync(runner, 0o755); + 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); + } } } + +function installDirsForDataHome(dataHome: string): string[] { + const candidates = new Set([ + join(dataHome, "sandbox-agent", "bin"), + ]); + + if (process.platform === "darwin") { + candidates.add(join(dataHome, "Library", "Application Support", "sandbox-agent", "bin")); + } + + if (process.platform === "win32") { + candidates.add(join(dataHome, "AppData", "Roaming", "sandbox-agent", "bin")); + } + + return [...candidates]; +} diff --git a/sdks/typescript/tests/integration.test.ts b/sdks/typescript/tests/integration.test.ts index 84b0d1a..3936648 100644 --- a/sdks/typescript/tests/integration.test.ts +++ b/sdks/typescript/tests/integration.test.ts @@ -64,6 +64,107 @@ async function waitFor( throw new Error("timed out waiting for condition"); } +async function waitForAsync( + fn: () => Promise, + timeoutMs = 6000, + stepMs = 30, +): Promise { + 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 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"); + } + 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; @@ -80,6 +181,10 @@ describe("Integration: TypeScript SDK flat session API", () => { timeoutMs: 30000, env: { XDG_DATA_HOME: dataHome, + HOME: dataHome, + USERPROFILE: dataHome, + APPDATA: join(dataHome, "AppData", "Roaming"), + LOCALAPPDATA: join(dataHome, "AppData", "Local"), }, }); baseUrl = handle.baseUrl; @@ -122,6 +227,9 @@ 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); @@ -137,6 +245,64 @@ 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) { @@ -168,7 +334,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( @@ -320,4 +486,184 @@ 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 ws = sdk.connectProcessTerminalWebSocket(ttyProcess.id); + ws.binaryType = "arraybuffer"; + + 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)); + }); + + await waitFor(() => { + const ready = socketTextFrames.find((frame) => frame.includes('"type":"ready"')); + return ready; + }); + + ws.send(JSON.stringify({ + type: "input", + data: "hello tty\n", + })); + + await waitFor(() => { + const joined = socketBinaryFrames.join(""); + return joined.includes("hello tty") ? joined : undefined; + }); + + ws.close(); + 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(); + } + }); }); diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 2c217d7..b56223c 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -1,17 +1,17 @@ # Server Instructions -## ACP v2 Architecture +## Architecture - Public API routes are defined in `server/packages/sandbox-agent/src/router.rs`. -- ACP runtime/process bridge is in `server/packages/sandbox-agent/src/acp_runtime.rs`. -- `/v2` is the only active API surface for sessions/prompts (`/v2/rpc`). +- ACP proxy runtime is in `server/packages/sandbox-agent/src/acp_proxy_runtime.rs`. +- All API endpoints are under `/v1`. - Keep binary filesystem transfer endpoints as dedicated HTTP APIs: - - `GET /v2/fs/file` - - `PUT /v2/fs/file` - - `POST /v2/fs/upload-batch` + - `GET /v1/fs/file` + - `PUT /v1/fs/file` + - `POST /v1/fs/upload-batch` - Rationale: host-owned cross-agent-consistent behavior and large binary transfer needs that ACP JSON-RPC is not suited to stream efficiently. - Maintain ACP variants in parallel only when they share the same underlying filesystem implementation; SDK defaults should still prefer HTTP for large/binary transfers. -- `/v1/*` must remain hard-removed (`410`) and `/opencode/*` stays disabled (`503`) until Phase 7. +- `/opencode/*` stays disabled (`503`) until Phase 7. - Agent install logic (native + ACP agent process + lazy install) is handled by `server/packages/agent-management/`. ## API Contract Rules @@ -23,14 +23,14 @@ ## Tests -Primary v2 integration coverage: -- `server/packages/sandbox-agent/tests/v2_api.rs` -- `server/packages/sandbox-agent/tests/v2_agent_process_matrix.rs` +Primary v1 integration coverage: +- `server/packages/sandbox-agent/tests/v1_api.rs` +- `server/packages/sandbox-agent/tests/v1_agent_process_matrix.rs` Run: ```bash -cargo test -p sandbox-agent --test v2_api -cargo test -p sandbox-agent --test v2_agent_process_matrix +cargo test -p sandbox-agent --test v1_api +cargo test -p sandbox-agent --test v1_agent_process_matrix ``` ## Migration Docs Sync