From a8090302a834dd465207513ea249096e31fcc46d Mon Sep 17 00:00:00 2001 From: Shri Sukhani Date: Fri, 1 May 2026 10:37:18 -0700 Subject: [PATCH 01/11] Fix stale web service packaging --- package.json | 3 ++- src/client.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index a394ab4..537c4cd 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "type": "commonjs", "license": "MIT", "scripts": { - "build": "tsc", + "clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"", + "build": "yarn clean && tsc", "lint": "eslint src/**/*.ts", "prepare": "yarn build", "test": "vitest run", diff --git a/src/client.ts b/src/client.ts index 5325859..cfa6330 100644 --- a/src/client.ts +++ b/src/client.ts @@ -12,7 +12,7 @@ import { HyperAgentService } from "./services/agents/hyper-agent"; import { TeamService } from "./services/team"; import { ComputerActionService } from "./services/computer-action"; import { GeminiComputerUseService } from "./services/agents/gemini-computer-use"; -import { WebService } from "./services/web"; +import { WebService } from "./services/web/index"; import { SandboxesService } from "./services/sandboxes"; import { VolumesService } from "./services/volumes"; From 7b61a663d3b2cea2590b48867b56d35daf15ec53 Mon Sep 17 00:00:00 2001 From: Shri Sukhani Date: Fri, 1 May 2026 10:37:42 -0700 Subject: [PATCH 02/11] Install published declaration type deps --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 537c4cd..dc5301e 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,9 @@ "ai" ], "dependencies": { + "@types/node": "^22.9.1", + "@types/node-fetch": "^2.6.4", + "@types/ws": "^8.18.1", "form-data": "^4.0.1", "node-fetch": "2.7.0", "ws": "^8.19.0", @@ -48,9 +51,6 @@ "zod-to-json-schema": "^3.25.0" }, "devDependencies": { - "@types/node": "^22.9.1", - "@types/node-fetch": "^2.6.4", - "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^8.15.0", "@typescript-eslint/parser": "^8.15.0", "dotenv": "^17.3.1", From 3e5d28d6ba3487dc34cdd92e3e223872cd8f92f3 Mon Sep 17 00:00:00 2001 From: Shri Sukhani Date: Fri, 1 May 2026 10:38:07 -0700 Subject: [PATCH 03/11] Fix runtime proxy override ports --- src/sandbox/ws.ts | 2 +- tests/sandbox/e2e/runtime-transport.test.ts | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/sandbox/ws.ts b/src/sandbox/ws.ts index 61f83d2..590b5c5 100644 --- a/src/sandbox/ws.ts +++ b/src/sandbox/ws.ts @@ -158,7 +158,7 @@ export const resolveRuntimeTransportTarget = ( url.username = override.username; url.password = override.password; url.hostname = override.hostname; - url.port = override.port || url.port; + url.port = override.port; return { url: url.toString(), diff --git a/tests/sandbox/e2e/runtime-transport.test.ts b/tests/sandbox/e2e/runtime-transport.test.ts index 94be667..a1c0ecc 100644 --- a/tests/sandbox/e2e/runtime-transport.test.ts +++ b/tests/sandbox/e2e/runtime-transport.test.ts @@ -52,6 +52,19 @@ describe("sandbox runtime transport target", () => { }); }); + test("clears the runtime port when the proxy override has no explicit port", () => { + const target = resolveRuntimeTransportTarget( + "https://session.example.dev:8443", + "/sandbox/exec?foo=bar", + "http://127.0.0.1" + ); + + expect(target).toEqual({ + url: "http://127.0.0.1/sandbox/exec?foo=bar", + hostHeader: "session.example.dev:8443", + }); + }); + test("applies the explicit override to websocket targets", () => { const target = toWebSocketUrl( "https://session.example.dev:8443", From d537e33397e8661ca67064398aed5ab18fe96518 Mon Sep 17 00:00:00 2001 From: Shri Sukhani Date: Fri, 1 May 2026 10:39:08 -0700 Subject: [PATCH 04/11] Refresh websocket auth on 401 --- src/sandbox/files.ts | 49 ++++++++++++------ src/sandbox/terminal.ts | 55 ++++++++++++-------- src/services/sandboxes.ts | 8 +-- tests/sandbox/e2e/sandbox-contract.test.ts | 60 ++++++++++++++++++++++ 4 files changed, 132 insertions(+), 40 deletions(-) diff --git a/src/sandbox/files.ts b/src/sandbox/files.ts index 81858b1..b1c7613 100644 --- a/src/sandbox/files.ts +++ b/src/sandbox/files.ts @@ -248,7 +248,7 @@ const encodeWriteData = async ( class RuntimeFileWatchHandle { constructor( private readonly transport: RuntimeTransport, - private readonly getConnectionInfo: () => Promise, + private readonly getConnectionInfo: (forceRefresh?: boolean) => Promise, private readonly status: RawFileWatchStatus, private readonly runtimeProxyOverride?: string ) {} @@ -271,23 +271,40 @@ class RuntimeFileWatchHandle { } async *events(cursor?: number): AsyncGenerator { - const connectionInfo = await this.getConnectionInfo(); - const target = toWebSocketUrl( - connectionInfo.baseUrl, - `/sandbox/files/watch/${this.status.id}/ws?sessionId=${encodeURIComponent( - connectionInfo.sandboxId - )}${cursor !== undefined ? `&cursor=${encodeURIComponent(String(cursor))}` : ""}`, - this.runtimeProxyOverride - ); + const buildTarget = async (forceRefresh: boolean = false) => { + const connectionInfo = await this.getConnectionInfo(forceRefresh); + const target = toWebSocketUrl( + connectionInfo.baseUrl, + `/sandbox/files/watch/${this.status.id}/ws?sessionId=${encodeURIComponent( + connectionInfo.sandboxId + )}${cursor !== undefined ? `&cursor=${encodeURIComponent(String(cursor))}` : ""}`, + this.runtimeProxyOverride + ); + + const headers: Record = { + Authorization: `Bearer ${connectionInfo.token}`, + }; + if (target.hostHeader) { + headers.Host = target.hostHeader; + } - const headers: Record = { - Authorization: `Bearer ${connectionInfo.token}`, + return { target, headers }; + }; + + const openSocket = async () => { + const { target, headers } = await buildTarget(); + try { + return await openRuntimeWebSocket(target, headers); + } catch (error) { + if (error instanceof HyperbrowserError && error.statusCode === 401) { + const refreshed = await buildTarget(true); + return openRuntimeWebSocket(refreshed.target, refreshed.headers); + } + throw error; + } }; - if (target.hostHeader) { - headers.Host = target.hostHeader; - } - const ws = await openRuntimeWebSocket(target, headers); + const ws = await openSocket(); const queue = new AsyncEventQueue(); ws.on("message", (data) => { @@ -405,7 +422,7 @@ export class SandboxWatchDirHandle { export class SandboxFilesApi { constructor( private readonly transport: RuntimeTransport, - private readonly getConnectionInfo: () => Promise, + private readonly getConnectionInfo: (forceRefresh?: boolean) => Promise, private readonly runtimeProxyOverride?: string, private readonly defaultRunAs?: string ) {} diff --git a/src/sandbox/terminal.ts b/src/sandbox/terminal.ts index 1446679..d7212ce 100644 --- a/src/sandbox/terminal.ts +++ b/src/sandbox/terminal.ts @@ -1,6 +1,7 @@ import WebSocket from "ws"; import { RuntimeTransport } from "./base"; import { AsyncEventQueue, openRuntimeWebSocket, toWebSocketUrl } from "./ws"; +import { HyperbrowserError } from "../client"; import { SandboxTerminalCreateParams, SandboxTerminalEvent, @@ -180,7 +181,7 @@ export class SandboxTerminalConnection { export class SandboxTerminalHandle { constructor( private readonly transport: RuntimeTransport, - private readonly getConnectionInfo: () => Promise, + private readonly getConnectionInfo: (forceRefresh?: boolean) => Promise, private status: SandboxTerminalStatus, private readonly runtimeProxyOverride?: string ) {} @@ -268,27 +269,41 @@ export class SandboxTerminalHandle { } async attach(cursor?: number | string): Promise { - const connectionInfo = await this.getConnectionInfo(); - const query = new URLSearchParams({ - sessionId: connectionInfo.sandboxId, - }); - if (cursor !== undefined) { - query.set("cursor", String(cursor)); - } - const target = toWebSocketUrl( - connectionInfo.baseUrl, - `/sandbox/pty/${this.id}/ws?${query.toString()}`, - this.runtimeProxyOverride - ); + const buildTarget = async (forceRefresh: boolean = false) => { + const connectionInfo = await this.getConnectionInfo(forceRefresh); + const query = new URLSearchParams({ + sessionId: connectionInfo.sandboxId, + }); + if (cursor !== undefined) { + query.set("cursor", String(cursor)); + } + const target = toWebSocketUrl( + connectionInfo.baseUrl, + `/sandbox/pty/${this.id}/ws?${query.toString()}`, + this.runtimeProxyOverride + ); + + const headers: Record = { + Authorization: `Bearer ${connectionInfo.token}`, + }; + if (target.hostHeader) { + headers.Host = target.hostHeader; + } - const headers: Record = { - Authorization: `Bearer ${connectionInfo.token}`, + return { target, headers }; }; - if (target.hostHeader) { - headers.Host = target.hostHeader; - } - const ws = await openRuntimeWebSocket(target, headers); + const { target, headers } = await buildTarget(); + let ws: WebSocket; + try { + ws = await openRuntimeWebSocket(target, headers); + } catch (error) { + if (!(error instanceof HyperbrowserError) || error.statusCode !== 401) { + throw error; + } + const refreshed = await buildTarget(true); + ws = await openRuntimeWebSocket(refreshed.target, refreshed.headers); + } return new SandboxTerminalConnection(ws); } @@ -297,7 +312,7 @@ export class SandboxTerminalHandle { export class SandboxTerminalApi { constructor( private readonly transport: RuntimeTransport, - private readonly getConnectionInfo: () => Promise, + private readonly getConnectionInfo: (forceRefresh?: boolean) => Promise, private readonly runtimeProxyOverride?: string ) {} diff --git a/src/services/sandboxes.ts b/src/services/sandboxes.ts index 826b4c8..6e61546 100644 --- a/src/services/sandboxes.ts +++ b/src/services/sandboxes.ts @@ -204,12 +204,12 @@ export class SandboxHandle { this.processes = new SandboxProcessesApi(this.transport); this.files = new SandboxFilesApi( this.transport, - () => this.resolveRuntimeSocketConnectionInfo(), + (forceRefresh) => this.resolveRuntimeSocketConnectionInfo(forceRefresh), service.runtimeProxyOverride ); this.terminal = new SandboxTerminalApi( this.transport, - () => this.resolveRuntimeSocketConnectionInfo(), + (forceRefresh) => this.resolveRuntimeSocketConnectionInfo(forceRefresh), service.runtimeProxyOverride ); this.pty = this.terminal; @@ -342,12 +342,12 @@ export class SandboxHandle { }; } - private async resolveRuntimeSocketConnectionInfo(): Promise<{ + private async resolveRuntimeSocketConnectionInfo(forceRefresh: boolean = false): Promise<{ sandboxId: string; baseUrl: string; token: string; }> { - const session = await this.ensureRuntimeSession(); + const session = await this.ensureRuntimeSession(forceRefresh); return { sandboxId: this.id, baseUrl: session.runtime.baseUrl, diff --git a/tests/sandbox/e2e/sandbox-contract.test.ts b/tests/sandbox/e2e/sandbox-contract.test.ts index c1e6541..9c3edd7 100644 --- a/tests/sandbox/e2e/sandbox-contract.test.ts +++ b/tests/sandbox/e2e/sandbox-contract.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test, vi, afterEach } from "vitest"; import { SandboxFilesApi } from "../../../src/sandbox/files"; import { SandboxTerminalHandle } from "../../../src/sandbox/terminal"; import * as wsModule from "../../../src/sandbox/ws"; +import { HyperbrowserError } from "../../../src/client"; import { SandboxesService } from "../../../src/services/sandboxes"; import type { SandboxExposeResult } from "../../../src/types"; @@ -307,4 +308,63 @@ describe("sandbox control and runtime contract", () => { undefined ); }); + + test("terminal attach refreshes runtime auth once after a 401 handshake", async () => { + const openRuntimeWebSocketSpy = vi + .spyOn(wsModule, "openRuntimeWebSocket") + .mockRejectedValueOnce( + new HyperbrowserError("expired runtime token", { + statusCode: 401, + service: "runtime", + }) + ) + .mockResolvedValueOnce({ + on: vi.fn(), + once: vi.fn(), + close: vi.fn(), + send: vi.fn(), + readyState: 1, + } as any); + const getConnectionInfo = vi + .fn() + .mockResolvedValueOnce({ + sandboxId: "sbx_123", + baseUrl: "https://runtime.example.com/sandbox/sbx_123", + token: "old-token", + }) + .mockResolvedValueOnce({ + sandboxId: "sbx_123", + baseUrl: "https://runtime.example.com/sandbox/sbx_123", + token: "new-token", + }); + + const terminal = new SandboxTerminalHandle( + {} as any, + getConnectionInfo, + { + id: "pty_123", + command: "bash", + cwd: "/", + running: true, + rows: 24, + cols: 80, + startedAt: Date.now(), + } + ); + + await terminal.attach(); + + expect(getConnectionInfo).toHaveBeenNthCalledWith(1, false); + expect(getConnectionInfo).toHaveBeenNthCalledWith(2, true); + expect(openRuntimeWebSocketSpy).toHaveBeenNthCalledWith( + 1, + expect.any(Object), + expect.objectContaining({ Authorization: "Bearer old-token" }) + ); + expect(openRuntimeWebSocketSpy).toHaveBeenNthCalledWith( + 2, + expect.any(Object), + expect.objectContaining({ Authorization: "Bearer new-token" }) + ); + }); }); From e325c6fda8000104b158cb8c912250bde61d8a6e Mon Sep 17 00:00:00 2001 From: Shri Sukhani Date: Fri, 1 May 2026 10:39:57 -0700 Subject: [PATCH 05/11] Add websocket handshake timeout --- src/sandbox/files.ts | 16 +++++--- src/sandbox/terminal.ts | 16 +++++--- src/sandbox/ws.ts | 5 ++- src/services/sandboxes.ts | 7 +++- tests/sandbox/e2e/sandbox-contract.test.ts | 44 +++++++++++++++++++++- 5 files changed, 70 insertions(+), 18 deletions(-) diff --git a/src/sandbox/files.ts b/src/sandbox/files.ts index b1c7613..2a8e2ef 100644 --- a/src/sandbox/files.ts +++ b/src/sandbox/files.ts @@ -250,7 +250,8 @@ class RuntimeFileWatchHandle { private readonly transport: RuntimeTransport, private readonly getConnectionInfo: (forceRefresh?: boolean) => Promise, private readonly status: RawFileWatchStatus, - private readonly runtimeProxyOverride?: string + private readonly runtimeProxyOverride?: string, + private readonly webSocketTimeout?: number ) {} get id(): string { @@ -294,11 +295,11 @@ class RuntimeFileWatchHandle { const openSocket = async () => { const { target, headers } = await buildTarget(); try { - return await openRuntimeWebSocket(target, headers); + return await openRuntimeWebSocket(target, headers, this.webSocketTimeout); } catch (error) { if (error instanceof HyperbrowserError && error.statusCode === 401) { const refreshed = await buildTarget(true); - return openRuntimeWebSocket(refreshed.target, refreshed.headers); + return openRuntimeWebSocket(refreshed.target, refreshed.headers, this.webSocketTimeout); } throw error; } @@ -424,7 +425,8 @@ export class SandboxFilesApi { private readonly transport: RuntimeTransport, private readonly getConnectionInfo: (forceRefresh?: boolean) => Promise, private readonly runtimeProxyOverride?: string, - private readonly defaultRunAs?: string + private readonly defaultRunAs?: string, + private readonly webSocketTimeout?: number ) {} withRunAs(runAs?: string): SandboxFilesApi { @@ -433,7 +435,8 @@ export class SandboxFilesApi { this.transport, this.getConnectionInfo, this.runtimeProxyOverride, - normalized ? normalized : undefined + normalized ? normalized : undefined, + this.webSocketTimeout ); } @@ -733,7 +736,8 @@ export class SandboxFilesApi { this.transport, this.getConnectionInfo, response.watch, - this.runtimeProxyOverride + this.runtimeProxyOverride, + this.webSocketTimeout ); return new SandboxWatchDirHandle(watch, onEvent, options.onExit, options.timeoutMs); diff --git a/src/sandbox/terminal.ts b/src/sandbox/terminal.ts index d7212ce..b6d9f42 100644 --- a/src/sandbox/terminal.ts +++ b/src/sandbox/terminal.ts @@ -183,7 +183,8 @@ export class SandboxTerminalHandle { private readonly transport: RuntimeTransport, private readonly getConnectionInfo: (forceRefresh?: boolean) => Promise, private status: SandboxTerminalStatus, - private readonly runtimeProxyOverride?: string + private readonly runtimeProxyOverride?: string, + private readonly webSocketTimeout?: number ) {} get id(): string { @@ -296,13 +297,13 @@ export class SandboxTerminalHandle { const { target, headers } = await buildTarget(); let ws: WebSocket; try { - ws = await openRuntimeWebSocket(target, headers); + ws = await openRuntimeWebSocket(target, headers, this.webSocketTimeout); } catch (error) { if (!(error instanceof HyperbrowserError) || error.statusCode !== 401) { throw error; } const refreshed = await buildTarget(true); - ws = await openRuntimeWebSocket(refreshed.target, refreshed.headers); + ws = await openRuntimeWebSocket(refreshed.target, refreshed.headers, this.webSocketTimeout); } return new SandboxTerminalConnection(ws); @@ -313,7 +314,8 @@ export class SandboxTerminalApi { constructor( private readonly transport: RuntimeTransport, private readonly getConnectionInfo: (forceRefresh?: boolean) => Promise, - private readonly runtimeProxyOverride?: string + private readonly runtimeProxyOverride?: string, + private readonly webSocketTimeout?: number ) {} async create(params: SandboxTerminalCreateParams): Promise { @@ -329,7 +331,8 @@ export class SandboxTerminalApi { this.transport, this.getConnectionInfo, normalizeTerminalStatus(response.pty), - this.runtimeProxyOverride + this.runtimeProxyOverride, + this.webSocketTimeout ); } @@ -344,7 +347,8 @@ export class SandboxTerminalApi { this.transport, this.getConnectionInfo, normalizeTerminalStatus(response.pty), - this.runtimeProxyOverride + this.runtimeProxyOverride, + this.webSocketTimeout ); } } diff --git a/src/sandbox/ws.ts b/src/sandbox/ws.ts index 590b5c5..f766d33 100644 --- a/src/sandbox/ws.ts +++ b/src/sandbox/ws.ts @@ -252,12 +252,13 @@ const buildHandshakeError = async (response: IncomingMessage): Promise + headers: Record, + timeout: number = 30000 ): Promise => new Promise((resolve, reject) => { let settled = false; - const socket = new WebSocket(target.url, { headers }); + const socket = new WebSocket(target.url, { headers, handshakeTimeout: timeout }); const rejectOnce = (error: unknown) => { if (settled) { diff --git a/src/services/sandboxes.ts b/src/services/sandboxes.ts index 6e61546..15de0bc 100644 --- a/src/services/sandboxes.ts +++ b/src/services/sandboxes.ts @@ -205,12 +205,15 @@ export class SandboxHandle { this.files = new SandboxFilesApi( this.transport, (forceRefresh) => this.resolveRuntimeSocketConnectionInfo(forceRefresh), - service.runtimeProxyOverride + service.runtimeProxyOverride, + undefined, + service.runtimeTimeout ); this.terminal = new SandboxTerminalApi( this.transport, (forceRefresh) => this.resolveRuntimeSocketConnectionInfo(forceRefresh), - service.runtimeProxyOverride + service.runtimeProxyOverride, + service.runtimeTimeout ); this.pty = this.terminal; } diff --git a/tests/sandbox/e2e/sandbox-contract.test.ts b/tests/sandbox/e2e/sandbox-contract.test.ts index 9c3edd7..2a882f0 100644 --- a/tests/sandbox/e2e/sandbox-contract.test.ts +++ b/tests/sandbox/e2e/sandbox-contract.test.ts @@ -359,12 +359,52 @@ describe("sandbox control and runtime contract", () => { expect(openRuntimeWebSocketSpy).toHaveBeenNthCalledWith( 1, expect.any(Object), - expect.objectContaining({ Authorization: "Bearer old-token" }) + expect.objectContaining({ Authorization: "Bearer old-token" }), + undefined ); expect(openRuntimeWebSocketSpy).toHaveBeenNthCalledWith( 2, expect.any(Object), - expect.objectContaining({ Authorization: "Bearer new-token" }) + expect.objectContaining({ Authorization: "Bearer new-token" }), + undefined + ); + }); + + test("terminal attach forwards the websocket handshake timeout", async () => { + const openRuntimeWebSocketSpy = vi.spyOn(wsModule, "openRuntimeWebSocket").mockResolvedValue({ + on: vi.fn(), + once: vi.fn(), + close: vi.fn(), + send: vi.fn(), + readyState: 1, + } as any); + + const terminal = new SandboxTerminalHandle( + {} as any, + async () => ({ + sandboxId: "sbx_123", + baseUrl: "https://runtime.example.com/sandbox/sbx_123", + token: "runtime-token", + }), + { + id: "pty_123", + command: "bash", + cwd: "/", + running: true, + rows: 24, + cols: 80, + startedAt: Date.now(), + }, + undefined, + 12_345 + ); + + await terminal.attach(); + + expect(openRuntimeWebSocketSpy).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ Authorization: "Bearer runtime-token" }), + 12_345 ); }); }); From 34c84c3c264461c8cf1f68850fc9af1f1a111082 Mon Sep 17 00:00:00 2001 From: Shri Sukhani Date: Fri, 1 May 2026 10:40:26 -0700 Subject: [PATCH 06/11] Handle empty JSON responses --- src/sandbox/base.ts | 5 +++-- src/services/base.ts | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/sandbox/base.ts b/src/sandbox/base.ts index b1b020a..465e773 100644 --- a/src/sandbox/base.ts +++ b/src/sandbox/base.ts @@ -52,12 +52,13 @@ export class RuntimeTransport { async requestJSON(path: string, init?: RequestInit, params?: RuntimeParams): Promise { const response = await this.fetchWithAuth(path, init, params); - if (response.headers.get("content-length") === "0") { + const responseText = await response.text(); + if (!responseText) { return {} as T; } try { - return (await response.json()) as T; + return JSON.parse(responseText) as T; } catch { throw new HyperbrowserError("Failed to parse JSON response", { statusCode: response.status, diff --git a/src/services/base.ts b/src/services/base.ts index f455424..bac8d66 100644 --- a/src/services/base.ts +++ b/src/services/base.ts @@ -97,12 +97,13 @@ export class BaseService { }); } - if (response.headers.get("content-length") === "0") { + const responseText = await response.text(); + if (!responseText) { return {} as T; } try { - return (await response.json()) as T; + return JSON.parse(responseText) as T; } catch { throw new HyperbrowserError("Failed to parse JSON response", { statusCode: response.status, From 614b81e949c11c621c27a8f80123ee2075ea7344 Mon Sep 17 00:00:00 2001 From: Shri Sukhani Date: Fri, 1 May 2026 10:40:58 -0700 Subject: [PATCH 07/11] Avoid uncaught upload stream errors --- src/services/sessions.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/services/sessions.ts b/src/services/sessions.ts index e8c7fe3..ef88ce0 100644 --- a/src/services/sessions.ts +++ b/src/services/sessions.ts @@ -237,13 +237,6 @@ export class SessionsService extends BaseService { const fileStream = createReadStream(fileInput); const fileBaseName = fileName || path.basename(fileInput); - fileStream.on("error", (error) => { - throw new HyperbrowserError( - `Failed to read file ${fileInput}: ${error.message}`, - undefined - ); - }); - formData.append("file", fileStream, { filename: fileBaseName, }); From 58bab23a84f0d777893f2cb0d8f908e55524a4e4 Mon Sep 17 00:00:00 2001 From: Shri Sukhani Date: Fri, 1 May 2026 10:41:35 -0700 Subject: [PATCH 08/11] Treat stopped jobs as terminal --- src/services/crawl.ts | 2 +- src/services/extract.ts | 2 +- src/services/scrape.ts | 4 ++-- src/services/web/batch-fetch.ts | 2 +- src/services/web/crawl.ts | 2 +- src/types/constants.ts | 6 +++--- src/types/web/batch-fetch.ts | 2 +- src/types/web/crawl.ts | 2 +- 8 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/services/crawl.ts b/src/services/crawl.ts index 9990012..d3443ca 100644 --- a/src/services/crawl.ts +++ b/src/services/crawl.ts @@ -83,7 +83,7 @@ export class CrawlService extends BaseService { while (true) { try { const { status } = await this.getStatus(jobId); - if (status === "completed" || status === "failed") { + if (status === "completed" || status === "failed" || status === "stopped") { jobStatus = status; break; } diff --git a/src/services/extract.ts b/src/services/extract.ts index 050ed1b..b382c68 100644 --- a/src/services/extract.ts +++ b/src/services/extract.ts @@ -87,7 +87,7 @@ export class ExtractService extends BaseService { while (true) { try { const { status } = await this.getStatus(jobId); - if (status === "completed" || status === "failed") { + if (status === "completed" || status === "failed" || status === "stopped") { return await this.get(jobId); } failures = 0; diff --git a/src/services/scrape.ts b/src/services/scrape.ts index 9d50ed7..aba91eb 100644 --- a/src/services/scrape.ts +++ b/src/services/scrape.ts @@ -87,7 +87,7 @@ export class BatchScrapeService extends BaseService { while (true) { try { const { status } = await this.getStatus(jobId); - if (status === "completed" || status === "failed") { + if (status === "completed" || status === "failed" || status === "stopped") { jobStatus = status; break; } @@ -236,7 +236,7 @@ export class ScrapeService extends BaseService { while (true) { try { const { status } = await this.getStatus(jobId); - if (status === "completed" || status === "failed") { + if (status === "completed" || status === "failed" || status === "stopped") { return await this.get(jobId); } failures = 0; diff --git a/src/services/web/batch-fetch.ts b/src/services/web/batch-fetch.ts index c70f800..8eb7d84 100644 --- a/src/services/web/batch-fetch.ts +++ b/src/services/web/batch-fetch.ts @@ -105,7 +105,7 @@ export class BatchFetchService extends BaseService { while (true) { try { const { status } = await this.getStatus(jobId); - if (status === "completed" || status === "failed") { + if (status === "completed" || status === "failed" || status === "stopped") { jobStatus = status; break; } diff --git a/src/services/web/crawl.ts b/src/services/web/crawl.ts index a2c7ecf..a5072b7 100644 --- a/src/services/web/crawl.ts +++ b/src/services/web/crawl.ts @@ -105,7 +105,7 @@ export class WebCrawlService extends BaseService { while (true) { try { const { status } = await this.getStatus(jobId); - if (status === "completed" || status === "failed") { + if (status === "completed" || status === "failed" || status === "stopped") { jobStatus = status; break; } diff --git a/src/types/constants.ts b/src/types/constants.ts index c9b0f58..885723b 100644 --- a/src/types/constants.ts +++ b/src/types/constants.ts @@ -4,9 +4,9 @@ export type SessionEventLogType = | "captcha_error" | "file_downloaded"; export type ScrapeFormat = "markdown" | "html" | "links" | "screenshot"; -export type ScrapeJobStatus = "pending" | "running" | "completed" | "failed"; -export type ExtractJobStatus = "pending" | "running" | "completed" | "failed"; -export type CrawlJobStatus = "pending" | "running" | "completed" | "failed"; +export type ScrapeJobStatus = "pending" | "running" | "completed" | "failed" | "stopped"; +export type ExtractJobStatus = "pending" | "running" | "completed" | "failed" | "stopped"; +export type CrawlJobStatus = "pending" | "running" | "completed" | "failed" | "stopped"; export type BrowserUseTaskStatus = "pending" | "running" | "completed" | "failed" | "stopped"; export type CuaTaskStatus = "pending" | "running" | "completed" | "failed" | "stopped"; export type HyperAgentTaskStatus = "pending" | "running" | "completed" | "failed" | "stopped"; diff --git a/src/types/web/batch-fetch.ts b/src/types/web/batch-fetch.ts index 28c8ea4..9a4ee06 100644 --- a/src/types/web/batch-fetch.ts +++ b/src/types/web/batch-fetch.ts @@ -7,7 +7,7 @@ import { PageData, } from "./common"; -export type BatchFetchJobStatus = "pending" | "running" | "completed" | "failed"; +export type BatchFetchJobStatus = "pending" | "running" | "completed" | "failed" | "stopped"; export interface StartBatchFetchJobParams { urls: string[]; diff --git a/src/types/web/crawl.ts b/src/types/web/crawl.ts index 7081da1..97ff4d5 100644 --- a/src/types/web/crawl.ts +++ b/src/types/web/crawl.ts @@ -7,7 +7,7 @@ import { PageData, } from "./common"; -export type WebCrawlJobStatus = "pending" | "running" | "completed" | "failed"; +export type WebCrawlJobStatus = "pending" | "running" | "completed" | "failed" | "stopped"; export interface WebCrawlOptions { maxPages?: number; From 89536fc56c2e43f83f2298692547f6dc92e72d0e Mon Sep 17 00:00:00 2001 From: Shri Sukhani Date: Fri, 1 May 2026 10:41:53 -0700 Subject: [PATCH 09/11] Use POSIX paths for file watch events --- src/sandbox/files.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sandbox/files.ts b/src/sandbox/files.ts index 2a8e2ef..82c4c43 100644 --- a/src/sandbox/files.ts +++ b/src/sandbox/files.ts @@ -1,5 +1,5 @@ import { Blob, Buffer } from "buffer"; -import nodePath from "path"; +import nodePath from "node:path/posix"; import { ReadableStream } from "node:stream/web"; import WebSocket from "ws"; import { HyperbrowserError } from "../client"; From 34d8e2bbea57e55834111d4160809fc9030d4cab Mon Sep 17 00:00:00 2001 From: Shri Sukhani Date: Fri, 1 May 2026 10:42:26 -0700 Subject: [PATCH 10/11] Add test typecheck gate --- .github/workflows/publish-npm.yml | 3 +++ package.json | 1 + tests/sandbox/e2e/process-api.test.ts | 7 ++++++- tests/sandbox/e2e/sandbox-contract.test.ts | 6 ++++-- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 0c3d5fa..7f5295c 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -66,6 +66,9 @@ jobs: - name: Install dependencies run: yarn install --frozen-lockfile + - name: Typecheck tests + run: yarn typecheck:tests + - name: Build package run: yarn build diff --git a/package.json b/package.json index dc5301e..b5b8c54 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "scripts": { "clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"", "build": "yarn clean && tsc", + "typecheck:tests": "tsc -p tsconfig.tests.json --noEmit", "lint": "eslint src/**/*.ts", "prepare": "yarn build", "test": "vitest run", diff --git a/tests/sandbox/e2e/process-api.test.ts b/tests/sandbox/e2e/process-api.test.ts index a4ede9f..7b79274 100644 --- a/tests/sandbox/e2e/process-api.test.ts +++ b/tests/sandbox/e2e/process-api.test.ts @@ -143,8 +143,13 @@ describe("sandbox process api", () => { test("sandbox handle exec forwards string options to processes.exec", async () => { const exec = vi.fn().mockResolvedValue(execResponse.result); + const execMethod = SandboxHandle.prototype.exec as unknown as ( + this: { processes: { exec: typeof exec } }, + input: string, + options?: { runAs: string } + ) => Promise; - await SandboxHandle.prototype.exec.call( + await execMethod.call( { processes: { exec }, }, diff --git a/tests/sandbox/e2e/sandbox-contract.test.ts b/tests/sandbox/e2e/sandbox-contract.test.ts index 2a882f0..823d748 100644 --- a/tests/sandbox/e2e/sandbox-contract.test.ts +++ b/tests/sandbox/e2e/sandbox-contract.test.ts @@ -80,7 +80,8 @@ describe("sandbox control and runtime contract", () => { method: "POST", }) ); - expect(JSON.parse(requestSpy.mock.calls[0][1].body)).toEqual({ + const createRequest = requestSpy.mock.calls[0][1] as { body: string }; + expect(JSON.parse(createRequest.body)).toEqual({ imageName: "node", exposedPorts: [{ port: 3000, auth: true }], mounts: { @@ -123,7 +124,8 @@ describe("sandbox control and runtime contract", () => { method: "POST", }) ); - expect(JSON.parse(requestSpy.mock.calls[0][1].body)).toEqual({ + const createRequest = requestSpy.mock.calls[0][1] as { body: string }; + expect(JSON.parse(createRequest.body)).toEqual({ snapshotName: "snapshot-1", mounts: { "/workspace/readonly": { From 033eb083f7d40567c4dc280ae6df47906b5ae17b Mon Sep 17 00:00:00 2001 From: Shri Sukhani Date: Fri, 1 May 2026 11:37:03 -0700 Subject: [PATCH 11/11] Simplify POSIX watch path names --- src/sandbox/files.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sandbox/files.ts b/src/sandbox/files.ts index 82c4c43..3ec9700 100644 --- a/src/sandbox/files.ts +++ b/src/sandbox/files.ts @@ -158,7 +158,7 @@ const relativeWatchName = (root: string, absolutePath: string): string => { if (!relative || relative === ".") { return nodePath.basename(absolutePath); } - return relative.split(nodePath.sep).join("/"); + return relative; }; const isReadableStreamLike = (value: SandboxFileWriteData): value is ReadableStream => {