From 522dd2e19f0ee1f834f93d50c602e4faf8dd3ad6 Mon Sep 17 00:00:00 2001 From: Jeffrey Tseng Date: Sun, 23 Aug 2026 23:43:37 -0400 Subject: [PATCH 1/3] feat(client): add cloud runtime client and image --- .dockerignore | 13 ++ .github/workflows/runtime-image.yml | 65 ++++++++ Dockerfile.runtime | 27 ++++ docs/cloud-runtime.md | 50 ++++++ packages/client/README.md | 46 ++++++ packages/client/package.json | 4 + packages/client/src/cloud.ts | 237 ++++++++++++++++++++++++++++ packages/client/test/cloud.test.ts | 149 +++++++++++++++++ 8 files changed, 591 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/runtime-image.yml create mode 100644 Dockerfile.runtime create mode 100644 docs/cloud-runtime.md create mode 100644 packages/client/src/cloud.ts create mode 100644 packages/client/test/cloud.test.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..017a93543 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.github +.artifacts +.npm +coverage +node_modules +packages/*/dist +packages/*/dist-chrome +packages/*/dist-firefox +packages/ai/src/providers/data +packages/coding-agent/binaries +*.log +*.tsbuildinfo diff --git a/.github/workflows/runtime-image.yml b/.github/workflows/runtime-image.yml new file mode 100644 index 000000000..1e76b4d66 --- /dev/null +++ b/.github/workflows/runtime-image.yml @@ -0,0 +1,65 @@ +name: Runtime Image + +on: + push: + branches: [main] + paths: + - '.github/workflows/runtime-image.yml' + - '.dockerignore' + - 'Dockerfile.runtime' + - 'package.json' + - 'package-lock.json' + - 'packages/**' + - 'scripts/**' + pull_request: + branches: [main] + paths: + - '.github/workflows/runtime-image.yml' + - '.dockerignore' + - 'Dockerfile.runtime' + - 'package.json' + - 'package-lock.json' + - 'packages/**' + - 'scripts/**' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: runtime-image-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + + - name: Log in to GitHub Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and publish immutable runtime image + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + file: Dockerfile.runtime + platforms: linux/amd64 + push: ${{ github.event_name != 'pull_request' }} + tags: ghcr.io/diffexai/diffex-runtime:sha-${{ github.sha }} + cache-from: type=gha,scope=diffex-runtime + cache-to: type=gha,mode=max,scope=diffex-runtime + provenance: mode=max + sbom: true diff --git a/Dockerfile.runtime b/Dockerfile.runtime new file mode 100644 index 000000000..1b95664a1 --- /dev/null +++ b/Dockerfile.runtime @@ -0,0 +1,27 @@ +FROM node:24-bookworm-slim AS build + +WORKDIR /opt/diffex + +COPY package.json package-lock.json ./ +COPY packages ./packages +COPY scripts ./scripts + +RUN npm ci --ignore-scripts +RUN npm run build +RUN npm prune --omit=dev --ignore-scripts + +FROM node:24-bookworm-slim AS runtime + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates fd-find git openssh-client ripgrep \ + && ln -s /usr/bin/fdfind /usr/local/bin/fd \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=build /opt/diffex /opt/diffex + +RUN ln -s /opt/diffex/packages/coding-agent-server/dist/cli.js /usr/local/bin/diffex-runtime-server + +WORKDIR /workspace + +LABEL org.opencontainers.image.source="https://github.com/diffexai/diffex" +LABEL org.opencontainers.image.description="Pi-based hosted Diffex coding-agent runtime" diff --git a/docs/cloud-runtime.md b/docs/cloud-runtime.md new file mode 100644 index 000000000..99dd4f07a --- /dev/null +++ b/docs/cloud-runtime.md @@ -0,0 +1,50 @@ +# Cloud Runtime Boundary + +## Purpose + +The hosted Diffex product runs the same Pi-derived coding-agent runtime as local Diffex. +It does not use the archived Rust implementation or a cloud-specific prompt loop. + +`@earendil-works/diffex-server` binds the coding-agent runtime to `@earendil-works/diffex-protocol` inside an isolated sandbox. +`@earendil-works/diffex-client` connects to that server through a transport supplied by the product. + +## Boundary + +The cloud control plane authenticates users, allocates sandboxes, enforces policy and billing, and issues short-lived attachment URLs. +It authenticates the WebSocket upgrade before relaying any bytes. + +Once connected, the client and in-sandbox server exchange the native length-prefixed CBOR protocol. +The cloud relay preserves byte order, applies backpressure and limits, and does not decode or reinterpret runtime messages. + +The WorkOS access token stays at the control-plane boundary. +Provider credentials, sandbox identifiers, and internal runtime addresses never enter the Diffex wire protocol. + +The production model credential is mounted through Daytona Secrets with a provider-host allowlist. +Only Daytona's opaque placeholder enters the sandbox environment, and the real provider credential is substituted into outbound HTTPS headers outside the sandbox. + +## Runtime Image + +`Dockerfile.runtime` produces the AMD64 image used to create the Daytona sandbox snapshot. +The runtime-image workflow verifies this image on pull requests and publishes an immutable `ghcr.io/diffexai/diffex-runtime:sha-` image after the matching `main` commit passes review. + +Before a cloud release, register GHCR with Daytona, create a Daytona snapshot from that exact immutable image tag or digest, and set `DAYTONA_SANDBOX_SNAPSHOT` to the resulting snapshot name. +Do not use a floating image tag for production. +The runtime image and the cloud control-plane release must be promoted as one tested release pair. + +## Required Hosted Runtime + +The production runtime service must: + +- Implement `DiffexServerService` using the real coding-agent session APIs +- Persist and reopen durable sessions using stable cloud-provided session IDs +- Expose an authenticated WebSocket listener suitable for a sandbox +- Publish authoritative protocol snapshots from coding-agent state +- Convert runtime events through the existing protocol adapters +- Reject conflicting session mutations instead of silently queuing them +- Shut down cleanly when the sandbox receives a termination signal + +## Compatibility + +The REST control-plane API and the live Diffex protocol are independently versioned. +Changing WorkOS, billing, quota, or sandbox-provider implementation must not change the agent protocol. +Changing the agent protocol must use protocol negotiation rather than an implicit cloud API change. diff --git a/packages/client/README.md b/packages/client/README.md index 82bbb26d4..967c31631 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -61,3 +61,49 @@ await client.connect(); `maxPendingBytes` bounds queued outbound data. It defaults to four times the protocol frame limit. The transport preserves send order and waits for socket backpressure before resolving each send. The `@earendil-works/diffex-client` root remains transport- and runtime-neutral. Importing the Node-compatible transport requires the explicit `@earendil-works/diffex-client/unix` subpath. + +## Hosted Diffex cloud + +Use the separately exported cloud client for the authenticated REST lifecycle and native WebSocket attachment: + +```ts +import { DiffexClient } from "@earendil-works/diffex-client"; +import { DiffexCloudClient } from "@earendil-works/diffex-client/cloud"; + +const cloud = new DiffexCloudClient({ + baseUrl: "https://api.diffex.ai", + accessToken: () => tokenStore.currentAccessToken(), +}); +const session = await cloud.createSession(); +const client = await DiffexClient.connect({ + transportFactory: cloud.createTransportFactory(session.session_id), +}); +const lease = await client.acquireSession(session.session_id); +``` + +The access token is sent only to the HTTPS control plane. +Every transport connection obtains a fresh, short-lived attachment URL, and only its scoped one-time ticket appears in the WebSocket URL. + +## WebSocket transport + +Browser and Node runtimes with a global `WebSocket` implementation can use the separately exported transport: + +```ts +import { DiffexClient } from "@earendil-works/diffex-client"; +import { createWebSocketTransportFactory } from "@earendil-works/diffex-client/websocket"; + +const client = new DiffexClient({ + transportFactory: createWebSocketTransportFactory({ + url: async () => { + const response = await fetch("https://api.diffex.ai/v1/cloud/sessions/session-id/attach", { + method: "POST", + headers: { authorization: `Bearer ${accessToken}` }, + }); + const { attachment } = await response.json(); + return attachment.url; + }, + }), +}); +``` + +Resolve a fresh attachment URL for every connection attempt because cloud attachment credentials are short-lived and scoped. diff --git a/packages/client/package.json b/packages/client/package.json index 3c229f284..39de37430 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -18,6 +18,10 @@ "types": "./dist/websocket.d.ts", "import": "./dist/websocket.js" }, + "./cloud": { + "types": "./dist/cloud.d.ts", + "import": "./dist/cloud.js" + }, "./package.json": "./package.json" }, "sideEffects": false, diff --git a/packages/client/src/cloud.ts b/packages/client/src/cloud.ts new file mode 100644 index 000000000..1d6870652 --- /dev/null +++ b/packages/client/src/cloud.ts @@ -0,0 +1,237 @@ +import { PROTOCOL_VERSION } from "@earendil-works/diffex-protocol"; +import type { ByteTransportFactory } from "./transport.ts"; +import { createWebSocketTransportFactory, type WebSocketConstructor } from "./websocket.ts"; + +export type CloudSessionStatus = "creating" | "running" | "stopping" | "stopped" | "failed" | "deleting" | "deleted"; + +export interface CloudSession { + session_id: string; + status: CloudSessionStatus; + created_at: string; + updated_at: string; +} + +export interface CloudRuntimeAttachment { + url: string; + expires_at: string; + protocol_version: number; +} + +interface CloudFetchResponse { + readonly ok: boolean; + readonly status: number; + json(): Promise; +} + +export type CloudFetch = ( + input: string, + init: { + method: string; + headers: Record; + body?: string; + }, +) => Promise; + +export interface DiffexCloudClientOptions { + baseUrl: string | URL; + accessToken: string | (() => string | Promise); + fetch?: CloudFetch; +} + +export interface CloudTransportOptions { + maxPendingBytes?: number; + webSocket?: WebSocketConstructor; +} + +export class DiffexCloudApiError extends Error { + readonly status: number; + readonly code: string; + readonly requestId?: string; + + constructor(input: { status: number; code: string; message: string; requestId?: string }) { + super(input.message); + this.name = "DiffexCloudApiError"; + this.status = input.status; + this.code = input.code; + this.requestId = input.requestId; + } +} + +/** Authenticated control-plane client for hosted Diffex session lifecycle and attachment. */ +export class DiffexCloudClient { + readonly #baseUrl: URL; + readonly #accessToken: DiffexCloudClientOptions["accessToken"]; + readonly #fetch: CloudFetch; + + constructor(options: DiffexCloudClientOptions) { + this.#baseUrl = normalizedBaseUrl(options.baseUrl); + this.#accessToken = options.accessToken; + this.#fetch = options.fetch ?? requiredFetch(); + } + + async createSession(): Promise { + const value = await this.#request("POST", "/v1/cloud/sessions", {}); + return sessionResponse(value); + } + + async listSessions(): Promise { + const value = record(await this.#request("GET", "/v1/cloud/sessions")); + if (!Array.isArray(value.sessions)) throw invalidResponse(); + return value.sessions.map(cloudSession); + } + + async getSession(sessionId: string): Promise { + return sessionResponse(await this.#request("GET", sessionPath(sessionId))); + } + + async createAttachment(sessionId: string): Promise { + const value = record(await this.#request("POST", `${sessionPath(sessionId)}/attach`, {})); + return cloudAttachment(value.attachment); + } + + async stopSession(sessionId: string): Promise { + return sessionResponse(await this.#request("POST", `${sessionPath(sessionId)}/stop`, {})); + } + + async deleteSession(sessionId: string): Promise { + await this.#request("DELETE", sessionPath(sessionId)); + } + + createTransportFactory(sessionId: string, options: CloudTransportOptions = {}): ByteTransportFactory { + assertSessionId(sessionId); + return createWebSocketTransportFactory({ + url: async () => (await this.createAttachment(sessionId)).url, + ...(options.maxPendingBytes === undefined ? {} : { maxPendingBytes: options.maxPendingBytes }), + ...(options.webSocket === undefined ? {} : { webSocket: options.webSocket }), + }); + } + + async #request(method: string, path: string, body?: object): Promise { + const token = await resolveAccessToken(this.#accessToken); + const response = await this.#fetch(new URL(path, this.#baseUrl).toString(), { + method, + headers: { + authorization: `Bearer ${token}`, + accept: "application/json", + ...(body === undefined ? {} : { "content-type": "application/json" }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + if (response.status === 204) return undefined; + const value = await response.json().catch(() => undefined); + if (!response.ok) throw apiError(response.status, value); + return value; + } +} + +function normalizedBaseUrl(input: string | URL): URL { + const value = new URL(input); + if (value.protocol !== "https:" && value.protocol !== "http:") { + throw new TypeError("Diffex cloud baseUrl must use HTTP or HTTPS"); + } + value.pathname = value.pathname.endsWith("/") ? value.pathname : `${value.pathname}/`; + value.search = ""; + value.hash = ""; + return value; +} + +function requiredFetch(): CloudFetch { + const candidate = globalThis.fetch; + if (!candidate) throw new Error("fetch is not available in this runtime"); + return (input, init) => candidate(input, init); +} + +async function resolveAccessToken(input: DiffexCloudClientOptions["accessToken"]): Promise { + const value = typeof input === "function" ? await input() : input; + const token = value.trim(); + if (!token) throw new Error("Diffex cloud access token is empty"); + return token; +} + +function sessionPath(sessionId: string): string { + assertSessionId(sessionId); + return `/v1/cloud/sessions/${encodeURIComponent(sessionId)}`; +} + +function assertSessionId(sessionId: string): void { + if (!sessionId.trim()) throw new TypeError("Diffex cloud session ID is required"); +} + +function sessionResponse(input: unknown): CloudSession { + return cloudSession(record(input).session); +} + +function cloudSession(input: unknown): CloudSession { + const value = record(input); + const sessionId = stringValue(value.session_id); + const status = stringValue(value.status); + const createdAt = isoTimestamp(value.created_at); + const updatedAt = isoTimestamp(value.updated_at); + if (!isCloudSessionStatus(status)) throw invalidResponse(); + return { session_id: sessionId, status, created_at: createdAt, updated_at: updatedAt }; +} + +function cloudAttachment(input: unknown): CloudRuntimeAttachment { + const value = record(input); + const url = stringValue(value.url); + const parsedUrl = new URL(url); + if (parsedUrl.protocol !== "wss:" && parsedUrl.protocol !== "ws:") throw invalidResponse(); + const protocolVersion = value.protocol_version; + if (protocolVersion !== PROTOCOL_VERSION) { + throw new DiffexCloudApiError({ + status: 409, + code: "unsupported_protocol_version", + message: `Diffex cloud requires protocol version ${String(protocolVersion)}; this client supports ${String(PROTOCOL_VERSION)}`, + }); + } + return { + url, + expires_at: isoTimestamp(value.expires_at), + protocol_version: Number(protocolVersion), + }; +} + +function isCloudSessionStatus(value: string): value is CloudSessionStatus { + return ["creating", "running", "stopping", "stopped", "failed", "deleting", "deleted"].includes(value); +} + +function apiError(status: number, input: unknown): DiffexCloudApiError { + const error = optionalRecord(optionalRecord(input)?.error); + return new DiffexCloudApiError({ + status, + code: typeof error?.code === "string" ? error.code : "request_failed", + message: typeof error?.message === "string" ? error.message : `Diffex cloud request failed (${status})`, + ...(typeof error?.requestId === "string" ? { requestId: error.requestId } : {}), + }); +} + +function record(input: unknown): Record { + const value = optionalRecord(input); + if (!value) throw invalidResponse(); + return value; +} + +function optionalRecord(input: unknown): Record | undefined { + return typeof input === "object" && input !== null && !Array.isArray(input) + ? (input as Record) + : undefined; +} + +function stringValue(input: unknown): string { + if (typeof input !== "string" || !input) throw invalidResponse(); + return input; +} + +function isoTimestamp(input: unknown): string { + const value = stringValue(input); + if (Number.isNaN(Date.parse(value))) throw invalidResponse(); + return value; +} + +function invalidResponse(): DiffexCloudApiError { + return new DiffexCloudApiError({ + status: 502, + code: "invalid_response", + message: "Diffex cloud returned an invalid response", + }); +} diff --git a/packages/client/test/cloud.test.ts b/packages/client/test/cloud.test.ts new file mode 100644 index 000000000..c4a5a97d3 --- /dev/null +++ b/packages/client/test/cloud.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, vi } from "vitest"; +import { type CloudFetch, type DiffexCloudApiError, DiffexCloudClient } from "../src/cloud.ts"; + +const SESSION_ID = "11111111-1111-4111-8111-111111111111"; +const SESSION = { + session_id: SESSION_ID, + status: "running", + created_at: "2026-08-23T00:00:00.000Z", + updated_at: "2026-08-23T00:01:00.000Z", +}; + +function response(status: number, value?: unknown) { + return { + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(value), + }; +} + +describe("DiffexCloudClient", () => { + it("uses bearer auth for the complete cloud session lifecycle", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(response(201, { session: SESSION })) + .mockResolvedValueOnce(response(200, { sessions: [SESSION] })) + .mockResolvedValueOnce(response(200, { session: SESSION })) + .mockResolvedValueOnce( + response(200, { + attachment: { + url: `wss://api.diffex.ai/v1/runtime/sessions/${SESSION_ID}?ticket=one-time`, + expires_at: "2026-08-23T00:02:00.000Z", + protocol_version: 1, + }, + }), + ) + .mockResolvedValueOnce(response(200, { session: { ...SESSION, status: "stopped" } })) + .mockResolvedValueOnce(response(204)); + const client = new DiffexCloudClient({ + baseUrl: "https://api.diffex.ai", + accessToken: async () => "access-secret", + fetch, + }); + + await expect(client.createSession()).resolves.toEqual(SESSION); + await expect(client.listSessions()).resolves.toEqual([SESSION]); + await expect(client.getSession(SESSION_ID)).resolves.toEqual(SESSION); + await expect(client.createAttachment(SESSION_ID)).resolves.toMatchObject({ protocol_version: 1 }); + await expect(client.stopSession(SESSION_ID)).resolves.toMatchObject({ status: "stopped" }); + await expect(client.deleteSession(SESSION_ID)).resolves.toBeUndefined(); + + expect(fetch).toHaveBeenCalledTimes(6); + for (const [, init] of fetch.mock.calls) { + expect(init.headers.authorization).toBe("Bearer access-secret"); + } + }); + + it("turns structured API failures into typed errors without leaking credentials", async () => { + const fetch = vi.fn().mockResolvedValue( + response(429, { + error: { code: "active_session_limit", message: "Active session limit reached", requestId: "req_1" }, + }), + ); + const client = new DiffexCloudClient({ + baseUrl: "https://api.diffex.ai", + accessToken: "access-secret", + fetch, + }); + + await expect(client.createSession()).rejects.toEqual( + expect.objectContaining>({ + status: 429, + code: "active_session_limit", + requestId: "req_1", + }), + ); + }); + + it("rejects attachments for a different native protocol version", async () => { + const fetch = vi.fn().mockResolvedValue( + response(200, { + attachment: { + url: `wss://api.diffex.ai/v1/runtime/sessions/${SESSION_ID}?ticket=one-time`, + expires_at: "2026-08-23T00:02:00.000Z", + protocol_version: 2, + }, + }), + ); + const client = new DiffexCloudClient({ baseUrl: "https://api.diffex.ai", accessToken: "token", fetch }); + + await expect(client.createAttachment(SESSION_ID)).rejects.toMatchObject({ + status: 409, + code: "unsupported_protocol_version", + }); + }); + + it("resolves a fresh one-time attachment for each transport connection", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce( + response(200, { + attachment: { + url: "wss://api.diffex.ai/v1/runtime/sessions/first?ticket=first", + expires_at: "2026-08-23T00:02:00.000Z", + protocol_version: 1, + }, + }), + ) + .mockResolvedValueOnce( + response(200, { + attachment: { + url: "wss://api.diffex.ai/v1/runtime/sessions/first?ticket=second", + expires_at: "2026-08-23T00:03:00.000Z", + protocol_version: 1, + }, + }), + ); + const opened: string[] = []; + class FakeWebSocket { + static readonly OPEN = 1; + binaryType = ""; + bufferedAmount = 0; + readyState = FakeWebSocket.OPEN; + readonly #listeners = new Map void>>(); + constructor(url: string | URL) { + opened.push(String(url)); + queueMicrotask(() => { + this.#listeners.get("open")?.forEach((listener) => { + listener(undefined as never); + }); + }); + } + send(): void {} + close(): void {} + addEventListener(type: string, listener: (event: never) => void): void { + this.#listeners.set(type, [...(this.#listeners.get(type) ?? []), listener]); + } + } + const client = new DiffexCloudClient({ baseUrl: "https://api.diffex.ai", accessToken: "token", fetch }); + const factory = client.createTransportFactory("first", { webSocket: FakeWebSocket }); + + (await factory({ onData() {}, onClose() {}, onError() {} })).close(); + (await factory({ onData() {}, onClose() {}, onError() {} })).close(); + + expect(opened).toEqual([ + "wss://api.diffex.ai/v1/runtime/sessions/first?ticket=first", + "wss://api.diffex.ai/v1/runtime/sessions/first?ticket=second", + ]); + }); +}); From e60daa8ed932096dd95deea09a6c6a5244bc4e6b Mon Sep 17 00:00:00 2001 From: Jeffrey Tseng Date: Mon, 24 Aug 2026 19:45:47 -0400 Subject: [PATCH 2/3] fix(client): require secure cloud URLs Reject cleartext cloud API and attachment URLs outside explicit loopback development hosts so bearer credentials and one-time runtime tickets cannot be exposed by configuration drift or a malformed control-plane response. --- packages/client/src/cloud.ts | 21 ++++++++++++++++++--- packages/client/test/cloud.test.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/packages/client/src/cloud.ts b/packages/client/src/cloud.ts index 1d6870652..29935be94 100644 --- a/packages/client/src/cloud.ts +++ b/packages/client/src/cloud.ts @@ -126,8 +126,12 @@ export class DiffexCloudClient { function normalizedBaseUrl(input: string | URL): URL { const value = new URL(input); - if (value.protocol !== "https:" && value.protocol !== "http:") { - throw new TypeError("Diffex cloud baseUrl must use HTTP or HTTPS"); + if ( + value.username || + value.password || + (value.protocol !== "https:" && !(value.protocol === "http:" && isLoopbackHostname(value.hostname))) + ) { + throw new TypeError("Diffex cloud baseUrl must use HTTPS outside localhost"); } value.pathname = value.pathname.endsWith("/") ? value.pathname : `${value.pathname}/`; value.search = ""; @@ -175,7 +179,13 @@ function cloudAttachment(input: unknown): CloudRuntimeAttachment { const value = record(input); const url = stringValue(value.url); const parsedUrl = new URL(url); - if (parsedUrl.protocol !== "wss:" && parsedUrl.protocol !== "ws:") throw invalidResponse(); + if ( + parsedUrl.username || + parsedUrl.password || + (parsedUrl.protocol !== "wss:" && !(parsedUrl.protocol === "ws:" && isLoopbackHostname(parsedUrl.hostname))) + ) { + throw invalidResponse(); + } const protocolVersion = value.protocol_version; if (protocolVersion !== PROTOCOL_VERSION) { throw new DiffexCloudApiError({ @@ -191,6 +201,11 @@ function cloudAttachment(input: unknown): CloudRuntimeAttachment { }; } +function isLoopbackHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase().replace(/^\[(.*)\]$/u, "$1"); + return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1"; +} + function isCloudSessionStatus(value: string): value is CloudSessionStatus { return ["creating", "running", "stopping", "stopped", "failed", "deleting", "deleted"].includes(value); } diff --git a/packages/client/test/cloud.test.ts b/packages/client/test/cloud.test.ts index c4a5a97d3..955bc244c 100644 --- a/packages/client/test/cloud.test.ts +++ b/packages/client/test/cloud.test.ts @@ -93,6 +93,36 @@ describe("DiffexCloudClient", () => { }); }); + it("rejects cleartext cloud credentials and attachment tickets outside localhost", async () => { + expect( + () => + new DiffexCloudClient({ + baseUrl: "http://api.diffex.ai", + accessToken: "access-secret", + }), + ).toThrow(/HTTPS outside localhost/u); + + const fetch = vi.fn().mockResolvedValue( + response(200, { + attachment: { + url: `ws://api.diffex.ai/v1/runtime/sessions/${SESSION_ID}?ticket=one-time`, + expires_at: "2026-08-23T00:02:00.000Z", + protocol_version: 1, + }, + }), + ); + const client = new DiffexCloudClient({ + baseUrl: "https://api.diffex.ai", + accessToken: "access-secret", + fetch, + }); + + await expect(client.createAttachment(SESSION_ID)).rejects.toMatchObject({ + status: 502, + code: "invalid_response", + }); + }); + it("resolves a fresh one-time attachment for each transport connection", async () => { const fetch = vi .fn() From e6b41e2ecd4f2f1ccf08eaef5d6db2d7c6187a77 Mon Sep 17 00:00:00 2001 From: Jeffrey Tseng Date: Mon, 24 Aug 2026 19:52:58 -0400 Subject: [PATCH 3/3] fix(build): copy root TypeScript config Include the root TypeScript base configuration in the runtime image build context so workspace package builds can resolve their shared compiler settings inside the Docker build stage. --- Dockerfile.runtime | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile.runtime b/Dockerfile.runtime index 1b95664a1..e06993a6e 100644 --- a/Dockerfile.runtime +++ b/Dockerfile.runtime @@ -3,6 +3,7 @@ FROM node:24-bookworm-slim AS build WORKDIR /opt/diffex COPY package.json package-lock.json ./ +COPY tsconfig.base.json ./ COPY packages ./packages COPY scripts ./scripts