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)
- apps/diffinery/docs/agents/terminal.md+77−0
- apps/diffinery/docs/nav.json+1−0
- apps/diffinery/docs/reference/limits.md+2−2
- apps/diffinery/docs/security/audit-log.md+3−0
- apps/diffinery/docs/self-hosting/configuration.md+1−0
- apps/diffinery/islands/build.mjs+15−1
- apps/diffinery/islands/terminal.ts+101−0
- apps/diffinery/migrations/0019_agent_terminals.sql+26−0
- apps/diffinery/package.json+2−0
- apps/diffinery/src/agentic/ProjectDO.ts+72−0
- apps/diffinery/src/agentic/tasks-web.tsx+3−0
- apps/diffinery/src/agentic/terminal-web.tsx+317−0
- apps/diffinery/src/agentic/terminals.ts+192−0
- apps/diffinery/src/env.ts+2−0
- apps/diffinery/src/repo/actions.ts+8−6
- apps/diffinery/src/runner/containers.ts+58−0
- apps/diffinery/src/web/assets.ts+2−0
- apps/diffinery/src/web/routes.tsx+2−0
- apps/diffinery/src/web/ui.tsx+5−0
- apps/diffinery/test/e2e/tasks.test.mjs+126−1
- containers/runner/package.json+13−3
- containers/runner/src/pty-helper.ts+62−0
- containers/runner/src/sandbox-pool.ts+25−1
- containers/runner/src/sandbox.ts+31−0
- containers/runner/src/terminal.ts+114−0
- containers/runner/test/sandbox.test.ts+82−0
- pnpm-lock.yaml+47−0
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/reference/limits.md+2−2View file
| @@ -172,9 +172,9 @@ | ||
| 172 | 172 | |
| 173 | 173 | | Quota | Default | What happens past it | |
| 174 | 174 | | --- | --- | --- | |
| 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. | | |
| 176 | 176 | | 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`. | | |
| 178 | 178 | | 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`. | |
| 179 | 179 | |
| 180 | 180 | ## Actions |
modifiedapps/diffinery/docs/security/audit-log.md+3−0View file
| @@ -82,6 +82,9 @@ | ||
| 82 | 82 | | `task.pick` | The person who started a task with [attempts](/docs/agents/tasks#best-of-n) picks one | `owner/repo#<task number>` | `attempt` | |
| 83 | 83 | | `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` | |
| 84 | 84 | | `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`) | | |
| 85 | 88 | | `project.memory` | Someone saves or restores the [project](/docs/agents/projects) memory | `owner/repo` | `version`, `length`, and `restored` (the version restored) for a restore | |
| 86 | 89 | | `project.settings` | A repository admin changes **Settings → Project** | `owner/repo` | `max_running`, `instructions_length`, `plan_first` | |
| 87 | 90 | | `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 @@ | ||
| 34 | 34 | | `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). | |
| 35 | 35 | | `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)). | |
| 36 | 36 | | `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. | | |
| 37 | 38 | |
| 38 | 39 | 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. |
| 39 | 40 |
modifiedapps/diffinery/islands/build.mjs+15−1View file
| @@ -73,4 +73,18 @@ | ||
| 73 | 73 | const cssIn = await readFile(require.resolve("@cloudflare/kumo/styles/standalone"), "utf8"); |
| 74 | 74 | const css = (await postcss([scoper]).process(cssIn, { from: undefined })).css; |
| 75 | 75 | 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 @@ | ||
| 37 | 37 | "@phosphor-icons/react": "2.1.10", |
| 38 | 38 | "@types/react": "19.2.2", |
| 39 | 39 | "@types/react-dom": "19.2.2", |
| 40 | + "@xterm/addon-fit": "0.10.0", | |
| 41 | + "@xterm/xterm": "5.5.0", | |
| 40 | 42 | "esbuild": "0.25.11", |
| 41 | 43 | "playwright": "1.56.1", |
| 42 | 44 | "postcss": "8.5.6", |
modifiedapps/diffinery/src/agentic/ProjectDO.ts+72−0View file
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | import { HttpError, now, randomToken, sha256 } from "../lib/util.js"; |
| 5 | 5 | import { atLeast } from "../repo/types.js"; |
| 6 | 6 | import { taskCaller, TaskAccessError } from "./access.js"; |
| 7 | +import { endTerminal, TERMINAL_IDLE_MS, TERMINAL_MAX_MS, terminalsEnabled, Terminals, type TerminalRow } from "./terminals.js"; | |
| 7 | 8 | import { PROVIDERS, type Provider } from "./keys.js"; |
| 8 | 9 | import { enqueueMail } from "../mail/send.js"; |
| 9 | 10 | import { formatTokens, pinRefusal, weighted, type Usage } from "./usage.js"; |
| @@ -317,6 +318,8 @@ | ||
| 317 | 318 | |
| 318 | 319 | /** How often a running task is polled. */ |
| 319 | 320 | 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; | |
| 320 | 323 | /** How often the person's access is re-checked while a run is active. */ |
| 321 | 324 | const RECHECK_MS = 60_000; |
| 322 | 325 | const MAX_LINE = 32 * 1024; |
| @@ -1115,9 +1118,78 @@ | ||
| 1115 | 1118 | 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)}`); |
| 1116 | 1119 | } |
| 1117 | 1120 | } |
| 1121 | + const terminals = await this.tickTerminals().catch((e) => { | |
| 1122 | + console.error("terminal check failed", e); | |
| 1123 | + return true; | |
| 1124 | + }); | |
| 1118 | 1125 | 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); | |
| 1119 | 1127 | } |
| 1120 | 1128 | |
| 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 | + | |
| 1121 | 1193 | private arm(ms: number): void { |
| 1122 | 1194 | this.ctx.waitUntil(this.ctx.storage.setAlarm(Date.now() + ms)); |
| 1123 | 1195 | } |
modifiedapps/diffinery/src/agentic/tasks-web.tsx+3−0View file
| @@ -540,6 +540,9 @@ | ||
| 540 | 540 | <div> |
| 541 | 541 | <div class="row mb"> |
| 542 | 542 | <h1 class="grow m0">Agent tasks</h1> |
| 543 | + <a class="btn" href={`${repoUrl(l.repo)}/terminals`}> | |
| 544 | + Terminals | |
| 545 | + </a> | |
| 543 | 546 | {l.repo.archived ? null : ( |
| 544 | 547 | <a class="btn primary" href={`${repoUrl(l.repo)}/tasks/new`}> |
| 545 | 548 | 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 @@ | ||
| 25 | 25 | */ |
| 26 | 26 | SANDBOX_POOL_URL?: string; |
| 27 | 27 | SANDBOX_POOL_SECRET?: string; |
| 28 | + /** `off` switches web terminals (agentic/terminals.ts) off for the whole instance; on by default. */ | |
| 29 | + TERMINALS?: string; | |
| 28 | 30 | /** Local development and end-to-end tests only: where the model proxy forwards instead of the providers' APIs. */ |
| 29 | 31 | MODEL_UPSTREAM_ANTHROPIC?: string; |
| 30 | 32 | MODEL_UPSTREAM_OPENAI?: string; |
modifiedapps/diffinery/src/repo/actions.ts+8−6View file
| @@ -2004,8 +2004,9 @@ | ||
| 2004 | 2004 | * `task` is set only by agentic/ (never from a request): the sandbox of an agent task, started for |
| 2005 | 2005 | * the agent `caller` by the person `task.personId`. No public sandbox call reaches it. An agent |
| 2006 | 2006 | * 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. | |
| 2007 | 2008 | */ |
| 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 }> { | |
| 2009 | 2010 | if (!atLeast(caller.role, "write")) throw new HttpError(403, "write access is required to create a sandbox"); |
| 2010 | 2011 | // A review's sandbox acts with read access only: its git credential can clone but never push. |
| 2011 | 2012 | if (task?.readOnly) caller = { ...caller, role: "read" }; |
| @@ -2032,7 +2033,7 @@ | ||
| 2032 | 2033 | JSON.stringify(caller), |
| 2033 | 2034 | ref ?? m.defaultBranch, |
| 2034 | 2035 | now(), |
| 2035 | - task ? "task" : "api", | |
| 2036 | + task ? (task.kind ?? "task") : "api", | |
| 2036 | 2037 | task?.personId ?? null, |
| 2037 | 2038 | ); |
| 2038 | 2039 | await startCompute(this.env, { id, accountId: m.ownerId, repoId: m.repoId, kind: "sandbox" }); |
| @@ -2061,8 +2062,9 @@ | ||
| 2061 | 2062 | |
| 2062 | 2063 | getSandbox(id: string, caller: Caller): { id: string; ref: string; status: string; createdAt: string } { |
| 2063 | 2064 | 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"); | |
| 2066 | 2068 | const owner = JSON.parse(s.created_by) as Caller; |
| 2067 | 2069 | if (owner.id !== caller.id && !atLeast(caller.role, "admin")) throw new HttpError(403, "not your sandbox"); |
| 2068 | 2070 | return { id: s.id, ref: s.ref, status: s.status, createdAt: s.created_at }; |
| @@ -2075,10 +2077,10 @@ | ||
| 2075 | 2077 | await this.env.SANDBOX.get(this.env.SANDBOX.idFromName(id)).shutdown(); |
| 2076 | 2078 | } |
| 2077 | 2079 | |
| 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. */ | |
| 2079 | 2081 | async closeTaskSandbox(id: string): Promise<void> { |
| 2080 | 2082 | 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; | |
| 2082 | 2084 | this.sql.exec("UPDATE sandboxes SET status = 'closed' WHERE id = ?", id); |
| 2083 | 2085 | await endCompute(this.env, id); |
| 2084 | 2086 | await this.env.SANDBOX.get(this.env.SANDBOX.idFromName(id)).shutdown(); |
modifiedapps/diffinery/src/runner/containers.ts+58−0View file
| @@ -87,6 +87,25 @@ | ||
| 87 | 87 | if (this.ctx.container?.running) await this.stop(); |
| 88 | 88 | } |
| 89 | 89 | |
| 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 | + | |
| 90 | 109 | override async onStop(): Promise<void> { |
| 91 | 110 | const usage = await this.ctx.storage.get<{ id: string; part?: number }>("usage"); |
| 92 | 111 | if (usage) await endCompute(this.env, usage.part ? `${usage.id}:${usage.part}` : usage.id); |
| @@ -122,4 +141,43 @@ | ||
| 122 | 141 | const name = await this.ctx.storage.get<string>("name"); |
| 123 | 142 | if (name) await fetch(`${this.env.SANDBOX_POOL_URL}/shutdown/${name}`, { method: "POST", headers: this.poolAuth() }).catch(() => {}); |
| 124 | 143 | } |
| 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 | + } | |
| 125 | 160 | } |
| 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 @@ | ||
| 261 | 261 | .tx-note{padding-left:10px;border-left:2px solid var(--border)} |
| 262 | 262 | .tx-result{padding:10px 12px;border:1px solid var(--border);border-radius:var(--r);background:var(--subtle,transparent)} |
| 263 | 263 | .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)} | |
| 264 | 266 | .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} |
| 265 | 267 | .tx-err{border-left:3px solid var(--red)} |
| 266 | 268 | .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 @@ | ||
| 6 | 6 | import { registerTasks } from "../agentic/tasks-web.js"; |
| 7 | 7 | import { registerReviews } from "../agentic/review-web.js"; |
| 8 | 8 | import { registerProject } from "../agentic/project-web.js"; |
| 9 | +import { registerTerminals } from "../agentic/terminal-web.js"; | |
| 9 | 10 | import { registerMailWeb } from "../mail/web.js"; |
| 10 | 11 | import { registerAuth } from "./auth.js"; |
| 11 | 12 | import { registerActions } from "./actions.js"; |
| @@ -49,6 +50,7 @@ | ||
| 49 | 50 | registerTasks(web); |
| 50 | 51 | registerReviews(web); |
| 51 | 52 | registerProject(web); |
| 53 | +registerTerminals(web); | |
| 52 | 54 | registerAccount(web); |
| 53 | 55 | registerRepoSettings(web); |
| 54 | 56 | registerActions(web); |
modifiedapps/diffinery/src/web/ui.tsx+5−0View file
| @@ -292,6 +292,8 @@ | ||
| 292 | 292 | head?: Child; |
| 293 | 293 | /** A page script, run after the site's own (it gets the page's CSP nonce). */ |
| 294 | 294 | 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[]; | |
| 295 | 297 | /** Reload the page every this many seconds (progress pages without live updates). */ |
| 296 | 298 | refresh?: number; |
| 297 | 299 | } |
| @@ -516,6 +518,9 @@ | ||
| 516 | 518 | <script nonce={nonce} src={kumoAssets().js} defer></script> |
| 517 | 519 | <script nonce={nonce}>{raw(JS)}</script> |
| 518 | 520 | {opts.script ? <script nonce={nonce}>{raw(opts.script)}</script> : null} |
| 521 | + {(opts.scriptSrc ?? []).map((src) => ( | |
| 522 | + <script nonce={nonce} src={src} defer></script> | |
| 523 | + ))} | |
| 519 | 524 | </body> |
| 520 | 525 | </html> |
| 521 | 526 | ); |
modifiedapps/diffinery/test/e2e/tasks.test.mjs+126−1View file
| @@ -13,7 +13,8 @@ | ||
| 13 | 13 | import { spawn, execFileSync } from "node:child_process"; |
| 14 | 14 | import { mkdtempSync, writeFileSync, mkdirSync, rmSync, readdirSync, statSync, readFileSync, existsSync } from "node:fs"; |
| 15 | 15 | import { tmpdir } from "node:os"; |
| 16 | -import { randomBytes } from "node:crypto"; | |
| 16 | +import { createHash, randomBytes } from "node:crypto"; | |
| 17 | +import { createRequire } from "node:module"; | |
| 17 | 18 | import { join, dirname, resolve } from "node:path"; |
| 18 | 19 | import { fileURLToPath } from "node:url"; |
| 19 | 20 | import { createServer as netServer } from "node:net"; |
| @@ -25,6 +26,9 @@ | ||
| 25 | 26 | const AGENT = resolve(APP, "../../containers/runner/dist/agent.mjs"); |
| 26 | 27 | const work = mkdtempSync(join(tmpdir(), "diffinery-tasks-e2e-")); |
| 27 | 28 | 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")}`; | |
| 28 | 32 | const ANTHROPIC_KEY = `sk-ant-api03-${randomBytes(30).toString("hex")}`; |
| 29 | 33 | const OPENROUTER_KEY = `sk-or-v1-${randomBytes(32).toString("hex")}`; |
| 30 | 34 | let base; |
| @@ -131,6 +135,13 @@ | ||
| 131 | 135 | base = `http://127.0.0.1:${port}`; |
| 132 | 136 | const persist = join(work, "state"); |
| 133 | 137 | 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 | + } | |
| 134 | 145 | 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 }; |
| 135 | 146 | 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"], { |
| 136 | 147 | cwd: APP, |
| @@ -1087,3 +1098,117 @@ | ||
| 1087 | 1098 | assert.equal(t.state, "stopped"); |
| 1088 | 1099 | assert.equal((await fetch(`${base}/-/model/anthropic/v1/messages`, { method: "POST", headers: { "x-api-key": runToken }, body: "{}" })).status, 401); |
| 1089 | 1100 | }); |
| 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 @@ | ||
| 4 | 4 | "private": true, |
| 5 | 5 | "type": "module", |
| 6 | 6 | "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);\"", | |
| 8 | 8 | "typecheck": "tsc --noEmit", |
| 9 | 9 | "test": "vitest run" |
| 10 | 10 | }, |
| 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 | + } | |
| 13 | 23 | } |
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 @@ | ||
| 1 | 1 | import { timingSafeEqual } from "node:crypto"; |
| 2 | 2 | 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"; | |
| 4 | 4 | import path from "node:path"; |
| 5 | 5 | import { startSandbox, type SandboxHandle } from "./sandbox.js"; |
| 6 | 6 | |
| @@ -82,6 +82,30 @@ | ||
| 82 | 82 | } |
| 83 | 83 | })(); |
| 84 | 84 | }); |
| 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 | + }); | |
| 85 | 109 | await new Promise<void>((resolve, reject) => { |
| 86 | 110 | server.once("error", reject); |
| 87 | 111 | server.listen(o.port, o.host ?? "127.0.0.1", () => resolve()); |
modifiedcontainers/runner/src/sandbox.ts+31−0View file
| @@ -4,9 +4,11 @@ | ||
| 4 | 4 | import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; |
| 5 | 5 | import type { AddressInfo } from "node:net"; |
| 6 | 6 | import path from "node:path"; |
| 7 | +import { WebSocketServer } from "ws"; | |
| 7 | 8 | import { basicAuthHeader } from "./builtins/checkout.js"; |
| 8 | 9 | import { Masker } from "./mask.js"; |
| 9 | 10 | import { ProcTable } from "./procs.js"; |
| 11 | +import { Terminal } from "./terminal.js"; | |
| 10 | 12 | import { exec } from "./util.js"; |
| 11 | 13 | |
| 12 | 14 | export interface SandboxOptions { |
| @@ -141,6 +143,14 @@ | ||
| 141 | 143 | return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, String(x)])); |
| 142 | 144 | }; |
| 143 | 145 | |
| 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 | + | |
| 144 | 154 | const resolvePath = (p: unknown): string => { |
| 145 | 155 | if (typeof p !== "string" || !p) throw new HttpErr(400, "`path` is required"); |
| 146 | 156 | return path.isAbsolute(p) ? p : path.join(repoDir, p); |
| @@ -229,6 +239,15 @@ | ||
| 229 | 239 | } |
| 230 | 240 | return; |
| 231 | 241 | } |
| 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 | + } | |
| 232 | 251 | case "/proc/kill": { |
| 233 | 252 | if (typeof body.id !== "string") throw new HttpErr(400, "`id` is required"); |
| 234 | 253 | const sig = body.signal === "SIGKILL" || body.signal === "SIGINT" ? body.signal : "SIGTERM"; |
| @@ -245,6 +264,17 @@ | ||
| 245 | 264 | })(); |
| 246 | 265 | }); |
| 247 | 266 | |
| 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 | + | |
| 248 | 278 | await new Promise<void>((resolve, reject) => { |
| 249 | 279 | server.once("error", reject); |
| 250 | 280 | server.listen(o.port, o.host ?? "0.0.0.0", () => resolve()); |
| @@ -257,6 +287,7 @@ | ||
| 257 | 287 | ready, |
| 258 | 288 | close: () => { |
| 259 | 289 | procs.killAll(); |
| 290 | + terminal.kill(); | |
| 260 | 291 | return new Promise<void>((r) => server.close(() => r())); |
| 261 | 292 | }, |
| 262 | 293 | }; |
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 @@ | ||
| 2 | 2 | import { readFileSync, rmSync, writeFileSync } from "node:fs"; |
| 3 | 3 | import path from "node:path"; |
| 4 | 4 | import { afterAll, beforeAll, describe, expect, it } from "vitest"; |
| 5 | +import { WebSocket } from "ws"; | |
| 5 | 6 | import { startSandbox, type SandboxHandle } from "../src/sandbox.js"; |
| 6 | 7 | import { startSandboxPool } from "../src/sandbox-pool.js"; |
| 7 | 8 | import { tmp } from "./helpers.js"; |
| @@ -151,3 +152,84 @@ | ||
| 151 | 152 | }); |
| 152 | 153 | }); |
| 153 | 154 | |
| 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 @@ | ||
| 94 | 94 | '@types/react-dom': |
| 95 | 95 | specifier: 19.2.2 |
| 96 | 96 | 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 | |
| 97 | 103 | esbuild: |
| 98 | 104 | specifier: 0.25.11 |
| 99 | 105 | version: 0.25.11 |
| @@ -127,10 +133,16 @@ | ||
| 127 | 133 | '@diffinery/workflow-yaml': |
| 128 | 134 | specifier: workspace:* |
| 129 | 135 | version: link:../../packages/workflow-yaml |
| 136 | + ws: | |
| 137 | + specifier: 8.18.3 | |
| 138 | + version: 8.18.3 | |
| 130 | 139 | devDependencies: |
| 131 | 140 | '@types/node': |
| 132 | 141 | specifier: ^22.0.0 |
| 133 | 142 | version: 22.20.5 |
| 143 | + '@types/ws': | |
| 144 | + specifier: 8.18.1 | |
| 145 | + version: 8.18.1 | |
| 134 | 146 | esbuild: |
| 135 | 147 | specifier: ^0.25.0 |
| 136 | 148 | version: 0.25.12 |
| @@ -1630,6 +1642,9 @@ | ||
| 1630 | 1642 | '@types/unist@3.0.3': |
| 1631 | 1643 | resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} |
| 1632 | 1644 | |
| 1645 | + '@types/ws@8.18.1': | |
| 1646 | + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} | |
| 1647 | + | |
| 1633 | 1648 | '@ungap/structured-clone@1.4.0': |
| 1634 | 1649 | resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==} |
| 1635 | 1650 | |
| @@ -1662,6 +1677,14 @@ | ||
| 1662 | 1677 | '@vitest/utils@4.1.11': |
| 1663 | 1678 | resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} |
| 1664 | 1679 | |
| 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 | + | |
| 1665 | 1688 | asn1js@3.0.10: |
| 1666 | 1689 | resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==} |
| 1667 | 1690 | engines: {node: '>=12.0.0'} |
| @@ -2295,6 +2318,18 @@ | ||
| 2295 | 2318 | '@cloudflare/workers-types': |
| 2296 | 2319 | optional: true |
| 2297 | 2320 | |
| 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 | + | |
| 2298 | 2333 | ws@8.21.0: |
| 2299 | 2334 | resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} |
| 2300 | 2335 | engines: {node: '>=10.0.0'} |
| @@ -3374,6 +3409,10 @@ | ||
| 3374 | 3409 | |
| 3375 | 3410 | '@types/unist@3.0.3': {} |
| 3376 | 3411 | |
| 3412 | + '@types/ws@8.18.1': | |
| 3413 | + dependencies: | |
| 3414 | + '@types/node': 22.20.5 | |
| 3415 | + | |
| 3377 | 3416 | '@ungap/structured-clone@1.4.0': {} |
| 3378 | 3417 | |
| 3379 | 3418 | '@vitest/expect@4.1.11': |
| @@ -3433,6 +3472,12 @@ | ||
| 3433 | 3472 | convert-source-map: 2.0.0 |
| 3434 | 3473 | tinyrainbow: 3.2.0 |
| 3435 | 3474 | |
| 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 | + | |
| 3436 | 3481 | asn1js@3.0.10: |
| 3437 | 3482 | dependencies: |
| 3438 | 3483 | pvtsutils: 1.3.6 |
| @@ -4239,6 +4284,8 @@ | ||
| 4239 | 4284 | - bufferutil |
| 4240 | 4285 | - utf-8-validate |
| 4241 | 4286 | |
| 4287 | + ws@8.18.3: {} | |
| 4288 | + | |
| 4242 | 4289 | ws@8.21.0: {} |
| 4243 | 4290 | |
| 4244 | 4291 | yaml@2.9.1: {} |