Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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
65 changes: 65 additions & 0 deletions .github/workflows/runtime-image.yml
Original file line number Diff line number Diff line change
@@ -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
28 changes: 28 additions & 0 deletions Dockerfile.runtime
Original file line number Diff line number Diff line change
@@ -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"
50 changes: 50 additions & 0 deletions docs/cloud-runtime.md
Original file line number Diff line number Diff line change
@@ -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-<commit>` 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.
46 changes: 46 additions & 0 deletions packages/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 4 additions & 0 deletions packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading