mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-16 13:04:11 +00:00
feat: improve inspector UI for processes and fix PTY terminal
- Simplify ProcessRunTab layout: compact form with collapsible Advanced section for timeout/maxOutputBytes - Rewrite ProcessesTab: collapsible create form, lightweight list items with status dots, clean detail panel with tabs - Extract error details: use problem.detail instead of generic "Stream Error" title for better error messages - Fix GhosttyTerminal binary frame parsing: handle server's binary ArrayBuffer control frames (ready/exit/error) - Enable WebSocket proxying in Vite dev server with ws: true - Set TERM=xterm-256color default for TTY processes so tools like tmux, vim, htop work out of the box - Remove orange gradient background from terminal container for cleaner look - Remove orange left border from selected process list items - Update inspector CSS with new process/terminal styles Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
c3a95c3611
commit
6dbc871db9
31 changed files with 6881 additions and 207 deletions
|
|
@ -17,8 +17,8 @@
|
|||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"acp-http-client": "workspace:*",
|
||||
"@sandbox-agent/cli-shared": "workspace:*"
|
||||
"@sandbox-agent/cli-shared": "workspace:*",
|
||||
"acp-http-client": "workspace:*"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
|
@ -34,10 +34,12 @@
|
|||
},
|
||||
"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"
|
||||
"vitest": "^3.0.0",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@sandbox-agent/cli": "workspace:*"
|
||||
|
|
|
|||
|
|
@ -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<ProcessLogsQuery, "follow">;
|
||||
|
||||
export interface AgentQueryOptions {
|
||||
config?: boolean;
|
||||
noCache?: boolean;
|
||||
}
|
||||
|
||||
export interface ProcessLogSubscription {
|
||||
close(): void;
|
||||
closed: Promise<void>;
|
||||
}
|
||||
|
||||
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<AgentListResponse> {
|
||||
async listAgents(options?: AgentQueryOptions): Promise<AgentListResponse> {
|
||||
return this.requestJson("GET", `${API_PREFIX}/agents`, {
|
||||
query: options?.config ? { config: "true" } : undefined,
|
||||
query: toAgentQuery(options),
|
||||
});
|
||||
}
|
||||
|
||||
async getAgent(agent: string, options?: { config?: boolean }): Promise<AgentInfo> {
|
||||
async getAgent(agent: string, options?: AgentQueryOptions): Promise<AgentInfo> {
|
||||
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<ProcessConfig> {
|
||||
return this.requestJson("GET", `${API_PREFIX}/processes/config`);
|
||||
}
|
||||
|
||||
async setProcessConfig(config: ProcessConfig): Promise<ProcessConfig> {
|
||||
return this.requestJson("POST", `${API_PREFIX}/processes/config`, {
|
||||
body: config,
|
||||
});
|
||||
}
|
||||
|
||||
async createProcess(request: ProcessCreateRequest): Promise<ProcessInfo> {
|
||||
return this.requestJson("POST", `${API_PREFIX}/processes`, {
|
||||
body: request,
|
||||
});
|
||||
}
|
||||
|
||||
async runProcess(request: ProcessRunRequest): Promise<ProcessRunResponse> {
|
||||
return this.requestJson("POST", `${API_PREFIX}/processes/run`, {
|
||||
body: request,
|
||||
});
|
||||
}
|
||||
|
||||
async listProcesses(): Promise<ProcessListResponse> {
|
||||
return this.requestJson("GET", `${API_PREFIX}/processes`);
|
||||
}
|
||||
|
||||
async getProcess(id: string): Promise<ProcessInfo> {
|
||||
return this.requestJson("GET", `${API_PREFIX}/processes/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
async stopProcess(id: string, query?: ProcessSignalQuery): Promise<ProcessInfo> {
|
||||
return this.requestJson("POST", `${API_PREFIX}/processes/${encodeURIComponent(id)}/stop`, {
|
||||
query,
|
||||
});
|
||||
}
|
||||
|
||||
async killProcess(id: string, query?: ProcessSignalQuery): Promise<ProcessInfo> {
|
||||
return this.requestJson("POST", `${API_PREFIX}/processes/${encodeURIComponent(id)}/kill`, {
|
||||
query,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteProcess(id: string): Promise<void> {
|
||||
await this.requestRaw("DELETE", `${API_PREFIX}/processes/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
async getProcessLogs(id: string, query: ProcessLogFollowQuery = {}): Promise<ProcessLogsResponse> {
|
||||
return this.requestJson("GET", `${API_PREFIX}/processes/${encodeURIComponent(id)}/logs`, {
|
||||
query,
|
||||
});
|
||||
}
|
||||
|
||||
async followProcessLogs(
|
||||
id: string,
|
||||
listener: ProcessLogListener,
|
||||
query: ProcessLogFollowQuery = {},
|
||||
): Promise<ProcessLogSubscription> {
|
||||
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<ProcessInputResponse> {
|
||||
return this.requestJson("POST", `${API_PREFIX}/processes/${encodeURIComponent(id)}/input`, {
|
||||
body: request,
|
||||
});
|
||||
}
|
||||
|
||||
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 = {},
|
||||
): 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<LiveAcpConnection> {
|
||||
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<string, QueryValue> | undefined {
|
||||
if (!options) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
config: options.config,
|
||||
no_cache: options.noCache,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSessionInit(
|
||||
value: Omit<NewSessionRequest, "_meta"> | undefined,
|
||||
): Omit<NewSessionRequest, "_meta"> {
|
||||
|
|
@ -1230,3 +1404,93 @@ async function readProblem(response: Response): Promise<ProblemDetails | undefin
|
|||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function consumeProcessLogSse(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
listener: ProcessLogListener,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, never>;
|
||||
|
|
@ -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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ 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>;
|
||||
|
||||
|
|
@ -31,6 +32,58 @@ 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 interface SessionRecord {
|
||||
id: string;
|
||||
agent: string;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ 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));
|
||||
|
||||
|
|
@ -64,6 +65,107 @@ 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 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;
|
||||
|
|
@ -122,6 +224,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 +242,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 +331,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 +483,186 @@ 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, {
|
||||
WebSocket: WebSocket as unknown as typeof globalThis.WebSocket,
|
||||
});
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue