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..e06993a6e --- /dev/null +++ b/Dockerfile.runtime @@ -0,0 +1,28 @@ +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 + +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..29935be94 --- /dev/null +++ b/packages/client/src/cloud.ts @@ -0,0 +1,252 @@ +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.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 = ""; + 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.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({ + 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 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); +} + +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..955bc244c --- /dev/null +++ b/packages/client/test/cloud.test.ts @@ -0,0 +1,179 @@ +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("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() + .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", + ]); + }); +});