Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

Web terminals: a shell in a sandbox, in the browser, for its opener only (phase 4a) (#124)

* WIP phase 4a: a PTY shell in the sandbox over a WebSocket, xterm bundle, container socket forwarding

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014VDQqT8oL18uLJ9fpsNxW2

* WIP phase 4a: terminals (open, limits, expiry, pages, socket) with an end-to-end test

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014VDQqT8oL18uLJ9fpsNxW2

* Terminal docs, audit and configuration rows; terminal.open records the pinned CLI versions

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014VDQqT8oL18uLJ9fpsNxW2

* Renumber the terminals migration to 0019 (0018 is quotas, #121)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014VDQqT8oL18uLJ9fpsNxW2

---------

Co-authored-by: Claude <noreply@anthropic.com>
Canberk Demirci authored · GitHub committed signed1 parent 7678c79commit 5619cf29b5c52c991eb6ab393efddd09991bbdf4Browse files
27 files changed+1389−14
Files (27)
addedapps/diffinery/docs/agents/terminal.md+77−0View file
@@ -1,0 +1,77 @@
1+---
2+title: Terminals
3+description: Open a shell in a sandbox with your repository checked out, right in the browser, and work there with Claude Code or Codex signed in to your own subscription.
4+---
5+
6+A **terminal** is a sandbox with a repository checked out, in your browser. You type into a real shell, and you can start Claude Code or Codex there and sign in with your own Claude or ChatGPT plan, instead of an API key. Whatever you push from it is pushed as one of your agents, so its pull requests need a person's approval like any agent's.
7+
8+Terminals are optional. Nothing changes for a repository until someone opens one.
9+
10+## Before you start
11+
12+You need:
13+
14+1. **Write access** to the repository.
15+2. **An agent** you manage, with write access to the repository, as for [tasks](/docs/agents/tasks#before-you-start).
16+3. **A browser session.** Terminals open only from a signed-in browser. Tokens, the REST API and MCP can't open one or connect to one.
17+
18+## Open a terminal
19+
20+1. Open the repository's **Agent tasks** page (`/<owner>/<repo>/tasks`) and click **Terminals**, or go to `/<owner>/<repo>/terminals`.
21+2. Pick the **agent** and the **branch**.
22+3. The first time, tick the notice: you use Claude Code and Codex with your own Anthropic or OpenAI account, under that provider's terms, and usage is billed to your account. Diffinery records when you accepted it and which version of the notice it was. When the notice changes, you're asked again.
23+4. Click **Open a terminal**.
24+
25+The page shows **Starting** while the sandbox starts, then the terminal. Reloading the page, or opening it in another tab, connects to the same shell and redraws its recent output. Only one tab is connected at a time: the newest one wins, and the older tab says so.
26+
27+## Signing in
28+
29+The toolbar has buttons that type a command for you:
30+
31+| Button | What it runs |
32+| --- | --- |
33+| **Start Claude Code** | Installs Claude Code at the version Diffinery pins for [tasks](/docs/agents/tasks#runtimes) if it isn't installed, then starts it. Type `/login`, choose your Claude subscription, open the link it shows, sign in with Anthropic and paste the code back. You can use an API key instead: `export ANTHROPIC_API_KEY=…` before starting it. |
34+| **Start Codex** | Installs Codex the same way, runs `codex login --device-auth` if you aren't signed in, then starts it. Open the link it shows, sign in with OpenAI and enter the code. A ChatGPT workspace admin may have to allow device codes. Offered on **private repositories only**, because OpenAI allows signing in with a ChatGPT plan only there; on a public repository you can still use an OpenAI API key. |
35+
36+The sign-in happens in the provider's own flow and stays in that sandbox. Diffinery never sees or keeps it, and it's gone when the terminal ends. The providers' terms: [Anthropic's Consumer Terms](https://www.anthropic.com/legal/consumer-terms), [Claude Code's legal and compliance page](https://code.claude.com/docs/en/legal-and-compliance) and [OpenAI's Terms of Use](https://openai.com/policies/terms-of-use).
37+
38+## Who can reach it
39+
40+- **Only you.** Nobody else sees your terminal or connects to it: not a repository admin, not the repository's owner, not a site administrator. The terminal may hold your provider sign-in.
41+- **Only from a browser on this Diffinery.** The connection must come from a page on the instance's own address with your session cookie. A request without that `Origin`, from another site, or with a token is refused.
42+- **Not through the sandbox API.** A terminal's sandbox isn't listed by the [sandbox API or MCP tools](/docs/agents/sandboxes), and they can't run commands in it, read its files or close it, whoever asks.
43+
44+## What the sandbox can do
45+
46+- Git in the sandbox acts as the agent you picked, at most with write access to this one repository. The credential stops working when the terminal ends, and it's checked again every minute: if you or the agent lose write access, the terminal ends.
47+- Your provider sign-in and anything else you type into the shell can be read by any process in the sandbox, including the sandbox's own server and its credentials, which reach only this sandbox. Don't paste secrets you wouldn't keep on that machine.
48+- The sandbox has outbound internet access, as other sandboxes do.
49+
50+## When it ends
51+
52+A terminal ends, and its sandbox stops with everything on its disk, when:
53+
54+| Reason | What the page says |
55+| --- | --- |
56+| You click **End** | You ended it. |
57+| No tab was connected for 30 minutes | Nobody was attached for 30 minutes. |
58+| It ran for 8 hours | It reached the 8-hour limit. |
59+| You or the agent lost write access | You or the agent no longer have write access here. |
60+| The sandbox stopped or didn't start | Its sandbox stopped. / Its sandbox didn't start. |
61+| An administrator switched terminals off | Terminals were switched off on this Diffinery. |
62+
63+Push your work before it ends: nothing in the sandbox is kept. Exiting the shell starts a new one in the same sandbox.
64+
65+## Limits
66+
67+You can have **2** terminals open in a repository and **5** in all. Opening another one is refused with `429` until you end one.
68+
69+A terminal's sandbox counts toward the [account quotas](/docs/reference/limits#account-quotas) of the account that owns the repository, like any agent sandbox: its sandboxes open at once and its hosted minutes for the month. Past either, opening a terminal is refused with `429` and the quota's message.
70+
71+## Nothing is stored
72+
73+What you type and what the shell prints go only between your browser and the sandbox. Diffinery keeps no copy and doesn't log it: the recent output that redraws the screen lives in the sandbox's memory and is gone when the terminal ends. The [audit log](/docs/security/audit-log) records only that a terminal was opened or ended, by whom, in which repository, with which agent and branch and the Claude Code and Codex versions its buttons install, and that you accepted the notice (`terminal.terms`, `terminal.open`, `terminal.end`).
74+
75+## For administrators
76+
77+Terminals are on by default. Set the Worker variable `TERMINALS` to `off` to switch them off: nobody can open one, and the ones that are open end within a minute. See [Configuration](/docs/self-hosting/configuration#worker-variables).
modifiedapps/diffinery/docs/nav.json+1−0View file
@@ -75,6 +75,7 @@
7575 "agents/review",
7676 "agents/mcp",
7777 "agents/sandboxes",
78+ "agents/terminal",
7879 "agents/docs-for-agents"
7980 ]
8081 },
modifiedapps/diffinery/docs/reference/limits.md+2−2View file
@@ -172,9 +172,9 @@
172172
173173 | Quota | Default | What happens past it |
174174 | --- | --- | --- |
175-| Hosted minutes per month | 3,000 | Hosted Actions jobs and agent sandboxes (including agent tasks and reviews) add up their running time, rounded up to the minute, per calendar month in UTC. Once it is used up, a hosted job fails without starting ("The job was not started: this account has used its 3000 hosted minutes for this month ..."), and a new sandbox is refused with `429`. Self-hosted runners don't count. |
175+| Hosted minutes per month | 3,000 | Hosted Actions jobs and agent sandboxes (including agent tasks, reviews and terminals) add up their running time, rounded up to the minute, per calendar month in UTC. Once it is used up, a hosted job fails without starting ("The job was not started: this account has used its 3000 hosted minutes for this month ..."), and a new sandbox is refused with `429`. Self-hosted runners don't count. |
176176 | Hosted jobs at once | 5 | Across all the account's repositories. Further jobs wait as queued and start, oldest first, as running ones finish (checked at least once a minute). A job that waits 6 hours fails with "The job waited 6 hours for one of this account's hosted jobs to finish, and was not started." |
177-| Agent sandboxes open at once | 5 | Across all the account's repositories, including agent tasks' and reviews' sandboxes. A new one is refused with `429 this account already has 5 agent sandboxes open; close one or wait for one to finish`. |
177+| Agent sandboxes open at once | 5 | Across all the account's repositories, including agent tasks', reviews' and terminals' sandboxes. A new one is refused with `429 this account already has 5 agent sandboxes open; close one or wait for one to finish`. |
178178 | Repositories | 500 | Creating, importing or transferring in a repository past it is refused with `403 <account> has reached its limit of 500 repositories; delete one or ask a site administrator to raise it`. |
179179
180180 ## Actions
modifiedapps/diffinery/docs/security/audit-log.md+3−0View file
@@ -82,6 +82,9 @@
8282 | `task.pick` | The person who started a task with [attempts](/docs/agents/tasks#best-of-n) picks one | `owner/repo#<task number>` | `attempt` |
8383 | `task.plan_split` | The person who started a plan-first task hands its plan to their coordinator to split | `owner/repo#<task number>` | `post`, `coordinator_task` |
8484 | `task.stop` | Someone stops a task (its starter, its agent or a repository admin) | `owner/repo#<task number>` | none |
85+| `terminal.terms` | Someone accepts the notice for [terminals](/docs/agents/terminal) | `owner/repo` | `version` |
86+| `terminal.open` | Someone opens a [terminal](/docs/agents/terminal) | `owner/repo` | `terminal`, `agent`, `ref`, `runtimes` (the Claude Code and Codex versions its buttons install) |
87+| `terminal.end` | A terminal ends | `owner/repo` | `terminal`, `reason` (`person`, `idle`, `limit`, `access`, `stopped`, `failed` or `disabled`) |
8588 | `project.memory` | Someone saves or restores the [project](/docs/agents/projects) memory | `owner/repo` | `version`, `length`, and `restored` (the version restored) for a restore |
8689 | `project.settings` | A repository admin changes **Settings → Project** | `owner/repo` | `max_running`, `instructions_length`, `plan_first` |
8790 | `project.coordinator` | Someone turns their [coordinator](/docs/agents/projects#coordinator) on, changes it or turns it off | `owner/repo` | `enabled`, and when on `agent`, `runtime`, `provider` and `model` (OpenRouter), `tokens_per_run`, `tokens_per_task` |
modifiedapps/diffinery/docs/self-hosting/configuration.md+1−0View file
@@ -34,6 +34,7 @@
3434 | `GITHUB_OAUTH_CLIENT_ID` | Not set | No | The client ID of a GitHub OAuth app, for linking GitHub accounts with a GitHub sign-in instead of a gist. Needs `GITHUB_OAUTH_CLIENT_SECRET` too. See [GitHub imports](#github-imports). |
3535 | `IMPORT_GIT_AGENT` | Not set in production; `external` in the `dev` environment | Never in production | With `external`, an import's Git transfer doesn't start a container: it waits for an import agent started by hand (see [The dev environment](#the-dev-environment)). |
3636 | `SCHEDULE_TICK_MINUTES` | Not set | No | Minutes between the workflow scheduler's checks, a whole number from 1 to 60. Default `5`, GitHub's shortest schedule interval. Anything else counts as not set. The end-to-end tests use `1`. |
37+| `TERMINALS` | Not set | No | `off` switches [terminals](/docs/agents/terminal) off: nobody can open one, and open ones end at their next check, within a minute. Anything else, or not set, leaves them on. |
3738
3839 When either `ACCESS_TEAM_DOMAIN` or `ACCESS_AUD` is empty, email sign-in at `/auth/access` is off: the page answers "Account recovery by email is not set up on this instance. Ask an administrator for a sign-in link.", and the sign-in page doesn't offer **Recover your account by email**. Passkeys, invitations and admin sign-in links keep working, but the first administrator can't sign in, because email sign-in is how an account in `BOOTSTRAP_ADMINS` is created.
3940
modifiedapps/diffinery/islands/build.mjs+15−1View file
@@ -73,4 +73,18 @@
7373 const cssIn = await readFile(require.resolve("@cloudflare/kumo/styles/standalone"), "utf8");
7474 const css = (await postcss([scoper]).process(cssIn, { from: undefined })).css;
7575 await writeFile(join(out, "kumo.css.txt"), css);
76-console.log(`islands: kumo.js ${(js.outputFiles[0].text.length / 1024).toFixed(0)} KB, kumo.css ${(css.length / 1024).toFixed(0)} KB`);
76+// The web terminal (phase 4): its own bundle, loaded only on the terminal page.
77+const term = await build({
78+ entryPoints: [join(here, "terminal.ts")],
79+ bundle: true,
80+ minify: true,
81+ format: "iife",
82+ target: ["chrome111", "safari16.4", "firefox128"],
83+ legalComments: "none",
84+ write: false,
85+ logLevel: "warning",
86+});
87+await writeFile(join(out, "terminal.js.txt"), term.outputFiles[0].text);
88+await writeFile(join(out, "terminal.css.txt"), await readFile(require.resolve("@xterm/xterm/css/xterm.css"), "utf8"));
89+
90+console.log(`islands: kumo.js ${(js.outputFiles[0].text.length / 1024).toFixed(0)} KB, kumo.css ${(css.length / 1024).toFixed(0)} KB, terminal.js ${(term.outputFiles[0].text.length / 1024).toFixed(0)} KB`);
addedapps/diffinery/islands/terminal.ts+101−0View file
@@ -1,0 +1,101 @@
1+// The web terminal (phase 4): xterm.js on a WebSocket to the shell in the person's sandbox.
2+// Loaded only on the terminal page, which renders <div id="dfy-terminal" data-socket="/o/r/terminals/<id>/socket">.
3+// Terminal bytes travel as binary frames both ways; resizes and pings are JSON text frames.
4+import { FitAddon } from "@xterm/addon-fit";
5+import { Terminal } from "@xterm/xterm";
6+
7+const el = document.getElementById("dfy-terminal");
8+const statusEl = document.getElementById("dfy-terminal-status");
9+if (el?.dataset.socket) {
10+ const css = getComputedStyle(document.body);
11+ const term = new Terminal({
12+ cursorBlink: true,
13+ fontFamily: css.getPropertyValue("--mono").trim() || "ui-monospace, Menlo, monospace",
14+ fontSize: 13,
15+ scrollback: 5000,
16+ allowProposedApi: false,
17+ theme: { background: "#111113", foreground: "#ededed", cursor: "#ededed", selectionBackground: "#3b4b6b" },
18+ });
19+ const fit = new FitAddon();
20+ term.loadAddon(fit);
21+ term.open(el);
22+ fit.fit();
23+
24+ const encoder = new TextEncoder();
25+ let ws: WebSocket | null = null;
26+ let tries = 0;
27+ let stopped = false;
28+ const say = (text: string, tone: "" | "ok" | "warn" = "") => {
29+ if (!statusEl) return;
30+ statusEl.textContent = text;
31+ statusEl.dataset.tone = tone;
32+ };
33+ const send = (data: string) => {
34+ if (ws?.readyState === WebSocket.OPEN) ws.send(encoder.encode(data));
35+ };
36+ const resize = () => {
37+ fit.fit();
38+ if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
39+ };
40+
41+ const connect = () => {
42+ const url = `${location.protocol === "https:" ? "wss://" : "ws://"}${location.host}${el.dataset.socket}?cols=${term.cols}&rows=${term.rows}`;
43+ const socket = new WebSocket(url);
44+ socket.binaryType = "arraybuffer";
45+ ws = socket;
46+ say("Connecting…");
47+ socket.onopen = () => {
48+ tries = 0;
49+ say("Connected", "ok");
50+ term.reset();
51+ term.focus();
52+ };
53+ socket.onmessage = (e) => {
54+ if (e.data instanceof ArrayBuffer) term.write(new Uint8Array(e.data));
55+ };
56+ socket.onclose = (e) => {
57+ if (ws !== socket) return;
58+ ws = null;
59+ if (e.code === 4000) {
60+ stopped = true;
61+ say("This terminal is open in another tab. Reload to use it here.", "warn");
62+ return;
63+ }
64+ if (e.reason === "terminal ended" || e.code === 4404) {
65+ stopped = true;
66+ say("The terminal ended.", "warn");
67+ setTimeout(() => location.reload(), 1500);
68+ return;
69+ }
70+ if (e.reason === "the shell exited") {
71+ say("The shell exited. Starting a new one…");
72+ setTimeout(connect, 500);
73+ return;
74+ }
75+ if (stopped || tries >= 8) {
76+ say("Disconnected. Reload the page to try again.", "warn");
77+ return;
78+ }
79+ const wait = Math.min(15_000, 500 * 2 ** tries++);
80+ say(`Disconnected. Reconnecting in ${Math.round(wait / 1000)} s…`, "warn");
81+ setTimeout(connect, wait);
82+ };
83+ };
84+
85+ term.onData(send);
86+ term.onBinary((data) => {
87+ if (ws?.readyState === WebSocket.OPEN) ws.send(Uint8Array.from(data, (c) => c.charCodeAt(0)));
88+ });
89+ new ResizeObserver(() => resize()).observe(el);
90+ setInterval(() => {
91+ if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "ping" }));
92+ }, 25_000);
93+ // Toolbar buttons type a command into the shell, as if the person typed it.
94+ for (const b of document.querySelectorAll<HTMLButtonElement>("[data-terminal-type]")) {
95+ b.addEventListener("click", () => {
96+ send(`${b.dataset.terminalType}\r`);
97+ term.focus();
98+ });
99+ }
100+ connect();
101+}
addedapps/diffinery/migrations/0019_agent_terminals.sql+26−0View file
@@ -1,0 +1,26 @@
1+-- Web terminals (phase 4, agentic/terminals.ts): a sandbox one person opened to work in
2+-- interactively, for example signed in to their own Claude or ChatGPT subscription. The
3+-- sandbox itself (and anything typed or shown in it) lives only in its container; this is the
4+-- index for limits, the list and expiry.
5+CREATE TABLE agent_terminals (
6+ id TEXT PRIMARY KEY,
7+ repo_id TEXT NOT NULL,
8+ person_id TEXT NOT NULL,
9+ agent_id TEXT NOT NULL,
10+ ref TEXT NOT NULL,
11+ status TEXT NOT NULL,
12+ sandbox_id TEXT,
13+ created_at TEXT NOT NULL,
14+ started_at TEXT,
15+ ended_at TEXT,
16+ end_reason TEXT
17+);
18+CREATE INDEX agent_terminals_person ON agent_terminals(person_id, status);
19+CREATE INDEX agent_terminals_repo ON agent_terminals(repo_id, status);
20+
21+-- A person's opt-in to using their own provider subscription in a terminal, with the notice's version.
22+CREATE TABLE terminal_terms (
23+ account_id TEXT PRIMARY KEY,
24+ version INTEGER NOT NULL,
25+ opted_in_at TEXT NOT NULL
26+);
modifiedapps/diffinery/package.json+2−0View file
@@ -37,6 +37,8 @@
3737 "@phosphor-icons/react": "2.1.10",
3838 "@types/react": "19.2.2",
3939 "@types/react-dom": "19.2.2",
40+ "@xterm/addon-fit": "0.10.0",
41+ "@xterm/xterm": "5.5.0",
4042 "esbuild": "0.25.11",
4143 "playwright": "1.56.1",
4244 "postcss": "8.5.6",
modifiedapps/diffinery/src/agentic/ProjectDO.ts+72−0View file
@@ -4,6 +4,7 @@
44 import { HttpError, now, randomToken, sha256 } from "../lib/util.js";
55 import { atLeast } from "../repo/types.js";
66 import { taskCaller, TaskAccessError } from "./access.js";
7+import { endTerminal, TERMINAL_IDLE_MS, TERMINAL_MAX_MS, terminalsEnabled, Terminals, type TerminalRow } from "./terminals.js";
78 import { PROVIDERS, type Provider } from "./keys.js";
89 import { enqueueMail } from "../mail/send.js";
910 import { formatTokens, pinRefusal, weighted, type Usage } from "./usage.js";
@@ -317,6 +318,8 @@
317318
318319 /** How often a running task is polled. */
319320 const POLL_MS = 3000;
321+/** How often open web terminals are checked for their idle and time limits. */
322+const TERMINAL_POLL_MS = 60_000;
320323 /** How often the person's access is re-checked while a run is active. */
321324 const RECHECK_MS = 60_000;
322325 const MAX_LINE = 32 * 1024;
@@ -1115,9 +1118,78 @@
11151118 if (run && ACTIVE.includes(run.step)) await this.endRun(run, "failed", e instanceof TaskAccessError || e instanceof HttpError ? e.message : `Internal error: ${e instanceof Error ? e.message : String(e)}`);
11161119 }
11171120 }
1121+ const terminals = await this.tickTerminals().catch((e) => {
1122+ console.error("terminal check failed", e);
1123+ return true;
1124+ });
11181125 if (this.sql.exec<{ n: number }>(`SELECT count(*) AS n FROM runs WHERE step IN (${ACTIVE.map(() => "?").join(", ")})`, ...ACTIVE).one().n) this.arm(POLL_MS);
1126+ else if (terminals) this.arm(TERMINAL_POLL_MS);
11191127 }
11201128
1129+ // ---------------------------------------------------------------- web terminals (agentic/terminals.ts)
1130+
1131+ /** When the open terminals were last checked (they don't need the task runs' pace). */
1132+ private terminalsCheckedAt = 0;
1133+
1134+ /** A terminal was opened in this repository: start it now. */
1135+ async wakeTerminals(repoId: string): Promise<void> {
1136+ this.sql.exec("INSERT INTO project_settings (k, v) VALUES ('repo_id', ?) ON CONFLICT (k) DO UPDATE SET v = excluded.v", repoId);
1137+ this.terminalsCheckedAt = 0;
1138+ this.arm(0);
1139+ }
1140+
1141+ /** Starts terminals waiting for a sandbox and ends those past their limits. Returns whether any are still open. */
1142+ private async tickTerminals(): Promise<boolean> {
1143+ const repoId = this.sql.exec<{ v: string }>("SELECT v FROM project_settings WHERE k = 'repo_id'").toArray()[0]?.v;
1144+ if (!repoId) return false;
1145+ if (Date.now() - this.terminalsCheckedAt < TERMINAL_POLL_MS - 1000) return true;
1146+ this.terminalsCheckedAt = Date.now();
1147+ const terminals = new Terminals(this.env);
1148+ const dir = new Directory(this.env);
1149+ for (const t of await terminals.running(repoId)) {
1150+ try {
1151+ await this.tickTerminal(t, terminals, dir);
1152+ } catch (e) {
1153+ console.error("terminal check failed", t.id, e);
1154+ }
1155+ }
1156+ return (await terminals.running(repoId)).length > 0;
1157+ }
1158+
1159+ private async tickTerminal(t: TerminalRow, terminals: Terminals, dir: Directory): Promise<void> {
1160+ if (!terminalsEnabled(this.env)) return endTerminal(this.env, dir, t, "disabled");
1161+ // The same rule as tasks, checked again every time: the person drives the agent, and both may write.
1162+ const [person, agent, repo] = await Promise.all([dir.account(t.person_id), dir.account(t.agent_id), dir.repoById(t.repo_id)]);
1163+ let caller;
1164+ try {
1165+ if (!person || !agent || !repo || repo.archived) throw new TaskAccessError("gone");
1166+ caller = await taskCaller(dir, person, agent, repo);
1167+ } catch (e) {
1168+ if (!(e instanceof TaskAccessError)) throw e;
1169+ return endTerminal(this.env, dir, t, "access");
1170+ }
1171+ const stub = this.env.REPO.get(this.env.REPO.idFromName(t.repo_id));
1172+ if (t.status === "starting") {
1173+ let sb;
1174+ try {
1175+ sb = await stub.createSandbox(caller, t.ref, { personId: t.person_id, kind: "terminal" });
1176+ } catch (e) {
1177+ console.error("terminal sandbox failed to start", t.id, e);
1178+ return endTerminal(this.env, dir, t, "failed");
1179+ }
1180+ // Ended while it started: stop it again.
1181+ if (!(await terminals.started(t.id, sb.id))) await stub.closeTaskSandbox(sb.id).catch(() => {});
1182+ return;
1183+ }
1184+ if (Date.now() - Date.parse(t.started_at ?? t.created_at) > TERMINAL_MAX_MS) return endTerminal(this.env, dir, t, "limit");
1185+ const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(t.sandbox_id!));
1186+ type PtyStatus = { attached?: boolean; idleSince?: string | null };
1187+ const res = (await sandbox.running()) ? await sandbox.call("/pty/status", "{}").catch(() => null) : null;
1188+ const status = res?.status === 200 ? (JSON.parse(res.body) as PtyStatus) : null;
1189+ if (!status) return endTerminal(this.env, dir, t, "stopped");
1190+ if (!status.attached && Date.now() - Date.parse(status.idleSince ?? t.started_at ?? t.created_at) > TERMINAL_IDLE_MS) return endTerminal(this.env, dir, t, "idle");
1191+ }
1192+
11211193 private arm(ms: number): void {
11221194 this.ctx.waitUntil(this.ctx.storage.setAlarm(Date.now() + ms));
11231195 }
modifiedapps/diffinery/src/agentic/tasks-web.tsx+3−0View file
@@ -540,6 +540,9 @@
540540 <div>
541541 <div class="row mb">
542542 <h1 class="grow m0">Agent tasks</h1>
543+ <a class="btn" href={`${repoUrl(l.repo)}/terminals`}>
544+ Terminals
545+ </a>
543546 {l.repo.archived ? null : (
544547 <a class="btn primary" href={`${repoUrl(l.repo)}/tasks/new`}>
545548 New task
addedapps/diffinery/src/agentic/terminal-web.tsx+317−0View file
@@ -1,0 +1,317 @@
1+/** @jsxImportSource hono/jsx */
2+import type { FC } from "hono/jsx";
3+import { requireAccount, type AppContext } from "../auth/principal.js";
4+import { loadRepo } from "../http/common.js";
5+import { HttpError } from "../lib/util.js";
6+import { csrfToken } from "../web/csrf.js";
7+import { KCheckbox, KSelect } from "../web/kumo.js";
8+import { Form, Time, act, post, prm, render, repoUrl, statusOf, type WebApp } from "../web/ui.js";
9+import TERMINAL_JS from "../../build/islands/terminal.js.txt";
10+import TERMINAL_CSS from "../../build/islands/terminal.css.txt";
11+import { taskCaller, TaskAccessError } from "./access.js";
12+import { RUNTIME_VERSIONS } from "./runtimes.js";
13+import { drivableAgents, requirePersonSession, requireTaskReader } from "./tasks.js";
14+import { endTerminal, MAX_TERMINALS, MAX_TERMINALS_PER_REPO, openTerminal, TERMINAL_END_TEXT, TERMINAL_IDLE_MS, TERMINAL_MAX_MS, terminalId, terminalsEnabled, Terminals, type TerminalEndReason, type TerminalRow } from "./terminals.js";
15+
16+/**
17+ * Web pages for terminals (phase 4): the list and open form on a repository, the terminal page
18+ * (xterm.js, islands/terminal.ts) and its WebSocket. Only the person who opened a terminal sees it.
19+ */
20+
21+type Loaded = Awaited<ReturnType<typeof loadRepo>>;
22+
23+/** The providers' own terms for signing in with a subscription (plan.md, "what the providers allow"). */
24+const SUBSCRIPTION_TERMS = [
25+ { label: "Anthropic's Consumer Terms", url: "https://www.anthropic.com/legal/consumer-terms" },
26+ { label: "Claude Code's legal and compliance page", url: "https://code.claude.com/docs/en/legal-and-compliance" },
27+ { label: "OpenAI's Terms of Use", url: "https://openai.com/policies/terms-of-use" },
28+];
29+
30+/** What the toolbar buttons type: install the published CLI if it isn't there yet, then start it. */
31+export const START_CLAUDE = `command -v claude >/dev/null || npm install --global --no-fund --no-audit --loglevel=error @anthropic-ai/claude-code@${RUNTIME_VERSIONS["claude-code"]}; claude`;
32+export const START_CODEX = `command -v codex >/dev/null || npm install --global --no-fund --no-audit --loglevel=error @openai/codex@${RUNTIME_VERSIONS.codex}; codex login status >/dev/null 2>&1 || codex login --device-auth; codex`;
33+
34+function fnv(s: string): string {
35+ let h = 0x811c9dc5;
36+ for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 0x01000193);
37+ return (h >>> 0).toString(36);
38+}
39+const ASSETS = { js: `/-/terminal/${fnv(TERMINAL_JS)}.js`, css: `/-/terminal/${fnv(TERMINAL_CSS)}.css` };
40+
41+const STATE: Record<TerminalRow["status"], { label: string; tone: string }> = {
42+ starting: { label: "Starting", tone: "blue" },
43+ running: { label: "Open", tone: "green" },
44+ ended: { label: "Ended", tone: "" },
45+};
46+
47+async function forTerminals(c: AppContext): Promise<Loaded> {
48+ if (c.get("principal").type !== "account") throw new HttpError(401, "sign in required");
49+ const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
50+ requireTaskReader(l);
51+ return l;
52+}
53+
54+const TermsLinks: FC = () => (
55+ <>
56+ {SUBSCRIPTION_TERMS.map((x, i) => (
57+ <>
58+ {i ? ", " : ""}
59+ <a href={x.url} rel="noopener noreferrer" target="_blank">
60+ {x.label}
61+ </a>
62+ </>
63+ ))}
64+ </>
65+);
66+
67+async function listPage(c: AppContext, l: Loaded, error?: string, v: { agent?: string; ref?: string } = {}) {
68+ const me = requireAccount(c);
69+ const base = repoUrl(l.repo);
70+ const tk = csrfToken(c);
71+ const terminals = new Terminals(c.env);
72+ const [mine, agents, branches, termsAt] = await Promise.all([terminals.list(me.id, l.repo.id), drivableAgents(c, me, l), l.stub.refs("refs/heads/"), terminals.termsAt(me.id)]);
73+ const enabled = terminalsEnabled(c.env);
74+ const open = mine.filter((t) => t.status !== "ended");
75+ const agentLogin = new Map(agents.map((a) => [a.id, a.login]));
76+ return render(
77+ c,
78+ { title: `Terminals · ${l.repo.owner_login}/${l.repo.name}`, repo: { repo: l.repo, caller: l.caller, tab: "project" }, status: error ? 422 : undefined, refresh: mine.some((t) => t.status === "starting") ? 5 : undefined },
79+ <div style="max-width:880px">
80+ <div class="row mb">
81+ <h1 class="grow m0">Terminals</h1>
82+ <a class="btn" href={`${base}/tasks`}>
83+ Agent tasks
84+ </a>
85+ </div>
86+ <p class="muted">
87+ A terminal is a sandbox with this repository checked out, in your browser. Sign in there to Claude Code with your Claude Pro or Max plan, or to Codex with your ChatGPT plan{l.repo.visibility === "public" ? " (private repositories only)" : ""}, and work with the agent directly. The sign-in happens in the provider's own flow and stays in that sandbox: Diffinery never sees or keeps it, and it's gone when the terminal ends. See <a href="/docs/agents/terminal">Terminals</a>.
88+ </p>
89+ {error ? <div class="flash error">{error}</div> : null}
90+ {!enabled ? <div class="flash info">Terminals are switched off on this Diffinery.</div> : null}
91+ {mine.length ? (
92+ <div class="box mb">
93+ <ul class="list">
94+ {mine.map((t) => (
95+ <li>
96+ <div class="grow">
97+ <a href={`${base}/terminals/${t.id}`}>
98+ <b>Terminal on {t.ref}</b>
99+ </a>{" "}
100+ <span class={`badge ${STATE[t.status].tone}`}>{STATE[t.status].label}</span>
101+ <div class="muted small">
102+ as {agentLogin.get(t.agent_id) ?? "an agent"} · opened <Time at={t.created_at} />
103+ {t.status === "ended" && t.end_reason ? <> · {TERMINAL_END_TEXT[t.end_reason as TerminalEndReason] ?? t.end_reason}</> : null}
104+ </div>
105+ </div>
106+ {t.status !== "ended" ? (
107+ <Form action={`${base}/terminals/${t.id}/end`} t={tk} class="inline" confirm="End this terminal? Its sandbox stops, and anything in it, including any sign-in, is gone.">
108+ <button class="btn sm danger">End</button>
109+ </Form>
110+ ) : null}
111+ </li>
112+ ))}
113+ </ul>
114+ </div>
115+ ) : null}
116+ {enabled && !l.repo.archived ? (
117+ <Form action={`${base}/terminals`} t={tk} class="box">
118+ <div class="head">
119+ <h2>Open a terminal</h2>
120+ </div>
121+ <div class="body">
122+ {!agents.length ? (
123+ <div class="flash info">
124+ You don't have an agent with write access here yet. <a href="/settings/agents">Create an agent</a>, then give it write access to this repository (or let it inherit yours).
125+ </div>
126+ ) : null}
127+ <div class="row" style="align-items:flex-start;flex-wrap:wrap">
128+ <div class="field grow">
129+ <label for="agent">Agent</label>
130+ <KSelect id="agent" name="agent" block label="Agent" value={v.agent ?? agents[0]?.login} options={agents.map((a) => ({ value: a.login, label: a.name ? `${a.login} (${a.name})` : a.login }))} placeholder="Choose an agent" />
131+ <div class="hint">The terminal pushes and opens pull requests as this agent, under the same rules as its tasks.</div>
132+ </div>
133+ <div class="field grow">
134+ <label for="ref">Branch</label>
135+ <KSelect id="ref" name="ref" block label="Branch" value={v.ref ?? l.repo.default_branch} options={branches.slice(0, 500).map((b) => ({ value: b.name.slice("refs/heads/".length), label: b.name.slice("refs/heads/".length) }))} />
136+ </div>
137+ </div>
138+ {termsAt ? (
139+ <p class="muted small">
140+ You accepted the notice about your own provider accounts <Time at={termsAt} />.
141+ </p>
142+ ) : (
143+ <div class="field">
144+ <KCheckbox
145+ name="accept"
146+ label={
147+ <>
148+ I use Claude Code and Codex in my terminals with my own Anthropic or OpenAI account, under that provider's terms (<TermsLinks />
149+ ). Usage is billed to my account. Diffinery only provides the sandbox, and never sees or keeps my sign-in.
150+ </>
151+ }
152+ />
153+ </div>
154+ )}
155+ <p class="muted small">
156+ A terminal ends when you end it, after {TERMINAL_IDLE_MS / 60_000} minutes with no tab attached, or after {TERMINAL_MAX_MS / 3600_000} hours. You can have {MAX_TERMINALS_PER_REPO} open in a repository and {MAX_TERMINALS} in all{open.length ? `; ${open.length} open here now` : ""}.
157+ </p>
158+ <button class="btn primary" disabled={!agents.length}>
159+ Open a terminal
160+ </button>
161+ </div>
162+ </Form>
163+ ) : null}
164+ </div>,
165+ );
166+}
167+
168+async function terminalPage(c: AppContext, l: Loaded, t: TerminalRow) {
169+ const base = repoUrl(l.repo);
170+ const tk = csrfToken(c);
171+ const agent = await c.get("dir").account(t.agent_id);
172+ const endsAt = t.started_at ? new Date(Date.parse(t.started_at) + TERMINAL_MAX_MS).toISOString() : null;
173+ const running = t.status === "running";
174+ return render(
175+ c,
176+ {
177+ title: `Terminal · ${l.repo.owner_login}/${l.repo.name}`,
178+ repo: { repo: l.repo, caller: l.caller, tab: "project" },
179+ wide: true,
180+ refresh: t.status === "starting" ? 3 : undefined,
181+ head: running ? <link rel="stylesheet" href={ASSETS.css} /> : undefined,
182+ scriptSrc: running ? [ASSETS.js] : undefined,
183+ },
184+ <div>
185+ <div class="row mb" style="flex-wrap:wrap;gap:8px">
186+ <h1 class="grow m0" style="font-size:20px">
187+ <a href={`${base}/terminals`}>Terminals</a> / {t.ref}
188+ </h1>
189+ {running ? (
190+ <>
191+ <button type="button" class="btn sm" data-terminal-type={START_CLAUDE}>
192+ Start Claude Code
193+ </button>
194+ {l.repo.visibility !== "public" ? (
195+ <button type="button" class="btn sm" data-terminal-type={START_CODEX}>
196+ Start Codex
197+ </button>
198+ ) : null}
199+ </>
200+ ) : null}
201+ {t.status !== "ended" ? (
202+ <Form action={`${base}/terminals/${t.id}/end`} t={tk} class="inline" confirm="End this terminal? Its sandbox stops, and anything in it, including any sign-in, is gone.">
203+ <button class="btn sm danger">End</button>
204+ </Form>
205+ ) : null}
206+ </div>
207+ <div class="muted small mb">
208+ <span class={`badge ${STATE[t.status].tone}`}>{STATE[t.status].label}</span> as {agent?.login ?? "an agent"}
209+ {endsAt ? (
210+ <>
211+ {" "}
212+ · ends by <time datetime={endsAt} title={endsAt}>{new Date(endsAt).toUTCString().slice(17, 22)} UTC</time> at the latest
213+ </>
214+ ) : null}
215+ {running ? (
216+ <>
217+ {" "}
218+ · <span id="dfy-terminal-status" class="term-status" aria-live="polite"></span>
219+ </>
220+ ) : null}
221+ </div>
222+ {t.status === "starting" ? <div class="box"><div class="body muted">Starting the sandbox and checking out {t.ref}…</div></div> : null}
223+ {t.status === "ended" ? (
224+ <div class="box">
225+ <div class="body">
226+ This terminal ended <Time at={t.ended_at} />. {t.end_reason ? (TERMINAL_END_TEXT[t.end_reason as TerminalEndReason] ?? "") : ""} Its sandbox is gone, with anything that was in it. <a href={`${base}/terminals`}>Open another</a>.
227+ </div>
228+ </div>
229+ ) : null}
230+ {running ? (
231+ <>
232+ <div id="dfy-terminal" class="term" data-socket={`${base}/terminals/${t.id}/socket`}></div>
233+ <details class="box mt">
234+ <summary class="body">Signing in</summary>
235+ <div class="body muted small">
236+ <p>
237+ <b>Claude Code</b>: click <b>Start Claude Code</b>, then type <code>/login</code> and choose your Claude subscription. Open the link it shows, sign in with Anthropic, and paste the code back here. You can also use your own API key: <code>export ANTHROPIC_API_KEY=…</code> before starting it.
238+ </p>
239+ {l.repo.visibility !== "public" ? (
240+ <p>
241+ <b>Codex</b>: click <b>Start Codex</b>. It runs <code>codex login --device-auth</code>: open the link it shows, sign in with OpenAI and enter the code. A ChatGPT workspace admin may have to allow device codes.
242+ </p>
243+ ) : (
244+ <p>
245+ <b>Codex</b>: OpenAI allows signing in with a ChatGPT plan only on private repositories, so this public repository's terminal doesn't offer it. You can use an OpenAI API key instead.
246+ </p>
247+ )}
248+ <p>The sign-in stays in this sandbox and ends with it. Your work is pushed as {agent?.login ?? "your agent"}, so its pull requests need a person's approval like any agent's.</p>
249+ </div>
250+ </details>
251+ </>
252+ ) : null}
253+ </div>,
254+ );
255+}
256+
257+export function registerTerminals(web: WebApp): void {
258+ web.get("/-/terminal/:file", (c) => {
259+ const headers = { "cache-control": "public, max-age=31536000, immutable", "x-content-type-options": "nosniff" };
260+ if (c.req.path === ASSETS.js) return c.body(TERMINAL_JS, 200, { ...headers, "content-type": "text/javascript; charset=utf-8" });
261+ if (c.req.path === ASSETS.css) return c.body(TERMINAL_CSS, 200, { ...headers, "content-type": "text/css; charset=utf-8" });
262+ return c.text("Not found", 404);
263+ });
264+
265+ web.get("/:owner/:repo/terminals", async (c) => listPage(c, await forTerminals(c)));
266+
267+ post(web, "/:owner/:repo/terminals", async (c, f) => {
268+ const l = await forTerminals(c);
269+ const v = { agent: f.s("agent"), ref: f.s("ref") };
270+ try {
271+ const t = await openTerminal(c, l, { ...v, accept: f.all("accept").length > 0 });
272+ return c.redirect(`${repoUrl(l.repo)}/terminals/${t.id}`, 303);
273+ } catch (e) {
274+ const st = statusOf(e);
275+ if (st.status >= 500) throw e;
276+ return listPage(c, l, st.message, v);
277+ }
278+ });
279+
280+ web.get("/:owner/:repo/terminals/:id", async (c) => {
281+ const l = await forTerminals(c);
282+ const t = await new Terminals(c.env).mine(terminalId(c.req.param("id")), requireAccount(c), l.repo);
283+ return terminalPage(c, l, t);
284+ });
285+
286+ post(web, "/:owner/:repo/terminals/:id/end", async (c) => {
287+ const l = await forTerminals(c);
288+ const me = requireAccount(c);
289+ const t = await new Terminals(c.env).mine(terminalId(c.req.param("id")), me, l.repo);
290+ return act(c, `${repoUrl(l.repo)}/terminals/${t.id}`, async () => endTerminal(c.env, c.get("dir"), t, "person", me), "The terminal ended.");
291+ });
292+
293+ // The terminal's WebSocket: only its opener, in a browser session on this site, while it runs.
294+ web.get("/:owner/:repo/terminals/:id/socket", async (c) => {
295+ if (c.req.header("upgrade")?.toLowerCase() !== "websocket") return c.text("expected a WebSocket", 426);
296+ if (c.req.header("origin") !== new URL(c.env.PUBLIC_URL).origin) return c.text("forbidden", 403);
297+ const me = requirePersonSession(c);
298+ const l = await forTerminals(c);
299+ const t = await new Terminals(c.env).mine(terminalId(c.req.param("id")), me, l.repo);
300+ if (t.status !== "running" || !t.sandbox_id || !terminalsEnabled(c.env)) return c.text("this terminal isn't running", 409);
301+ const dir = c.get("dir");
302+ const agent = await dir.account(t.agent_id);
303+ try {
304+ if (!agent) throw new TaskAccessError("the agent is gone");
305+ await taskCaller(dir, me, agent, l.repo);
306+ } catch (e) {
307+ if (e instanceof TaskAccessError) return c.text(e.message, 403);
308+ throw e;
309+ }
310+ const q = new URL(c.req.url).searchParams;
311+ const size = (k: string) => {
312+ const n = Number(q.get(k));
313+ return Number.isFinite(n) && n >= 1 ? String(Math.min(1000, Math.floor(n))) : "";
314+ };
315+ return c.env.SANDBOX.get(c.env.SANDBOX.idFromName(t.sandbox_id)).fetch(new Request(`http://sandbox/pty?cols=${size("cols")}&rows=${size("rows")}`, c.req.raw));
316+ });
317+}
addedapps/diffinery/src/agentic/terminals.ts+192−0View file
@@ -1,0 +1,192 @@
1+import type { AppContext } from "../auth/principal.js";
2+import type { Env } from "../env.js";
3+import type { loadRepo } from "../http/common.js";
4+import type { Account, Directory, Repo } from "../lib/directory.js";
5+import { mayStart } from "../lib/quotas.js";
6+import { HttpError, newId, now } from "../lib/util.js";
7+import { taskCaller, TaskAccessError } from "./access.js";
8+import { RUNTIME_VERSIONS } from "./runtimes.js";
9+import { projectStub, requirePersonSession } from "./tasks.js";
10+
11+/**
12+ * Web terminals (phase 4, /mnt/project-files/agentic/phase4-design.md): a sandbox one person opens
13+ * to work in directly, for example signed in to their own Claude or ChatGPT subscription.
14+ *
15+ * - Only a person in a browser session opens one, with write access, driving one of their agents
16+ * (the sandbox's git credential acts as that agent, capped at write, as for tasks).
17+ * - Only that person sees it or attaches to it: not an admin, not the repository's owner, because
18+ * the shell may hold their provider sign-in. The sandbox API never reaches it either.
19+ * - It ends on End, after TERMINAL_IDLE_MS with nobody attached, or after TERMINAL_MAX_MS; ending
20+ * stops the container (its disk, and any sign-in, go with it) and revokes its git credential.
21+ * - Nothing typed or shown is stored outside the container.
22+ */
23+
24+type Loaded = Awaited<ReturnType<typeof loadRepo>>;
25+
26+export const MAX_TERMINALS_PER_REPO = 2;
27+export const MAX_TERMINALS = 5;
28+export const TERMINAL_IDLE_MS = 30 * 60_000;
29+export const TERMINAL_MAX_MS = 8 * 3600_000;
30+/** Bump when the notice changes: everyone opts in again. */
31+export const TERMINAL_TERMS_VERSION = 1;
32+
33+export type TerminalStatus = "starting" | "running" | "ended";
34+export type TerminalEndReason = "person" | "idle" | "limit" | "access" | "stopped" | "failed" | "disabled";
35+
36+export interface TerminalRow {
37+ id: string;
38+ repo_id: string;
39+ person_id: string;
40+ agent_id: string;
41+ ref: string;
42+ status: TerminalStatus;
43+ sandbox_id: string | null;
44+ created_at: string;
45+ started_at: string | null;
46+ ended_at: string | null;
47+ end_reason: string | null;
48+}
49+
50+export const TERMINAL_END_TEXT: Record<TerminalEndReason, string> = {
51+ person: "You ended it.",
52+ idle: `Nobody was attached for ${TERMINAL_IDLE_MS / 60_000} minutes.`,
53+ limit: `It reached the ${TERMINAL_MAX_MS / 3600_000}-hour limit.`,
54+ access: "You or the agent no longer have write access here.",
55+ stopped: "Its sandbox stopped.",
56+ failed: "Its sandbox didn't start.",
57+ disabled: "Terminals were switched off on this Diffinery.",
58+};
59+
60+/** Terminals can be switched off for the whole instance (`TERMINALS=off`). */
61+export function terminalsEnabled(env: Env): boolean {
62+ return (env.TERMINALS ?? "on").toLowerCase() !== "off";
63+}
64+
65+export function terminalId(s: string | undefined): string {
66+ if (!s || !/^[A-Za-z0-9_-]{1,64}$/.test(s)) throw new HttpError(404, "terminal not found");
67+ return s;
68+}
69+
70+export class Terminals {
71+ constructor(private env: Env) {}
72+
73+ private get db() {
74+ return this.env.DB;
75+ }
76+
77+ async get(id: string): Promise<TerminalRow | null> {
78+ return (await this.db.prepare("SELECT * FROM agent_terminals WHERE id = ?").bind(id).first<TerminalRow>()) ?? null;
79+ }
80+
81+ /** A terminal as its opener sees it; anyone else, admins included, gets 404. */
82+ async mine(id: string, person: Account, repo: Repo): Promise<TerminalRow> {
83+ const t = await this.get(id);
84+ if (!t || t.person_id !== person.id || t.repo_id !== repo.id) throw new HttpError(404, "terminal not found");
85+ return t;
86+ }
87+
88+ async list(personId: string, repoId: string, limit = 20): Promise<TerminalRow[]> {
89+ return (await this.db.prepare("SELECT * FROM agent_terminals WHERE person_id = ? AND repo_id = ? ORDER BY created_at DESC LIMIT ?").bind(personId, repoId, limit).all<TerminalRow>()).results;
90+ }
91+
92+ async open(personId: string): Promise<{ total: number; byRepo: Map<string, number> }> {
93+ const rows = (await this.db.prepare("SELECT repo_id, count(*) AS n FROM agent_terminals WHERE person_id = ? AND status != 'ended' GROUP BY repo_id").bind(personId).all<{ repo_id: string; n: number }>()).results;
94+ return { total: rows.reduce((a, r) => a + r.n, 0), byRepo: new Map(rows.map((r) => [r.repo_id, r.n])) };
95+ }
96+
97+ async termsAt(personId: string): Promise<string | null> {
98+ const r = await this.db.prepare("SELECT version, opted_in_at FROM terminal_terms WHERE account_id = ?").bind(personId).first<{ version: number; opted_in_at: string }>();
99+ return r && r.version >= TERMINAL_TERMS_VERSION ? r.opted_in_at : null;
100+ }
101+
102+ async acceptTerms(personId: string): Promise<string> {
103+ const at = now();
104+ await this.db
105+ .prepare("INSERT INTO terminal_terms (account_id, version, opted_in_at) VALUES (?, ?, ?) ON CONFLICT (account_id) DO UPDATE SET version = excluded.version, opted_in_at = excluded.opted_in_at")
106+ .bind(personId, TERMINAL_TERMS_VERSION, at)
107+ .run();
108+ return at;
109+ }
110+
111+ async insert(t: Pick<TerminalRow, "repo_id" | "person_id" | "agent_id" | "ref">): Promise<TerminalRow> {
112+ const row: TerminalRow = { ...t, id: newId(), status: "starting", sandbox_id: null, created_at: now(), started_at: null, ended_at: null, end_reason: null };
113+ await this.db
114+ .prepare("INSERT INTO agent_terminals (id, repo_id, person_id, agent_id, ref, status, sandbox_id, created_at) VALUES (?, ?, ?, ?, ?, 'starting', NULL, ?)")
115+ .bind(row.id, row.repo_id, row.person_id, row.agent_id, row.ref, row.created_at)
116+ .run();
117+ return row;
118+ }
119+
120+ async started(id: string, sandboxId: string): Promise<boolean> {
121+ const r = await this.db.prepare("UPDATE agent_terminals SET status = 'running', sandbox_id = ?, started_at = ? WHERE id = ? AND status = 'starting'").bind(sandboxId, now(), id).run();
122+ return (r.meta.changes ?? 0) > 0;
123+ }
124+
125+ /** Marks it ended (once); the caller stops the sandbox. Returns whether this call ended it. */
126+ async markEnded(id: string, reason: TerminalEndReason): Promise<boolean> {
127+ const r = await this.db.prepare("UPDATE agent_terminals SET status = 'ended', ended_at = ?, end_reason = ? WHERE id = ? AND status != 'ended'").bind(now(), reason, id).run();
128+ return (r.meta.changes ?? 0) > 0;
129+ }
130+
131+ async running(repoId: string): Promise<TerminalRow[]> {
132+ return (await this.db.prepare("SELECT * FROM agent_terminals WHERE repo_id = ? AND status != 'ended' ORDER BY created_at").bind(repoId).all<TerminalRow>()).results;
133+ }
134+}
135+
136+/**
137+ * Opens a terminal for the person in this browser session. The sandbox starts in the project's
138+ * alarm (it can take a minute); the page shows "Starting" until then.
139+ */
140+export async function openTerminal(c: AppContext, l: Loaded, input: { agent?: string; ref?: string; accept?: boolean }): Promise<TerminalRow> {
141+ const me = requirePersonSession(c);
142+ if (!terminalsEnabled(c.env)) throw new HttpError(403, "terminals are switched off on this Diffinery");
143+ if (l.repo.archived) throw new HttpError(403, "repository is archived (read-only)");
144+ const dir = c.get("dir");
145+ const agent = input.agent ? await dir.accountByLogin(input.agent) : null;
146+ if (!agent || agent.kind !== "agent") throw new HttpError(422, "choose one of your agents");
147+ try {
148+ await taskCaller(dir, me, agent, l.repo);
149+ } catch (e) {
150+ if (e instanceof TaskAccessError) throw new HttpError(403, e.message);
151+ throw e;
152+ }
153+ const terminals = new Terminals(c.env);
154+ if (!(await terminals.termsAt(me.id))) {
155+ if (!input.accept) throw new HttpError(422, "accept the notice first: you use your own provider account, under its terms");
156+ await terminals.acceptTerms(me.id);
157+ audit(c, me, l.repo, "terminal.terms", { version: TERMINAL_TERMS_VERSION });
158+ }
159+ const ref = (input.ref ?? "").trim() || l.repo.default_branch;
160+ if (!(await l.stub.getRef(`refs/heads/${ref}`))) throw new HttpError(422, `the branch ${ref} doesn't exist`);
161+ const open = await terminals.open(me.id);
162+ if ((open.byRepo.get(l.repo.id) ?? 0) >= MAX_TERMINALS_PER_REPO) throw new HttpError(429, `you have ${MAX_TERMINALS_PER_REPO} terminals open in this repository, the most allowed; end one first`);
163+ if (open.total >= MAX_TERMINALS) throw new HttpError(429, `you have ${MAX_TERMINALS} terminals open, the most allowed; end one first`);
164+ // The account that owns the repository pays for the sandbox (lib/quotas.ts); say so now rather than
165+ // after it fails to start. RepoDO.createSandbox checks again when it starts.
166+ const quota = await mayStart(c.env, l.repo.owner_id, "sandbox");
167+ if (!quota.ok) throw new HttpError(429, quota.message);
168+ const t = await terminals.insert({ repo_id: l.repo.id, person_id: me.id, agent_id: agent.id, ref });
169+ audit(c, me, l.repo, "terminal.open", { terminal: t.id, agent: agent.login, ref, runtimes: { "claude-code": RUNTIME_VERSIONS["claude-code"], codex: RUNTIME_VERSIONS.codex } });
170+ await projectStub(c.env, l.repo.id).wakeTerminals(l.repo.id);
171+ return t;
172+}
173+
174+/** Ends a terminal: marks it ended, then stops its sandbox (which revokes its git credential). */
175+export async function endTerminal(env: Env, dir: Directory, t: TerminalRow, reason: TerminalEndReason, by?: Account): Promise<void> {
176+ if (!(await new Terminals(env).markEnded(t.id, reason))) return;
177+ if (t.sandbox_id) await env.REPO.get(env.REPO.idFromName(t.repo_id)).closeTaskSandbox(t.sandbox_id).catch((e) => console.error("terminal sandbox close failed", e));
178+ const repo = await dir.repoById(t.repo_id).catch(() => null);
179+ const actor = by ?? (await dir.account(t.person_id).catch(() => null));
180+ await dir
181+ .audit({ actorId: actor?.id ?? t.person_id, actorLogin: actor?.login ?? "unknown", action: "terminal.end", target: repo ? `${repo.owner_login}/${repo.name}` : t.repo_id, detail: { terminal: t.id, reason } })
182+ .catch((e) => console.error("audit failed", e));
183+}
184+
185+function audit(c: AppContext, me: Account, repo: Repo, action: string, detail: Record<string, unknown>): void {
186+ c.executionCtx.waitUntil(
187+ c
188+ .get("dir")
189+ .audit({ actorId: me.id, actorLogin: me.login, action, target: `${repo.owner_login}/${repo.name}`, detail })
190+ .catch((e) => console.error("audit failed", e)),
191+ );
192+}
modifiedapps/diffinery/src/env.ts+2−0View file
@@ -25,6 +25,8 @@
2525 */
2626 SANDBOX_POOL_URL?: string;
2727 SANDBOX_POOL_SECRET?: string;
28+ /** `off` switches web terminals (agentic/terminals.ts) off for the whole instance; on by default. */
29+ TERMINALS?: string;
2830 /** Local development and end-to-end tests only: where the model proxy forwards instead of the providers' APIs. */
2931 MODEL_UPSTREAM_ANTHROPIC?: string;
3032 MODEL_UPSTREAM_OPENAI?: string;
modifiedapps/diffinery/src/repo/actions.ts+8−6View file
@@ -2004,8 +2004,9 @@
20042004 * `task` is set only by agentic/ (never from a request): the sandbox of an agent task, started for
20052005 * the agent `caller` by the person `task.personId`. No public sandbox call reaches it. An agent
20062006 * review's sandbox is `readOnly`: stored with a `read` caller, its git credential can't push.
2007+ * A web terminal's sandbox (`kind: "terminal"`) is a task sandbox one person works in directly.
20072008 */
2008- async createSandbox(caller: Caller, ref?: string, task?: { personId: string; readOnly?: boolean }): Promise<{ id: string; ref: string }> {
2009+ async createSandbox(caller: Caller, ref?: string, task?: { personId: string; readOnly?: boolean; kind?: "task" | "terminal" }): Promise<{ id: string; ref: string }> {
20092010 if (!atLeast(caller.role, "write")) throw new HttpError(403, "write access is required to create a sandbox");
20102011 // A review's sandbox acts with read access only: its git credential can clone but never push.
20112012 if (task?.readOnly) caller = { ...caller, role: "read" };
@@ -2032,7 +2033,7 @@
20322033 JSON.stringify(caller),
20332034 ref ?? m.defaultBranch,
20342035 now(),
2035- task ? "task" : "api",
2036+ task ? (task.kind ?? "task") : "api",
20362037 task?.personId ?? null,
20372038 );
20382039 await startCompute(this.env, { id, accountId: m.ownerId, repoId: m.repoId, kind: "sandbox" });
@@ -2061,8 +2062,9 @@
20612062
20622063 getSandbox(id: string, caller: Caller): { id: string; ref: string; status: string; createdAt: string } {
20632064 const s = this.sql.exec<{ id: string; ref: string; status: string; created_at: string; created_by: string; kind: string }>("SELECT * FROM sandboxes WHERE id = ?", id).toArray()[0];
2064- // A task's sandbox holds a credential for the person's model access: nobody reaches it through the sandbox API.
2065- if (!s || s.kind === "task") throw new HttpError(404, "sandbox not found");
2065+ // A task's sandbox holds a credential for the person's model access, and a terminal's may hold their
2066+ // provider sign-in: nobody reaches either through the sandbox API, admins included.
2067+ if (!s || s.kind !== "api") throw new HttpError(404, "sandbox not found");
20662068 const owner = JSON.parse(s.created_by) as Caller;
20672069 if (owner.id !== caller.id && !atLeast(caller.role, "admin")) throw new HttpError(403, "not your sandbox");
20682070 return { id: s.id, ref: s.ref, status: s.status, createdAt: s.created_at };
@@ -2075,10 +2077,10 @@
20752077 await this.env.SANDBOX.get(this.env.SANDBOX.idFromName(id)).shutdown();
20762078 }
20772079
2078- /** Close an agent task's sandbox (agentic/), which revokes its git credential. */
2080+ /** Close an agent task's or a web terminal's sandbox (agentic/), which revokes its git credential. */
20792081 async closeTaskSandbox(id: string): Promise<void> {
20802082 const s = this.sql.exec<{ kind: string }>("SELECT kind FROM sandboxes WHERE id = ?", id).toArray()[0];
2081- if (s?.kind !== "task") return;
2083+ if (s?.kind !== "task" && s?.kind !== "terminal") return;
20822084 this.sql.exec("UPDATE sandboxes SET status = 'closed' WHERE id = ?", id);
20832085 await endCompute(this.env, id);
20842086 await this.env.SANDBOX.get(this.env.SANDBOX.idFromName(id)).shutdown();
modifiedapps/diffinery/src/runner/containers.ts+58−0View file
@@ -87,6 +87,25 @@
8787 if (this.ctx.container?.running) await this.stop();
8888 }
8989
90+ /** Whether the container is up (calling into a stopped one would start it without its checkout). */
91+ async running(): Promise<boolean> {
92+ return !!this.ctx.container?.running && !!(await this.ctx.storage.get<string>("secret"));
93+ }
94+
95+ /**
96+ * A web terminal's socket (agentic/terminal-web.ts checked who is asking): only a WebSocket
97+ * upgrade to /pty, with the sandbox's secret added. A sandbox that isn't running isn't started
98+ * again (it would come up without its checkout), and nothing else of the sandbox is reachable here.
99+ */
100+ override async fetch(request: Request): Promise<Response> {
101+ const url = new URL(request.url);
102+ if (url.pathname !== "/pty" || request.headers.get("upgrade")?.toLowerCase() !== "websocket") return new Response("not found", { status: 404 });
103+ const secret = await this.ctx.storage.get<string>("secret");
104+ if (!secret || !this.ctx.container?.running) return new Response("the terminal isn't running", { status: 410 });
105+ const headers = new Headers({ upgrade: "websocket", authorization: `Bearer ${secret}` });
106+ return this.containerFetch(new Request(`http://sandbox/pty${url.search}`, { headers }), 8080);
107+ }
108+
90109 override async onStop(): Promise<void> {
91110 const usage = await this.ctx.storage.get<{ id: string; part?: number }>("usage");
92111 if (usage) await endCompute(this.env, usage.part ? `${usage.id}:${usage.part}` : usage.id);
@@ -122,4 +141,43 @@
122141 const name = await this.ctx.storage.get<string>("name");
123142 if (name) await fetch(`${this.env.SANDBOX_POOL_URL}/shutdown/${name}`, { method: "POST", headers: this.poolAuth() }).catch(() => {});
124143 }
144+
145+ async running(): Promise<boolean> {
146+ if (!(await this.ctx.storage.get<string>("name"))) return false;
147+ return (await this.call("/pty/status", "{}").catch(() => ({ status: 0 }))).status === 200;
148+ }
149+
150+ /** As SandboxContainer.fetch: a terminal's WebSocket, through the pool. */
151+ override async fetch(request: Request): Promise<Response> {
152+ const url = new URL(request.url);
153+ if (url.pathname !== "/pty" || request.headers.get("upgrade")?.toLowerCase() !== "websocket") return new Response("not found", { status: 404 });
154+ const [secret, name] = await Promise.all([this.ctx.storage.get<string>("secret"), this.ctx.storage.get<string>("name")]);
155+ if (!secret || !name) return new Response("the terminal isn't running", { status: 410 });
156+ const res = await fetch(`${this.env.SANDBOX_POOL_URL}/s/${name}/pty${url.search}`, { headers: { upgrade: "websocket", authorization: `Bearer ${secret}` } });
157+ if (!res.webSocket) return new Response(await res.text(), { status: res.status === 101 ? 502 : res.status });
158+ return pipeSocket(res.webSocket);
159+ }
125160 }
161+
162+/** Hands a WebSocket this Durable Object holds to the caller, relaying frames and closes both ways. */
163+function pipeSocket(upstream: WebSocket): Response {
164+ const [client, server] = Object.values(new WebSocketPair()) as [WebSocket, WebSocket];
165+ upstream.accept();
166+ server.accept();
167+ const code = (c: number) => (c === 1005 || c === 1006 ? 1000 : c);
168+ // Binary frames can arrive as Blobs; reading one is async, so each direction keeps its order with a chain.
169+ const relay = (from: WebSocket, to: WebSocket) => {
170+ let chain = Promise.resolve();
171+ from.addEventListener("message", (e) => {
172+ const data = e.data as string | ArrayBuffer | Blob;
173+ chain = chain.then(async () => to.send(data instanceof Blob ? await data.arrayBuffer() : data)).catch(() => to.close(1011, "relay failed"));
174+ });
175+ };
176+ relay(server, upstream);
177+ relay(upstream, server);
178+ server.addEventListener("close", (e) => upstream.close(code(e.code), e.reason));
179+ upstream.addEventListener("close", (e) => server.close(code(e.code), e.reason));
180+ server.addEventListener("error", () => upstream.close(1011, "client error"));
181+ upstream.addEventListener("error", () => server.close(1011, "sandbox error"));
182+ return new Response(null, { status: 101, webSocket: client });
183+}
modifiedapps/diffinery/src/web/assets.ts+2−0View file
@@ -261,6 +261,8 @@
261261 .tx-note{padding-left:10px;border-left:2px solid var(--border)}
262262 .tx-result{padding:10px 12px;border:1px solid var(--border);border-radius:var(--r);background:var(--subtle,transparent)}
263263 .tx-tool summary{cursor:pointer;color:var(--muted)}.tx-tool[open] summary{margin-bottom:6px}
264+.term{height:min(70vh,760px);min-height:320px;padding:8px;background:#111113;border:1px solid var(--border);border-radius:var(--r);overflow:hidden}.term .xterm{height:100%}
265+.term-status[data-tone=ok]{color:var(--green)}.term-status[data-tone=warn]{color:var(--amber)}
264266 .tx-pre{margin:0;padding:8px 10px;background:#0d1014;color:#d5d9de;border-radius:var(--r);font-size:12px;white-space:pre-wrap;word-break:break-word;max-height:360px;overflow:auto}
265267 .tx-err{border-left:3px solid var(--red)}
266268 .tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin:0 0 16px}
modifiedapps/diffinery/src/web/routes.tsx+2−0View file
@@ -6,6 +6,7 @@
66 import { registerTasks } from "../agentic/tasks-web.js";
77 import { registerReviews } from "../agentic/review-web.js";
88 import { registerProject } from "../agentic/project-web.js";
9+import { registerTerminals } from "../agentic/terminal-web.js";
910 import { registerMailWeb } from "../mail/web.js";
1011 import { registerAuth } from "./auth.js";
1112 import { registerActions } from "./actions.js";
@@ -49,6 +50,7 @@
4950 registerTasks(web);
5051 registerReviews(web);
5152 registerProject(web);
53+registerTerminals(web);
5254 registerAccount(web);
5355 registerRepoSettings(web);
5456 registerActions(web);
modifiedapps/diffinery/src/web/ui.tsx+5−0View file
@@ -292,6 +292,8 @@
292292 head?: Child;
293293 /** A page script, run after the site's own (it gets the page's CSP nonce). */
294294 script?: string;
295+ /** Same-origin script files for this page only (e.g. the web terminal's bundle), loaded with the nonce. */
296+ scriptSrc?: string[];
295297 /** Reload the page every this many seconds (progress pages without live updates). */
296298 refresh?: number;
297299 }
@@ -516,6 +518,9 @@
516518 <script nonce={nonce} src={kumoAssets().js} defer></script>
517519 <script nonce={nonce}>{raw(JS)}</script>
518520 {opts.script ? <script nonce={nonce}>{raw(opts.script)}</script> : null}
521+ {(opts.scriptSrc ?? []).map((src) => (
522+ <script nonce={nonce} src={src} defer></script>
523+ ))}
519524 </body>
520525 </html>
521526 );
modifiedapps/diffinery/test/e2e/tasks.test.mjs+126−1View file
@@ -13,7 +13,8 @@
1313 import { spawn, execFileSync } from "node:child_process";
1414 import { mkdtempSync, writeFileSync, mkdirSync, rmSync, readdirSync, statSync, readFileSync, existsSync } from "node:fs";
1515 import { tmpdir } from "node:os";
16-import { randomBytes } from "node:crypto";
16+import { createHash, randomBytes } from "node:crypto";
17+import { createRequire } from "node:module";
1718 import { join, dirname, resolve } from "node:path";
1819 import { fileURLToPath } from "node:url";
1920 import { createServer as netServer } from "node:net";
@@ -25,6 +26,9 @@
2526 const AGENT = resolve(APP, "../../containers/runner/dist/agent.mjs");
2627 const work = mkdtempSync(join(tmpdir(), "diffinery-tasks-e2e-"));
2728 const poolRoot = join(work, "pool");
29+// The terminal test speaks WebSocket with headers (Origin, cookies), which Node's built-in client can't set.
30+const { WebSocket: WS } = createRequire(resolve(APP, "../../containers/runner/package.json"))("ws");
31+const RIVAL_SESSION = `rival-${randomBytes(12).toString("hex")}`;
2832 const ANTHROPIC_KEY = `sk-ant-api03-${randomBytes(30).toString("hex")}`;
2933 const OPENROUTER_KEY = `sk-or-v1-${randomBytes(32).toString("hex")}`;
3034 let base;
@@ -131,6 +135,13 @@
131135 base = `http://127.0.0.1:${port}`;
132136 const persist = join(work, "state");
133137 execFileSync("pnpm", ["exec", "wrangler", "d1", "migrations", "apply", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } });
138+ // A second person, signed in with a session: not the terminal's opener, whatever their role.
139+ {
140+ const at = new Date().toISOString();
141+ const later = new Date(Date.now() + 30 * 86400_000).toISOString();
142+ const hash = createHash("sha256").update(RIVAL_SESSION).digest("hex");
143+ execFileSync("pnpm", ["exec", "wrangler", "d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command", `INSERT INTO accounts (id, login, kind, name, status, created_at) VALUES ('u-rival', 'rival', 'user', 'Rival', 'active', '${at}'); INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('${hash}', 'u-rival', 'passkey', '${at}', '${at}', '${later}')`], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } });
144+ }
134145 const vars = { PUBLIC_URL: base, MASTER_KEY: randomBytes(32).toString("base64"), SANDBOX_POOL_URL: `http://127.0.0.1:${poolPort}`, SANDBOX_POOL_SECRET: poolSecret, MODEL_UPSTREAM_ANTHROPIC: upstreamUrl, MODEL_UPSTREAM_OPENAI: upstreamUrl, MODEL_UPSTREAM_OPENROUTER: upstreamUrl };
135146 dev = spawn("pnpm", ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--inspector-port", String(await freePort()), "--persist-to", persist, ...Object.entries(vars).flatMap(([k, v]) => ["--var", `${k}:${v}`]), "--show-interactive-dev-session=false"], {
136147 cwd: APP,
@@ -1087,3 +1098,117 @@
10871098 assert.equal(t.state, "stopped");
10881099 assert.equal((await fetch(`${base}/-/model/anthropic/v1/messages`, { method: "POST", headers: { "x-api-key": runToken }, body: "{}" })).status, 401);
10891100 });
1101+
1102+/** Opens a terminal's WebSocket; resolves with the HTTP status when the upgrade is refused. */
1103+function termSocket(url, headers) {
1104+ return new Promise((resolve) => {
1105+ const ws = new WS(url, { headers });
1106+ let out = "";
1107+ ws.on("message", (d, binary) => {
1108+ if (binary) out += String(d);
1109+ });
1110+ let closedWith = null;
1111+ const closed = new Promise((r) => ws.on("close", (code, reason) => r((closedWith = { code, reason: String(reason) }))));
1112+ ws.once("unexpected-response", (_req, res) => resolve({ status: res.statusCode }));
1113+ ws.once("error", (e) => resolve({ status: 0, error: e.message }));
1114+ ws.once("open", () =>
1115+ resolve({
1116+ status: 101,
1117+ ws,
1118+ closed,
1119+ out: () => out,
1120+ type: (text) => ws.send(Buffer.from(text)),
1121+ until: (re, ms = 30_000) => until(`${re} in the terminal (got ${JSON.stringify(out.slice(-300))}, closed ${JSON.stringify(closedWith)})`, async () => re.test(out), ms),
1122+ }),
1123+ );
1124+ });
1125+}
1126+
1127+test("a web terminal: only its opener attaches, the sandbox API can't reach it, and End takes it away", async () => {
1128+ const me = await ok("GET", "/user");
1129+ const owner = me.login;
1130+ await ok("POST", "/user/repos", { name: "term-demo" });
1131+ await ok("PUT", `/repos/${owner}/term-demo/contents/README.md`, { message: "init", content: Buffer.from("# term\n").toString("base64") });
1132+ assert.equal((await asPerson("POST", "/diffinery/agents", { login: "term-bot", name: "Term Bot" })).status, 201);
1133+ const page = (path) => fetch(`${base}/${owner}/term-demo/${path}`).then(async (r) => ({ status: r.status, text: await r.text(), headers: r.headers }));
1134+ async function form(path, fields, headers = {}) {
1135+ const res = await fetch(`${base}/${owner}/term-demo/terminals`, { headers });
1136+ const csrf = /name="_csrf" value="([^"]+)"/.exec(await res.text())?.[1];
1137+ const cookie = /dfy_csrf=([^;]+)/.exec(res.headers.get("set-cookie") ?? "")?.[1];
1138+ return fetch(`${base}/${owner}/term-demo/${path}`, { method: "POST", redirect: "manual", headers: { ...headers, cookie: [headers.cookie, `dfy_csrf=${cookie}`].filter(Boolean).join("; "), "sec-fetch-site": "same-origin" }, body: new URLSearchParams({ _csrf: csrf ?? "", ...fields }) });
1139+ }
1140+
1141+ // The notice comes first, and it is a Kumo checkbox, not a native one.
1142+ const list = await page("terminals");
1143+ assert.equal(list.status, 200);
1144+ assert.match(list.text, /Open a terminal/);
1145+ assert.match(list.text, /my own Anthropic or OpenAI account/);
1146+ assert.ok(!/<input[^>]+type="checkbox"/.test(list.text.replace(/<input[^>]+data-k[^>]*>/g, "")), "no native checkbox");
1147+ const refused = await form("terminals", { agent: "term-bot", ref: "main" });
1148+ assert.equal(refused.status, 422);
1149+ assert.match(await refused.text(), /accept the notice first/);
1150+ const opened = await form("terminals", { agent: "term-bot", ref: "main", accept: "1" });
1151+ assert.equal(opened.status, 303, await opened.text());
1152+ const path = new URL(opened.headers.get("location"), base).pathname;
1153+ const id = path.split("/").pop();
1154+ const running = await until("the terminal to start", async () => {
1155+ const p = await page(`terminals/${id}`);
1156+ return p.text.includes("data-socket") ? p : null;
1157+ });
1158+ assert.match(running.text, /Start Claude Code/);
1159+ assert.match(running.text, /Start Codex/, "a private repository offers Codex");
1160+ assert.match(running.text, /\/-\/terminal\/[a-z0-9]+\.js/);
1161+ const sandboxId = poolSandboxes()[0];
1162+
1163+ // The socket: this site's Origin, a browser session, and only the opener.
1164+ const url = `${base.replace("http", "ws")}/${owner}/term-demo/terminals/${id}/socket?cols=100&rows=30`;
1165+ const origin = new URL(base).origin;
1166+ assert.equal((await termSocket(url, {})).status, 403, "no Origin");
1167+ assert.equal((await termSocket(url, { origin: "https://evil.example" })).status, 403, "another Origin");
1168+ assert.equal((await termSocket(url, { origin, authorization: `Bearer ${token}` })).status, 403, "a token, even the opener's own admin token");
1169+ assert.equal((await termSocket(url, { origin, cookie: `__Host-dfy_session=${RIVAL_SESSION}` })).status, 404, "another person");
1170+ const t = await termSocket(url, { origin });
1171+ assert.equal(t.status, 101, JSON.stringify(t));
1172+ t.type("stty size; echo \"s=[$DIFFINERY_SANDBOX_SECRET] t=[$DIFFINERY_JOB_TOKEN]\"; git rev-parse --abbrev-ref HEAD; echo hi-$((6*7))\n");
1173+ await t.until(/hi-42/);
1174+ assert.match(t.out(), /30 100/);
1175+ assert.match(t.out(), /s=\[\] t=\[\]/);
1176+ assert.match(t.out(), /\bmain\b/);
1177+
1178+ // Another person, even one the owner made an admin of the repository, sees nothing of it.
1179+ const invite = await ok("PUT", `/repos/${owner}/term-demo/collaborators/rival`, { permission: "admin" });
1180+ if (invite?.id) await fetch(`${base}/api/v3/user/repository_invitations/${invite.id}`, { method: "PATCH", headers: { cookie: `__Host-dfy_session=${RIVAL_SESSION}`, "sec-fetch-site": "same-origin" } });
1181+ const rival = { cookie: `__Host-dfy_session=${RIVAL_SESSION}` };
1182+ assert.equal((await fetch(`${base}/${owner}/term-demo/terminals/${id}`, { headers: rival })).status, 404);
1183+ assert.ok(!(await (await fetch(`${base}/${owner}/term-demo/terminals`, { headers: rival })).text()).includes(id));
1184+ assert.equal((await form(`terminals/${id}/end`, {}, rival)).status, 404);
1185+ assert.equal((await termSocket(url, { origin, ...rival })).status, 404);
1186+
1187+ // The sandbox API and MCP never reach a terminal's sandbox, not even for a site admin's admin token.
1188+ assert.equal((await api("POST", `/repos/${owner}/term-demo/diffinery/sandboxes/${sandboxId}/exec`, { cmd: "cat ~/.claude/.credentials.json" })).status, 404);
1189+ assert.equal((await api("POST", `/repos/${owner}/term-demo/diffinery/sandboxes/${sandboxId}/read`, { path: "/root/.codex/auth.json" })).status, 404);
1190+ assert.equal((await api("POST", `/repos/${owner}/term-demo/diffinery/sandboxes/${sandboxId}/close`)).status, 404);
1191+ const viaMcp = await mcp("sandbox_exec", { owner, repo: "term-demo", sandbox_id: sandboxId, command: "id" });
1192+ assert.ok(viaMcp?.isError, JSON.stringify(viaMcp));
1193+
1194+ // Limits: two open per repository.
1195+ const second = await form("terminals", { agent: "term-bot", ref: "main" });
1196+ assert.equal(second.status, 303);
1197+ const third = await form("terminals", { agent: "term-bot", ref: "main" });
1198+ assert.equal(third.status, 422);
1199+ assert.match(await third.text(), /2 terminals open in this repository/);
1200+ const secondId = new URL(second.headers.get("location"), base).pathname.split("/").pop();
1201+
1202+ // End: the socket closes, the sandbox and its git credential go, and it can't be attached again.
1203+ assert.equal((await form(`terminals/${id}/end`, {})).status, 303);
1204+ const closed = await t.closed;
1205+ assert.ok([1000, 1006, 1011].includes(closed.code), JSON.stringify(closed));
1206+ assert.equal((await termSocket(url, { origin })).status, 409);
1207+ assert.match((await page(`terminals/${id}`)).text, /You ended it/);
1208+ await until("the pool to stop the sandbox", async () => !poolSandboxes().includes(sandboxId) || (await fetch(`${base}/${owner}/term-demo/terminals/${id}`)).ok);
1209+
1210+ // Losing access ends a terminal too: the agent's write access goes, and the next check ends it.
1211+ await until("the second terminal to start", async () => (await page(`terminals/${secondId}`)).text.includes("data-socket"));
1212+ assert.equal((await api("DELETE", "/diffinery/agents/term-bot")).status, 204);
1213+ await until("the second terminal to end", async () => /no longer have write access|ended/i.test((await page(`terminals/${secondId}`)).text) && !(await page(`terminals/${secondId}`)).text.includes("data-socket"), 150_000);
1214+});
modifiedcontainers/runner/package.json+13−3View file
@@ -4,10 +4,20 @@
44 "private": true,
55 "type": "module",
66 "scripts": {
7- "build": "esbuild src/main.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/agent.mjs \"--banner:js=import { createRequire as __diffineryCreateRequire } from 'node:module'; const require = __diffineryCreateRequire(import.meta.url);\"",
7+ "build": "esbuild src/main.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/agent.mjs --external:bufferutil --external:utf-8-validate \"--banner:js=import { createRequire as __diffineryCreateRequire } from 'node:module'; const require = __diffineryCreateRequire(import.meta.url);\"",
88 "typecheck": "tsc --noEmit",
99 "test": "vitest run"
1010 },
11- "dependencies": { "@diffinery/shared": "workspace:*", "@diffinery/workflow-yaml": "workspace:*" },
12- "devDependencies": { "typescript": "^5.9.3", "vitest": "^4.1.0", "esbuild": "^0.25.0", "@types/node": "^22.0.0" }
11+ "dependencies": {
12+ "@diffinery/shared": "workspace:*",
13+ "@diffinery/workflow-yaml": "workspace:*",
14+ "ws": "8.18.3"
15+ },
16+ "devDependencies": {
17+ "@types/node": "^22.0.0",
18+ "@types/ws": "8.18.1",
19+ "esbuild": "^0.25.0",
20+ "typescript": "^5.9.3",
21+ "vitest": "^4.1.0"
22+ }
1323 }
addedcontainers/runner/src/pty-helper.ts+62−0View file
@@ -1,0 +1,62 @@
1+/**
2+ * The terminal's pseudo-terminal, as a small Python program (standard library only, so the image
3+ * needs no native Node module). It forks a login shell on a PTY, copies its stdin to the PTY and the
4+ * PTY to its stdout, and reads control lines (JSON) on fd 3: `{"cols": 120, "rows": 40}` resizes.
5+ * It exits with the shell.
6+ */
7+export const PTY_HELPER = String.raw`
8+import fcntl, json, os, pty, select, signal, struct, sys, termios
9+
10+def winsize(fd, rows, cols):
11+ fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
12+
13+pid, fd = pty.fork()
14+if pid == 0:
15+ shell = os.environ.get("SHELL") or "/bin/bash"
16+ os.execvp(shell, [shell, "-l"])
17+
18+winsize(fd, int(os.environ.get("DIFFINERY_PTY_ROWS", "24")), int(os.environ.get("DIFFINERY_PTY_COLS", "80")))
19+stdin, stdout = sys.stdin.fileno(), sys.stdout.fileno()
20+pending = b""
21+watch = [fd, stdin, 3]
22+while True:
23+ try:
24+ ready, _, _ = select.select(watch, [], [])
25+ except InterruptedError:
26+ continue
27+ if fd in ready:
28+ try:
29+ data = os.read(fd, 65536)
30+ except OSError:
31+ data = b""
32+ if not data:
33+ break
34+ os.write(stdout, data)
35+ if stdin in ready:
36+ data = os.read(stdin, 65536)
37+ if not data:
38+ watch.remove(stdin)
39+ else:
40+ os.write(fd, data)
41+ if 3 in ready:
42+ data = os.read(3, 4096)
43+ if not data:
44+ watch.remove(3)
45+ continue
46+ pending += data
47+ while b"\n" in pending:
48+ line, pending = pending.split(b"\n", 1)
49+ try:
50+ m = json.loads(line)
51+ rows, cols = int(m["rows"]), int(m["cols"])
52+ if 1 <= rows <= 1000 and 1 <= cols <= 1000:
53+ winsize(fd, rows, cols)
54+ except Exception:
55+ pass
56+try:
57+ os.kill(pid, signal.SIGHUP)
58+except OSError:
59+ pass
60+_, status = os.waitpid(pid, 0)
61+sys.exit(os.waitstatus_to_exitcode(status) if hasattr(os, "waitstatus_to_exitcode") else 0)
62+`;
modifiedcontainers/runner/src/sandbox-pool.ts+25−1View file
@@ -1,6 +1,6 @@
11 import { timingSafeEqual } from "node:crypto";
22 import { createServer, request, type IncomingMessage, type Server, type ServerResponse } from "node:http";
3-import type { AddressInfo } from "node:net";
3+import { connect, type AddressInfo } from "node:net";
44 import path from "node:path";
55 import { startSandbox, type SandboxHandle } from "./sandbox.js";
66
@@ -82,6 +82,30 @@
8282 }
8383 })();
8484 });
85+ // WebSocket upgrades (the terminal) go through as raw TCP; the sandbox checks its own secret.
86+ server.on("upgrade", (req, socket, head) => {
87+ const url = new URL(req.url ?? "/", "http://pool");
88+ const m = /^\/s\/([A-Za-z0-9_-]+)(\/.*)$/.exec(url.pathname);
89+ const sb = m ? sandboxes.get(m[1]!) : undefined;
90+ if (!m || !sb) {
91+ socket.end("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
92+ return;
93+ }
94+ const up = connect(sb.port, "127.0.0.1", () => {
95+ const lines = [`${req.method} ${m[2]!}${url.search} HTTP/1.1`];
96+ for (let i = 0; i < req.rawHeaders.length; i += 2) lines.push(`${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}`);
97+ up.write(`${lines.join("\r\n")}\r\n\r\n`);
98+ if (head.length) up.write(head);
99+ up.pipe(socket);
100+ socket.pipe(up);
101+ });
102+ const end = () => {
103+ up.destroy();
104+ socket.destroy();
105+ };
106+ up.on("error", end);
107+ socket.on("error", end);
108+ });
85109 await new Promise<void>((resolve, reject) => {
86110 server.once("error", reject);
87111 server.listen(o.port, o.host ?? "127.0.0.1", () => resolve());
modifiedcontainers/runner/src/sandbox.ts+31−0View file
@@ -4,9 +4,11 @@
44 import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
55 import type { AddressInfo } from "node:net";
66 import path from "node:path";
7+import { WebSocketServer } from "ws";
78 import { basicAuthHeader } from "./builtins/checkout.js";
89 import { Masker } from "./mask.js";
910 import { ProcTable } from "./procs.js";
11+import { Terminal } from "./terminal.js";
1012 import { exec } from "./util.js";
1113
1214 export interface SandboxOptions {
@@ -141,6 +143,14 @@
141143 return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, String(x)]));
142144 };
143145
146+ // The web terminal's shell: interactive, so not CI, and without the sandbox's own credentials.
147+ const terminal = new Terminal(o.workRoot, () => (existsSync(repoDir) ? repoDir : o.workRoot), () => {
148+ const env = baseEnv();
149+ delete env.CI;
150+ return { ...env, IS_SANDBOX: "1" };
151+ });
152+ const sockets = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 });
153+
144154 const resolvePath = (p: unknown): string => {
145155 if (typeof p !== "string" || !p) throw new HttpErr(400, "`path` is required");
146156 return path.isAbsolute(p) ? p : path.join(repoDir, p);
@@ -229,6 +239,15 @@
229239 }
230240 return;
231241 }
242+ case "/pty/status": {
243+ send(res, 200, terminal.status());
244+ return;
245+ }
246+ case "/pty/kill": {
247+ terminal.kill();
248+ send(res, 200, { ok: true });
249+ return;
250+ }
232251 case "/proc/kill": {
233252 if (typeof body.id !== "string") throw new HttpErr(400, "`id` is required");
234253 const sig = body.signal === "SIGKILL" || body.signal === "SIGINT" ? body.signal : "SIGTERM";
@@ -245,6 +264,17 @@
245264 })();
246265 });
247266
267+ // GET /pty with Upgrade: websocket attaches to the terminal (?cols=&rows= for its first size).
268+ server.on("upgrade", (req, socket, head) => {
269+ const url = new URL(req.url ?? "/", "http://sandbox");
270+ if (url.pathname !== "/pty" || !authorized(req, o.secret)) {
271+ socket.end(`HTTP/1.1 ${url.pathname !== "/pty" ? "404 Not Found" : "401 Unauthorized"}\r\nConnection: close\r\n\r\n`);
272+ return;
273+ }
274+ // Open only once the checkout is there, so nothing typed arrives before the shell listens.
275+ void ready.then(() => sockets.handleUpgrade(req, socket, head, (ws) => terminal.attach(ws, { cols: Number(url.searchParams.get("cols")), rows: Number(url.searchParams.get("rows")) })));
276+ });
277+
248278 await new Promise<void>((resolve, reject) => {
249279 server.once("error", reject);
250280 server.listen(o.port, o.host ?? "0.0.0.0", () => resolve());
@@ -257,6 +287,7 @@
257287 ready,
258288 close: () => {
259289 procs.killAll();
290+ terminal.kill();
260291 return new Promise<void>((r) => server.close(() => r()));
261292 },
262293 };
addedcontainers/runner/src/terminal.ts+114−0View file
@@ -1,0 +1,114 @@
1+import { spawn, type ChildProcess } from "node:child_process";
2+import { writeFileSync } from "node:fs";
3+import path from "node:path";
4+import type { WebSocket } from "ws";
5+import { PTY_HELPER } from "./pty-helper.js";
6+
7+/** What the screen is redrawn from when someone attaches again. */
8+export const SCROLLBACK_BYTES = 256 * 1024;
9+
10+/**
11+ * The sandbox's one interactive shell (a web terminal, phase 4). The shell keeps running while
12+ * nobody is attached; attaching sends the recent output so the screen can be redrawn. A new
13+ * connection replaces the previous one (the newest tab wins). Output is not masked: a terminal's
14+ * sandbox holds no secret of anyone else's, and its git credential is the agent's own.
15+ */
16+export class Terminal {
17+ private shell: ChildProcess | null = null;
18+ private socket: WebSocket | null = null;
19+ private scrollback: Buffer[] = [];
20+ private scrollbackBytes = 0;
21+ private detachedAt: number = Date.now();
22+ private helperPath: string;
23+
24+ constructor(
25+ private workRoot: string,
26+ private cwd: () => string,
27+ private env: () => NodeJS.ProcessEnv,
28+ ) {
29+ this.helperPath = path.join(workRoot, ".pty.py");
30+ }
31+
32+ /** For the Worker's idle rule: whether someone is attached, and since when nobody has been. */
33+ status(): { attached: boolean; running: boolean; idleSince: string | null } {
34+ return { attached: !!this.socket, running: !!this.shell, idleSince: this.socket ? null : new Date(this.detachedAt).toISOString() };
35+ }
36+
37+ attach(ws: WebSocket, size: { cols: number; rows: number }): void {
38+ const previous = this.socket;
39+ this.socket = ws;
40+ if (previous) previous.close(4000, "opened in another tab");
41+ if (!this.shell) this.start(size);
42+ else this.resize(size);
43+ if (this.scrollbackBytes) ws.send(Buffer.concat(this.scrollback));
44+ ws.on("message", (data, isBinary) => {
45+ if (this.socket !== ws) return;
46+ if (isBinary) {
47+ this.shell?.stdin?.write(data as Buffer);
48+ return;
49+ }
50+ let m: { type?: unknown; cols?: unknown; rows?: unknown };
51+ try {
52+ m = JSON.parse(String(data)) as typeof m;
53+ } catch {
54+ return;
55+ }
56+ if (m.type === "resize") this.resize({ cols: Number(m.cols), rows: Number(m.rows) });
57+ else if (m.type === "ping") ws.send(JSON.stringify({ type: "pong" }));
58+ });
59+ const detach = () => {
60+ if (this.socket !== ws) return;
61+ this.socket = null;
62+ this.detachedAt = Date.now();
63+ };
64+ ws.on("close", detach);
65+ ws.on("error", detach);
66+ }
67+
68+ kill(): void {
69+ this.shell?.kill("SIGHUP");
70+ this.socket?.close(1000, "terminal ended");
71+ }
72+
73+ private start(size: { cols: number; rows: number }): void {
74+ writeFileSync(this.helperPath, PTY_HELPER, { mode: 0o600 });
75+ this.scrollback = [];
76+ this.scrollbackBytes = 0;
77+ const { cols, rows } = clampSize(size);
78+ const shell = spawn("python3", [this.helperPath], {
79+ cwd: this.cwd(),
80+ env: { ...this.env(), TERM: "xterm-256color", COLORTERM: "truecolor", LANG: process.env.LANG ?? "C.UTF-8", DIFFINERY_PTY_COLS: String(cols), DIFFINERY_PTY_ROWS: String(rows) },
81+ stdio: ["pipe", "pipe", "inherit", "pipe"],
82+ });
83+ this.shell = shell;
84+ shell.stdout!.on("data", (chunk: Buffer) => {
85+ this.remember(chunk);
86+ this.socket?.send(chunk);
87+ });
88+ shell.on("exit", () => {
89+ if (this.shell !== shell) return;
90+ this.shell = null;
91+ this.socket?.close(1000, "the shell exited");
92+ });
93+ shell.on("error", (e) => {
94+ process.stderr.write(`[sandbox] terminal: ${e.message}\n`);
95+ });
96+ }
97+
98+ private resize(size: { cols: number; rows: number }): void {
99+ const ctl = this.shell?.stdio[3];
100+ if (!ctl || !("write" in ctl)) return;
101+ (ctl as NodeJS.WritableStream).write(`${JSON.stringify(clampSize(size))}\n`);
102+ }
103+
104+ private remember(chunk: Buffer): void {
105+ this.scrollback.push(chunk);
106+ this.scrollbackBytes += chunk.length;
107+ while (this.scrollbackBytes > SCROLLBACK_BYTES && this.scrollback.length > 1) this.scrollbackBytes -= this.scrollback.shift()!.length;
108+ }
109+}
110+
111+export function clampSize(size: { cols: number; rows: number }): { cols: number; rows: number } {
112+ const n = (v: number, d: number) => (Number.isFinite(v) ? Math.min(1000, Math.max(1, Math.round(v))) : d);
113+ return { cols: n(size.cols, 80), rows: n(size.rows, 24) };
114+}
modifiedcontainers/runner/test/sandbox.test.ts+82−0View file
@@ -2,6 +2,7 @@
22 import { readFileSync, rmSync, writeFileSync } from "node:fs";
33 import path from "node:path";
44 import { afterAll, beforeAll, describe, expect, it } from "vitest";
5+import { WebSocket } from "ws";
56 import { startSandbox, type SandboxHandle } from "../src/sandbox.js";
67 import { startSandboxPool } from "../src/sandbox-pool.js";
78 import { tmp } from "./helpers.js";
@@ -151,3 +152,84 @@
151152 });
152153 });
153154
155+
156+/** A terminal connection that collects the screen output. */
157+async function term(url: string, auth = SECRET) {
158+ const ws = new WebSocket(url, { headers: { authorization: `Bearer ${auth}` } });
159+ let out = "";
160+ const closed = new Promise<{ code: number; reason: string }>((r) => ws.on("close", (code, reason) => r({ code, reason: String(reason) })));
161+ ws.on("message", (d, binary) => {
162+ if (binary) out += String(d);
163+ });
164+ await new Promise<void>((resolve, reject) => {
165+ ws.once("open", () => resolve());
166+ ws.once("unexpected-response", (_req, res) => reject(new Error(`status ${res.statusCode}`)));
167+ ws.once("error", reject);
168+ });
169+ const until = async (re: RegExp, ms = 10_000) => {
170+ const end = Date.now() + ms;
171+ while (!re.test(out)) {
172+ if (Date.now() > end) throw new Error(`timed out waiting for ${re} in ${JSON.stringify(out.slice(-500))}`);
173+ await new Promise((r) => setTimeout(r, 50));
174+ }
175+ };
176+ return { ws, out: () => out, until, closed, type: (s: string) => ws.send(Buffer.from(s)), control: (m: unknown) => ws.send(JSON.stringify(m)) };
177+}
178+
179+describe("sandbox terminal", () => {
180+ it("runs one shell on a PTY in the checkout, resizes it, and redraws it for the next tab", async () => {
181+ const ws = base.replace("http", "ws");
182+ await expect(term(`${ws}/pty`, "wrong")).rejects.toThrow(/401/);
183+ expect((await call("/pty/status", {})).json).toMatchObject({ attached: false, running: false });
184+ const a = await term(`${ws}/pty?cols=100&rows=30`);
185+ a.type("tty; stty size; pwd; echo \"s=[$DIFFINERY_SANDBOX_SECRET] t=[$DIFFINERY_JOB_TOKEN] ci=[$CI]\"; echo mark-$((40+2))\n");
186+ await a.until(/mark-42/);
187+ expect(a.out()).toMatch(/\/dev\/pts\/\d+/);
188+ expect(a.out()).toContain("30 100");
189+ expect(a.out()).toContain(sb.repoDir);
190+ expect(a.out()).toContain("s=[] t=[] ci=[]");
191+ a.control({ type: "resize", cols: 132, rows: 43 });
192+ a.type("stty size\n");
193+ await a.until(/43 132/);
194+ expect((await call("/pty/status", {})).json).toMatchObject({ attached: true, running: true, idleSince: null });
195+
196+ // A second tab takes over the same shell and gets the recent output; the first is told why it closed.
197+ a.type("export KEPT=yes\n");
198+ const b = await term(`${ws}/pty?cols=80&rows=24`);
199+ expect((await a.closed).code).toBe(4000);
200+ await b.until(/mark-42/);
201+ b.type("echo kept=$KEPT\n");
202+ await b.until(/kept=yes/);
203+ b.ws.close();
204+ await b.closed;
205+ await new Promise((r) => setTimeout(r, 100));
206+ const idle = (await call("/pty/status", {})).json;
207+ expect(idle).toMatchObject({ attached: false, running: true });
208+ expect(Date.parse(idle.idleSince)).toBeGreaterThan(Date.now() - 5000);
209+
210+ // Exiting the shell closes the connection; the next attach starts a fresh one.
211+ const c = await term(`${ws}/pty`);
212+ c.type("exit\n");
213+ expect((await c.closed).reason).toBe("the shell exited");
214+ const d = await term(`${ws}/pty`);
215+ d.type("echo fresh=${KEPT:-none}\n");
216+ await d.until(/fresh=none/);
217+ await call("/pty/kill", {});
218+ expect((await d.closed).reason).toBe("terminal ended");
219+ });
220+
221+ it("reaches a pooled sandbox's terminal through the pool", { timeout: 20_000 }, async () => {
222+ const pool = await startSandboxPool({ port: 0, secret: "pool-secret", workRoot: tmp("pool-") });
223+ const p = `127.0.0.1:${pool.port}`;
224+ await fetch(`http://${p}/boot`, { method: "POST", headers: { authorization: "Bearer pool-secret" }, body: JSON.stringify({ id: "sb2", secret: "s2", env: { DIFFINERY_CLONE_URL: `file://${origin}`, DIFFINERY_REF: "main" } }) });
225+ await expect(term(`ws://${p}/s/sb2/pty`, "wrong")).rejects.toThrow(/401/);
226+ await expect(term(`ws://${p}/s/nope/pty`, "s2")).rejects.toThrow(/404/);
227+ const t = await term(`ws://${p}/s/sb2/pty?cols=90&rows=20`, "s2");
228+ t.type("cat hello.txt; stty size\n");
229+ await t.until(/20 90/);
230+ expect(t.out()).toContain("hello sandbox");
231+ t.ws.close();
232+ await t.closed;
233+ await pool.close();
234+ });
235+});
modifiedpnpm-lock.yaml+47−0View file
@@ -94,6 +94,12 @@
9494 '@types/react-dom':
9595 specifier: 19.2.2
9696 version: 19.2.2(@types/react@19.2.2)
97+ '@xterm/addon-fit':
98+ specifier: 0.10.0
99+ version: 0.10.0(@xterm/xterm@5.5.0)
100+ '@xterm/xterm':
101+ specifier: 5.5.0
102+ version: 5.5.0
97103 esbuild:
98104 specifier: 0.25.11
99105 version: 0.25.11
@@ -127,10 +133,16 @@
127133 '@diffinery/workflow-yaml':
128134 specifier: workspace:*
129135 version: link:../../packages/workflow-yaml
136+ ws:
137+ specifier: 8.18.3
138+ version: 8.18.3
130139 devDependencies:
131140 '@types/node':
132141 specifier: ^22.0.0
133142 version: 22.20.5
143+ '@types/ws':
144+ specifier: 8.18.1
145+ version: 8.18.1
134146 esbuild:
135147 specifier: ^0.25.0
136148 version: 0.25.12
@@ -1630,6 +1642,9 @@
16301642 '@types/unist@3.0.3':
16311643 resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
16321644
1645+ '@types/ws@8.18.1':
1646+ resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
1647+
16331648 '@ungap/structured-clone@1.4.0':
16341649 resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==}
16351650
@@ -1662,6 +1677,14 @@
16621677 '@vitest/utils@4.1.11':
16631678 resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==}
16641679
1680+ '@xterm/addon-fit@0.10.0':
1681+ resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==}
1682+ peerDependencies:
1683+ '@xterm/xterm': ^5.0.0
1684+
1685+ '@xterm/xterm@5.5.0':
1686+ resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==}
1687+
16651688 asn1js@3.0.10:
16661689 resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==}
16671690 engines: {node: '>=12.0.0'}
@@ -2295,6 +2318,18 @@
22952318 '@cloudflare/workers-types':
22962319 optional: true
22972320
2321+ ws@8.18.3:
2322+ resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==}
2323+ engines: {node: '>=10.0.0'}
2324+ peerDependencies:
2325+ bufferutil: ^4.0.1
2326+ utf-8-validate: '>=5.0.2'
2327+ peerDependenciesMeta:
2328+ bufferutil:
2329+ optional: true
2330+ utf-8-validate:
2331+ optional: true
2332+
22982333 ws@8.21.0:
22992334 resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
23002335 engines: {node: '>=10.0.0'}
@@ -3374,6 +3409,10 @@
33743409
33753410 '@types/unist@3.0.3': {}
33763411
3412+ '@types/ws@8.18.1':
3413+ dependencies:
3414+ '@types/node': 22.20.5
3415+
33773416 '@ungap/structured-clone@1.4.0': {}
33783417
33793418 '@vitest/expect@4.1.11':
@@ -3433,6 +3472,12 @@
34333472 convert-source-map: 2.0.0
34343473 tinyrainbow: 3.2.0
34353474
3475+ '@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)':
3476+ dependencies:
3477+ '@xterm/xterm': 5.5.0
3478+
3479+ '@xterm/xterm@5.5.0': {}
3480+
34363481 asn1js@3.0.10:
34373482 dependencies:
34383483 pvtsutils: 1.3.6
@@ -4239,6 +4284,8 @@
42394284 - bufferutil
42404285 - utf-8-validate
42414286
4287+ ws@8.18.3: {}
4288+
42424289 ws@8.21.0: {}
42434290
42444291 yaml@2.9.1: {}