Phase 4b: give a task to the Claude Code or Codex signed in to my web terminal (#142)
* Phase 4b: run agent tasks in my web terminal, with the CLI signed in there
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014VDQqT8oL18uLJ9fpsNxW2
* Terminal tasks: branch names reach the boot script only as quoted data; the notice says repository content can steer a task
Security's must-fixes on #142.
- terminalBoot interpolated the ref into bash -c, and branch names may hold $( ), backticks, ; or |.
The ref, worktree and start commit now go through /exec's env, quoted; the worktree removal too.
The e2e opens its terminal on t$(touch${IFS}injected): on the old code the shell ran it (and
fetched refs/heads/t); now the task checks out that branch and nothing runs.
- A task runs the agent without asking, where the person's sign-in is, and repository content can
steer it: the notice and terminal.md say so, and TERMINAL_TERMS_VERSION is 2 so everyone sees it.
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 7bc0a57commit 94ad4715384637f0801b8f466c87e6d1852c6acaBrowse files
10 files changed+466−40
Files (10)
- apps/diffinery/docs/agents/tasks.md+1−1
- apps/diffinery/docs/agents/terminal.md+24−2
- apps/diffinery/docs/security/audit-log.md+1−0
- apps/diffinery/src/agentic/ProjectDO.ts+144−22
- apps/diffinery/src/agentic/tasks-web.tsx+30−3
- apps/diffinery/src/agentic/tasks.ts+1−1
- apps/diffinery/src/agentic/terminal-web.tsx+49−4
- apps/diffinery/src/agentic/terminals.ts+97−5
- apps/diffinery/test/e2e/agents-harness.mjs+17−1
- apps/diffinery/test/e2e/terminals.test.mjs+102−1
modifiedapps/diffinery/docs/agents/tasks.md+1−1View file
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | |
| 6 | 6 | An **agent task** is a goal you give one of your agents, such as "Fix the Contact sales link on the pricing page and add a test". Diffinery starts a sandbox as that agent with the repository checked out, runs a coding agent (Claude Code, Codex or a command of your own) with **your own model API key**, streams its transcript to the task page, then pushes the work to a branch and opens a pull request as the agent. The pull request needs a person's approval like any agent pull request. |
| 7 | 7 | |
| 8 | -Tasks are optional. Nothing changes for a repository until someone with a model key starts one. | |
| 8 | +Tasks are optional. Nothing changes for a repository until someone with a model key starts one. To use your Claude or ChatGPT subscription instead of an API key, run tasks in a [terminal](/docs/agents/terminal#give-it-a-task). | |
| 9 | 9 | |
| 10 | 10 | The same machinery reviews pull requests: see [Agent review](/docs/agents/review). Reviews show in the repository's task list, labelled **Review**. The repository's **Project** tab shows tasks on a board, with a conversation and a memory every task reads: see [Projects](/docs/agents/projects). |
| 11 | 11 |
modifiedapps/diffinery/docs/agents/terminal.md+24−2View file
| @@ -19,7 +19,7 @@ | ||
| 19 | 19 | |
| 20 | 20 | 1. Open the repository's **Project** tab and click **Terminals** (the **Agent tasks** page has the same button), or go to `/<owner>/<repo>/terminals`. |
| 21 | 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. | |
| 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. It also says what a task in your terminal can reach (see [Give it a task](#give-it-a-task)). | |
| 23 | 23 | 4. Click **Open a terminal**. |
| 24 | 24 | |
| 25 | 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. |
| @@ -35,6 +35,28 @@ | ||
| 35 | 35 | |
| 36 | 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 | 37 | |
| 38 | +## Give it a task | |
| 39 | + | |
| 40 | +Your terminal can run [agent tasks](/docs/agents/tasks) with the Claude Code or Codex you signed in to there, on your own subscription, with no API key. | |
| 41 | + | |
| 42 | +- **Start one from the terminal.** Under the terminal, **Give it a task** takes a goal and **Runs with** Claude Code or Codex (Codex on private repositories only). Click **Run with my subscription**. The task's page opens and shows the transcript as it runs. | |
| 43 | +- **Run a task again in it.** On a task you started, with the same agent as an open terminal of yours, **Run in my terminal** runs it again there, and **Send to my terminal** sends your follow-up there. | |
| 44 | + | |
| 45 | +What happens: | |
| 46 | + | |
| 47 | +| Step | What Diffinery does | | |
| 48 | +| --- | --- | | |
| 49 | +| Check | Checks again, on each click, that the terminal is yours, open and acting as the task's agent, that you accepted the notice, and that you and the agent may still write to the repository. | | |
| 50 | +| Checkout | Checks out the task's branch (or the terminal's branch, for a new task) in a directory of its own beside yours, so your checkout and anything you're doing in it are left alone. | | |
| 51 | +| Run | Runs the CLI headless with the goal, as a task run does, with no model key or token: it uses the account you signed in to in the terminal. If it isn't installed yet, Diffinery installs the pinned version first. | | |
| 52 | +| Push | Pushes the work to the task's branch as the agent and opens a pull request, as for any task. Then it removes the task's directory. | | |
| 53 | + | |
| 54 | +Only your own click starts a run in your terminal. Nothing else does: not a coordinator, a schedule, a retry, [Best of N](/docs/agents/tasks#best-of-n) or a failing check. A task whose last run was in a terminal doesn't start a fix run when its checks fail; run it again yourself. A terminal runs one task at a time, it counts as attached while it does (so it doesn't end for being idle), and ending the terminal stops the task. Tasks that plan first or have more than one attempt run in sandboxes of their own. | |
| 55 | + | |
| 56 | +The task's transcript is kept, as for any task: it is the CLI's output for that run, not what you type in the terminal. | |
| 57 | + | |
| 58 | +**Give it tasks only in repositories whose contents you trust.** A task runs the agent without asking before each step, in your terminal, where your sign-in is. Anything in the repository the agent reads (a CLAUDE.md, a file, an issue the goal quotes) can steer it, for example into reading your sign-in and sending it somewhere. A task in its own sandbox can't do that: it holds only a token for that run. The notice says this too. | |
| 59 | + | |
| 38 | 60 | ## Who can reach it |
| 39 | 61 | |
| 40 | 62 | - **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. |
| @@ -70,7 +92,7 @@ | ||
| 70 | 92 | |
| 71 | 93 | ## Nothing is stored |
| 72 | 94 | |
| 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`). | |
| 95 | +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.task`, `terminal.end`). | |
| 74 | 96 | |
| 75 | 97 | ## For administrators |
| 76 | 98 |
modifiedapps/diffinery/docs/security/audit-log.md+1−0View file
| @@ -85,6 +85,7 @@ | ||
| 85 | 85 | | `task.stop` | Someone stops a task (its starter, its agent or a repository admin) | `owner/repo#<task number>` | none | |
| 86 | 86 | | `terminal.terms` | Someone accepts the notice for [terminals](/docs/agents/terminal) | `owner/repo` | `version` | |
| 87 | 87 | | `terminal.open` | Someone opens a [terminal](/docs/agents/terminal) | `owner/repo` | `terminal`, `agent`, `ref`, `runtimes` (the Claude Code and Codex versions its buttons install) | |
| 88 | +| `terminal.task` | Someone runs a task in their [terminal](/docs/agents/terminal#give-it-a-task) | `owner/repo` | `terminal`, `task`, `runtime`, `version` (the pinned version of the CLI) | | |
| 88 | 89 | | `terminal.end` | A terminal ends | `owner/repo` | `terminal`, `reason` (`person`, `idle`, `limit`, `access`, `stopped`, `failed`, `disabled` or `suspended`) | |
| 89 | 90 | | `project.memory` | Someone saves or restores the [project](/docs/agents/projects) memory | `owner/repo` | `version`, `length`, and `restored` (the version restored) for a restore | |
| 90 | 91 | | `project.settings` | A repository admin changes **Settings → Project** | `owner/repo` | `max_running`, `instructions_length`, `plan_first` | |
modifiedapps/diffinery/src/agentic/ProjectDO.ts+144−22View file
| @@ -136,6 +136,8 @@ | ||
| 136 | 136 | /** One of a task's attempts (best of N): which, and the branch it pushes to. */ |
| 137 | 137 | attempt: number | null; |
| 138 | 138 | branch: string | null; |
| 139 | + /** Run in the person's web terminal (phase 4b): its sandbox and the CLI signed in there, not a sandbox of its own. */ | |
| 140 | + terminal_id: string | null; | |
| 139 | 141 | started_at: string; |
| 140 | 142 | ended_at: string | null; |
| 141 | 143 | }; |
| @@ -156,7 +158,7 @@ | ||
| 156 | 158 | } |
| 157 | 159 | |
| 158 | 160 | export interface TaskView extends TaskRow { |
| 159 | - runs: Array<Pick<RunRow, "seq" | "kind" | "message" | "step" | "exit_code" | "error" | "model_calls" | "tokens" | "started_at" | "ended_at" | "attempt">>; | |
| 161 | + runs: Array<Pick<RunRow, "seq" | "kind" | "message" | "step" | "exit_code" | "error" | "model_calls" | "tokens" | "started_at" | "ended_at" | "attempt" | "terminal_id">>; | |
| 160 | 162 | } |
| 161 | 163 | |
| 162 | 164 | const SCHEMA = ` |
| @@ -297,6 +299,7 @@ | ||
| 297 | 299 | ["tasks", "check_fixes", "check_fixes INTEGER NOT NULL DEFAULT 0"], |
| 298 | 300 | ["tasks", "check_fix_sha", "check_fix_sha TEXT"], |
| 299 | 301 | ["tasks", "land", "land INTEGER NOT NULL DEFAULT 0"], |
| 302 | + ["runs", "terminal_id", "terminal_id TEXT"], | |
| 300 | 303 | ]; |
| 301 | 304 | |
| 302 | 305 | /** A finding as stored. */ |
| @@ -362,8 +365,16 @@ | ||
| 362 | 365 | /** Verify and land (phase 3e). */ |
| 363 | 366 | fixChecks?: boolean; |
| 364 | 367 | land?: boolean; |
| 368 | + /** Run it in the person's web terminal (phase 4b; agentic/terminals.ts checked it is theirs and open). */ | |
| 369 | + terminal?: InTerminal; | |
| 365 | 370 | } |
| 366 | 371 | |
| 372 | +/** A web terminal a run goes to: its id and its sandbox. */ | |
| 373 | +export interface InTerminal { | |
| 374 | + id: string; | |
| 375 | + sandboxId: string; | |
| 376 | +} | |
| 377 | + | |
| 367 | 378 | export interface ReviewRequest { |
| 368 | 379 | repoId: string; |
| 369 | 380 | pr: number; |
| @@ -401,7 +412,7 @@ | ||
| 401 | 412 | get(number: number): TaskView | null { |
| 402 | 413 | const t = this.task(number); |
| 403 | 414 | if (!t) return null; |
| 404 | - const runs = this.sql.exec<RunRow>("SELECT seq, kind, message, step, exit_code, error, model_calls, tokens, started_at, ended_at, attempt FROM runs WHERE task = ? ORDER BY seq", number).toArray(); | |
| 415 | + const runs = this.sql.exec<RunRow>("SELECT seq, kind, message, step, exit_code, error, model_calls, tokens, started_at, ended_at, attempt, terminal_id FROM runs WHERE task = ? ORDER BY seq", number).toArray(); | |
| 405 | 416 | return { ...t, runs }; |
| 406 | 417 | } |
| 407 | 418 | |
| @@ -570,7 +581,8 @@ | ||
| 570 | 581 | at, |
| 571 | 582 | ); |
| 572 | 583 | this.event(number, `${input.autofixOf ? "Autofix" : input.coordinator ? `${input.person.login}'s coordinator` : input.person.login} started task #${number}: ${title}`); |
| 573 | - if (input.planFirst) this.startRun(number, "plan", null, input.providers); | |
| 584 | + if (input.terminal) this.startRun(number, "start", null, [], null, input.terminal); | |
| 585 | + else if (input.planFirst) this.startRun(number, "plan", null, input.providers); | |
| 574 | 586 | else this.startAttempts(number, "start", input.providers); |
| 575 | 587 | await this.index(number); |
| 576 | 588 | return this.get(number)!; |
| @@ -599,6 +611,7 @@ | ||
| 599 | 611 | async message(number: number, text: string, providers: Provider[], from?: string): Promise<TaskView> { |
| 600 | 612 | const t = this.mustTask(number); |
| 601 | 613 | if (this.picking(t)) throw new HttpError(409, t.pick_state === "running" ? `the task is running ${t.attempts} attempts: send follow-ups once you've picked one` : "pick an attempt first: follow-ups go to the picked attempt's branch"); |
| 614 | + if (this.activeRun(number)?.terminal_id) throw new HttpError(409, "the task is running in your terminal: send your message once it ends, or stop it"); | |
| 602 | 615 | if (this.activeRun(number)) { |
| 603 | 616 | this.sql.exec("UPDATE tasks SET queued = ?, updated_at = ? WHERE number = ?", t.queued ? `${t.queued}\n\n${text}` : text, now(), number); |
| 604 | 617 | this.note(number, this.lastRun(number)?.seq ?? 0, `${from ?? t.person_login} sent a message; it starts a new run when this one ends.`); |
| @@ -621,6 +634,37 @@ | ||
| 621 | 634 | return this.get(number)!; |
| 622 | 635 | } |
| 623 | 636 | |
| 637 | + /** | |
| 638 | + * Run the task again in the person's open web terminal (phase 4b), with the CLI signed in there: only on their own | |
| 639 | + * click (agentic/terminals.ts checked the terminal, its agent and the opt-in). Nothing else starts runs in a terminal. | |
| 640 | + */ | |
| 641 | + async runInTerminal(number: number, terminal: InTerminal, message: string | null): Promise<TaskView> { | |
| 642 | + const t = this.mustTask(number); | |
| 643 | + if (t.kind !== "task") throw new HttpError(409, "only agent tasks run in a terminal"); | |
| 644 | + if (this.activeRun(number)) throw new HttpError(409, "the task is running; stop it first"); | |
| 645 | + if (t.attempts > 1 && t.picked === null) throw new HttpError(409, "a task with attempts runs in sandboxes of its own until you pick one"); | |
| 646 | + if (t.plan_state === "planning" || t.plan_state === "waiting") throw new HttpError(409, "approve the plan first: plans are written in a read-only sandbox"); | |
| 647 | + const busy = this.sql.exec<{ task: number }>(`SELECT task FROM runs WHERE terminal_id = ? AND step IN (${ACTIVE.map(() => "?").join(", ")})`, terminal.id, ...ACTIVE).toArray()[0]; | |
| 648 | + if (busy) throw new HttpError(409, `your terminal is already running task #${busy.task}; one task at a time`); | |
| 649 | + const last = this.lastRun(number); | |
| 650 | + if (message) this.startRun(number, "message", message, [], null, terminal); | |
| 651 | + else this.startRun(number, "retry", last?.kind === "plan" ? null : (last?.message ?? null), [], null, terminal); | |
| 652 | + await this.index(number); | |
| 653 | + return this.get(number)!; | |
| 654 | + } | |
| 655 | + | |
| 656 | + /** Whether a terminal is running a task, for its idle rule and its page. */ | |
| 657 | + terminalTask(terminalId: string): number | null { | |
| 658 | + return this.sql.exec<{ task: number }>(`SELECT task FROM runs WHERE terminal_id = ? AND step IN (${ACTIVE.map(() => "?").join(", ")})`, terminalId, ...ACTIVE).toArray()[0]?.task ?? null; | |
| 659 | + } | |
| 660 | + | |
| 661 | + /** A terminal ended: the run in it stops (its sandbox is gone). */ | |
| 662 | + async terminalEnded(terminalId: string): Promise<void> { | |
| 663 | + for (const r of this.sql.exec<RunRow>(`SELECT * FROM runs WHERE terminal_id = ? AND step IN (${ACTIVE.map(() => "?").join(", ")})`, terminalId, ...ACTIVE).toArray()) { | |
| 664 | + await this.endRun(r, "stopped", "The terminal it ran in ended."); | |
| 665 | + } | |
| 666 | + } | |
| 667 | + | |
| 624 | 668 | /** Stop the active run: kill the agent, close the sandbox (revoking its git credential) and the model token. */ |
| 625 | 669 | async stop(number: number, by: string, reason?: string): Promise<TaskView> { |
| 626 | 670 | this.mustTask(number); |
| @@ -722,6 +766,12 @@ | ||
| 722 | 766 | if (now_.check_fix_sha === head || now_.state !== "ready") continue; |
| 723 | 767 | this.sql.exec("UPDATE tasks SET check_fix_sha = ? WHERE number = ?", head, t.number); |
| 724 | 768 | const run = this.lastRun(t.number); |
| 769 | + // Nothing starts a run in a terminal by itself, and the run's own model key may not exist: the person reruns it. | |
| 770 | + if (run?.terminal_id) { | |
| 771 | + this.note(t.number, run.seq, `Checks failed on #${t.pr_number}. The last run was in ${t.person_login}'s terminal, so no fix run starts by itself: run it again from the task.`); | |
| 772 | + this.event(t.number, `Task #${t.number}'s pull request #${t.pr_number} failed checks.`); | |
| 773 | + continue; | |
| 774 | + } | |
| 725 | 775 | if (now_.check_fixes >= MAX_CHECK_FIXES) { |
| 726 | 776 | this.note(t.number, run?.seq ?? 0, `Checks failed on #${t.pr_number} again; ${MAX_CHECK_FIXES} fix runs is the most a task starts by itself.`); |
| 727 | 777 | this.event(t.number, `Task #${t.number}'s pull request #${t.pr_number} still fails checks after ${MAX_CHECK_FIXES} fix runs.`); |
| @@ -1001,7 +1051,7 @@ | ||
| 1001 | 1051 | |
| 1002 | 1052 | // ---------------------------------------------------------------- runs |
| 1003 | 1053 | |
| 1004 | - private startRun(number: number, kind: RunRow["kind"], message: string | null, providers: Provider[], attempt: number | null = null): void { | |
| 1054 | + private startRun(number: number, kind: RunRow["kind"], message: string | null, providers: Provider[], attempt: number | null = null, terminal: InTerminal | null = null): void { | |
| 1005 | 1055 | const t = this.mustTask(number); |
| 1006 | 1056 | const seq = (this.lastRun(number)?.seq ?? 0) + 1; |
| 1007 | 1057 | // A follow-up continues from the run before it on the task's branch: after a pick, the picked attempt's. |
| @@ -1019,8 +1069,23 @@ | ||
| 1019 | 1069 | if (kind === "plan") this.sql.exec("UPDATE tasks SET plan_state = 'planning' WHERE number = ?", number); |
| 1020 | 1070 | const at = now(); |
| 1021 | 1071 | // A task's run waits its turn under the project's "how many at once"; reviews and coordinators have their own limits. |
| 1022 | - const step: RunStep = own ? "boot" : "queued"; | |
| 1023 | - this.sql.exec("INSERT INTO runs (task, seq, kind, message, prompt, providers, step, started_at, attempt, branch) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", number, seq, kind, message, prompt, JSON.stringify(providers), step, at, attempt, attempt ? attemptBranch(t.branch, attempt) : null); | |
| 1072 | + // A run in the person's terminal uses that terminal's sandbox, so it takes no slot. | |
| 1073 | + const step: RunStep = own || terminal ? "boot" : "queued"; | |
| 1074 | + this.sql.exec( | |
| 1075 | + "INSERT INTO runs (task, seq, kind, message, prompt, providers, step, started_at, attempt, branch, terminal_id, sandbox_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 1076 | + number, | |
| 1077 | + seq, | |
| 1078 | + kind, | |
| 1079 | + message, | |
| 1080 | + prompt, | |
| 1081 | + JSON.stringify(providers), | |
| 1082 | + step, | |
| 1083 | + at, | |
| 1084 | + attempt, | |
| 1085 | + attempt ? attemptBranch(t.branch, attempt) : null, | |
| 1086 | + terminal?.id ?? null, | |
| 1087 | + terminal?.sandboxId ?? null, | |
| 1088 | + ); | |
| 1024 | 1089 | // A task with an attempt already running stays running while the next one queues. |
| 1025 | 1090 | const busy = this.sql.exec<{ n: number }>(`SELECT count(*) AS n FROM runs WHERE task = ? AND step IN (${RUNNING.map(() => "?").join(", ")})`, number, ...RUNNING).one().n > 0; |
| 1026 | 1091 | this.sql.exec("UPDATE tasks SET state = ?, error = NULL, updated_at = ? WHERE number = ?", step === "queued" && !busy ? "queued" : "running", at, number); |
| @@ -1048,6 +1113,7 @@ | ||
| 1048 | 1113 | : "Planning again." |
| 1049 | 1114 | : `${t.coordinator_post ? `${t.person_login}'s coordinator` : t.person_login} started the task with ${runtimeLabel(t.runtime, t)}${kind === "plan" ? ". It plans first" : ""}.`, |
| 1050 | 1115 | ); |
| 1116 | + if (terminal) this.note(number, seq, `Running in ${t.person_login}'s terminal, with the ${RUNTIMES[t.runtime].name} signed in there on ${t.person_login}'s own account.`); | |
| 1051 | 1117 | } |
| 1052 | 1118 | if (step === "queued" && !this.promote().includes(number) && this.run(number, seq)?.step === "queued") { |
| 1053 | 1119 | const waits = this.waitsFor(t); |
| @@ -1062,7 +1128,7 @@ | ||
| 1062 | 1128 | |
| 1063 | 1129 | /** Start queued runs, oldest first, while the project has room; returns the tasks started. */ |
| 1064 | 1130 | private promote(): number[] { |
| 1065 | - const running = this.sql.exec<{ n: number }>(`SELECT count(*) AS n FROM runs r JOIN tasks t ON t.number = r.task WHERE t.kind = 'task' AND r.step IN (${RUNNING.map(() => "?").join(", ")})`, ...RUNNING).one().n; | |
| 1131 | + const running = this.sql.exec<{ n: number }>(`SELECT count(*) AS n FROM runs r JOIN tasks t ON t.number = r.task WHERE t.kind = 'task' AND r.terminal_id IS NULL AND r.step IN (${RUNNING.map(() => "?").join(", ")})`, ...RUNNING).one().n; | |
| 1066 | 1132 | let room = this.projectSettings().maxRunning - running; |
| 1067 | 1133 | const queued: RunRow[] = []; |
| 1068 | 1134 | const at = now(); |
| @@ -1167,9 +1233,9 @@ | ||
| 1167 | 1233 | } |
| 1168 | 1234 | |
| 1169 | 1235 | private async tickTerminal(t: TerminalRow, terminals: Terminals, dir: Directory): Promise<void> { |
| 1170 | - if (!terminalsEnabled(this.env)) return endTerminal(this.env, dir, t, "disabled"); | |
| 1236 | + if (!terminalsEnabled(this.env)) return endTerminal(this.env, dir, t, "disabled", undefined, this); | |
| 1171 | 1237 | // A kill switch (lib/killswitch.ts) that stops sandboxes here ends the terminal too. |
| 1172 | - if (blockedBy(await killSwitches(this.env, t.repo_id), "sandbox")) return endTerminal(this.env, dir, t, "suspended"); | |
| 1238 | + if (blockedBy(await killSwitches(this.env, t.repo_id), "sandbox")) return endTerminal(this.env, dir, t, "suspended", undefined, this); | |
| 1173 | 1239 | // The same rule as tasks, checked again every time: the person drives the agent, and both may write. |
| 1174 | 1240 | const [person, agent, repo] = await Promise.all([dir.account(t.person_id), dir.account(t.agent_id), dir.repoById(t.repo_id)]); |
| 1175 | 1241 | let caller; |
| @@ -1178,7 +1244,7 @@ | ||
| 1178 | 1244 | caller = await taskCaller(dir, person, agent, repo); |
| 1179 | 1245 | } catch (e) { |
| 1180 | 1246 | if (!(e instanceof TaskAccessError)) throw e; |
| 1181 | - return endTerminal(this.env, dir, t, "access"); | |
| 1247 | + return endTerminal(this.env, dir, t, "access", undefined, this); | |
| 1182 | 1248 | } |
| 1183 | 1249 | const stub = this.env.REPO.get(this.env.REPO.idFromName(t.repo_id)); |
| 1184 | 1250 | if (t.status === "starting") { |
| @@ -1187,19 +1253,20 @@ | ||
| 1187 | 1253 | sb = await stub.createSandbox(caller, t.ref, { personId: t.person_id, kind: "terminal" }); |
| 1188 | 1254 | } catch (e) { |
| 1189 | 1255 | console.error("terminal sandbox failed to start", t.id, e); |
| 1190 | - return endTerminal(this.env, dir, t, "failed"); | |
| 1256 | + return endTerminal(this.env, dir, t, "failed", undefined, this); | |
| 1191 | 1257 | } |
| 1192 | 1258 | // Ended while it started: stop it again. |
| 1193 | 1259 | if (!(await terminals.started(t.id, sb.id))) await stub.closeTaskSandbox(sb.id).catch(() => {}); |
| 1194 | 1260 | return; |
| 1195 | 1261 | } |
| 1196 | - if (Date.now() - Date.parse(t.started_at ?? t.created_at) > TERMINAL_MAX_MS) return endTerminal(this.env, dir, t, "limit"); | |
| 1262 | + if (Date.now() - Date.parse(t.started_at ?? t.created_at) > TERMINAL_MAX_MS) return endTerminal(this.env, dir, t, "limit", undefined, this); | |
| 1197 | 1263 | const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(t.sandbox_id!)); |
| 1198 | 1264 | type PtyStatus = { attached?: boolean; idleSince?: string | null }; |
| 1199 | 1265 | const res = (await sandbox.running()) ? await sandbox.call("/pty/status", "{}").catch(() => null) : null; |
| 1200 | 1266 | const status = res?.status === 200 ? (JSON.parse(res.body) as PtyStatus) : null; |
| 1201 | - if (!status) return endTerminal(this.env, dir, t, "stopped"); | |
| 1202 | - 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"); | |
| 1267 | + if (!status) return endTerminal(this.env, dir, t, "stopped", undefined, this); | |
| 1268 | + // A task running in it counts as someone attached. | |
| 1269 | + if (!status.attached && this.terminalTask(t.id) === null && Date.now() - Date.parse(status.idleSince ?? t.started_at ?? t.created_at) > TERMINAL_IDLE_MS) return endTerminal(this.env, dir, t, "idle", undefined, this); | |
| 1203 | 1270 | } |
| 1204 | 1271 | |
| 1205 | 1272 | private arm(ms: number): void { |
| @@ -1213,17 +1280,26 @@ | ||
| 1213 | 1280 | return this.endRun(r, "failed", t.kind === "review" ? `The review took longer than ${REVIEW_TIMEOUT_MS / 60_000} minutes and was stopped.` : t.kind === "coordinator" ? `The coordinator run took longer than ${COORDINATOR_TIMEOUT_MS / 60_000} minutes and was stopped.` : "The run took longer than 6 hours and was stopped."); |
| 1214 | 1281 | switch (r.step) { |
| 1215 | 1282 | case "boot": |
| 1283 | + if (r.terminal_id) return this.terminalBoot(t, r); | |
| 1216 | 1284 | return t.kind === "review" ? this.reviewBoot(t, r) : t.kind === "coordinator" ? this.coordinatorBoot(t, r) : this.boot(t, r); |
| 1217 | 1285 | case "install": { |
| 1218 | - const install = RUNTIMES[t.runtime].install; | |
| 1286 | + let install = RUNTIMES[t.runtime].install; | |
| 1219 | 1287 | if (!install) return this.setStep(r, "agent"); |
| 1220 | - if (!r.proc) return this.startProc(r, "install", install, {}, {}, INSTALL_TIMEOUT_MS, `Installing ${RUNTIMES[t.runtime].name}.`); | |
| 1288 | + // In a terminal the CLI is usually there already, signed in: keep it. | |
| 1289 | + const bin = TERMINAL_BIN[t.runtime]; | |
| 1290 | + if (r.terminal_id && bin) install = `command -v ${bin} >/dev/null 2>&1 || { ${install}; }`; | |
| 1291 | + if (!r.proc) return this.startProc(r, "install", install, {}, {}, INSTALL_TIMEOUT_MS, r.terminal_id ? null : `Installing ${RUNTIMES[t.runtime].name}.`); | |
| 1221 | 1292 | const done = await this.poll(r, "setup"); |
| 1222 | 1293 | if (done === null) return; |
| 1223 | 1294 | if (done !== 0) return this.endRun(this.run(r.task, r.seq)!, "failed", `Installing ${RUNTIMES[t.runtime].name} failed (exit ${done}).`); |
| 1224 | 1295 | return this.setStep(this.run(r.task, r.seq)!, "agent"); |
| 1225 | 1296 | } |
| 1226 | 1297 | case "agent": { |
| 1298 | + if (!r.proc && r.terminal_id) { | |
| 1299 | + // No model token and no proxy: the CLI uses the account the person signed in to in their terminal. | |
| 1300 | + const env = { IS_SANDBOX: "1", DISABLE_AUTOUPDATER: "1", DIFFINERY_TASK_PROMPT: r.prompt }; | |
| 1301 | + return this.startProc(r, "agent", RUNTIMES[t.runtime].command(), env, {}, timeoutOf(t) - (Date.now() - Date.parse(r.started_at)), null, worktree(r)); | |
| 1302 | + } | |
| 1227 | 1303 | if (!r.proc) { |
| 1228 | 1304 | // The key itself never enters the sandbox: the runtime gets a token for this run that only the model proxy accepts. |
| 1229 | 1305 | const providers = JSON.parse(r.providers) as Provider[]; |
| @@ -1234,6 +1310,7 @@ | ||
| 1234 | 1310 | } |
| 1235 | 1311 | if (!r.checked_at || Date.parse(r.checked_at) + RECHECK_MS < Date.now()) { |
| 1236 | 1312 | await this.checkAccess(t); |
| 1313 | + if (r.terminal_id) await this.checkTerminal(t, r); | |
| 1237 | 1314 | this.sql.exec("UPDATE runs SET checked_at = ? WHERE task = ? AND seq = ?", now(), r.task, r.seq); |
| 1238 | 1315 | } |
| 1239 | 1316 | const done = await this.poll(r, "out"); |
| @@ -1250,7 +1327,7 @@ | ||
| 1250 | 1327 | if (r.kind === "plan") return this.keepPlan(t, r); |
| 1251 | 1328 | const branch = r.branch ?? t.branch; |
| 1252 | 1329 | if (!r.proc) { |
| 1253 | - return this.startProc(r, "finish", FINISH_SCRIPT, { DIFFINERY_TASK_BRANCH: branch, DIFFINERY_TASK_START: r.start_sha ?? "", DIFFINERY_TASK_MESSAGE: commitMessage(t, r) }, {}, 10 * 60 * 1000, `Pushing to ${branch}.`); | |
| 1330 | + return this.startProc(r, "finish", FINISH_SCRIPT, { DIFFINERY_TASK_BRANCH: branch, DIFFINERY_TASK_START: r.start_sha ?? "", DIFFINERY_TASK_MESSAGE: commitMessage(t, r) }, {}, 10 * 60 * 1000, `Pushing to ${branch}.`, r.terminal_id ? worktree(r) : undefined); | |
| 1254 | 1331 | } |
| 1255 | 1332 | const done = await this.poll(r, "setup"); |
| 1256 | 1333 | if (done === null) return; |
| @@ -1305,6 +1382,42 @@ | ||
| 1305 | 1382 | } |
| 1306 | 1383 | |
| 1307 | 1384 | /** |
| 1385 | + * A run in the person's web terminal (phase 4b): no sandbox of its own. It checks the terminal is still theirs and | |
| 1386 | + * open, and the opt-in, then checks out the task's branch in a separate directory there (a git worktree), so the | |
| 1387 | + * person's own checkout is left alone. | |
| 1388 | + */ | |
| 1389 | + private async terminalBoot(t: TaskRow, r: RunRow): Promise<void> { | |
| 1390 | + await this.checkAccess(t); | |
| 1391 | + await this.checkTerminal(t, r); | |
| 1392 | + const stub = this.env.REPO.get(this.env.REPO.idFromName(t.repo_id)); | |
| 1393 | + const branchSha = await stub.getRef(`refs/heads/${t.branch}`); | |
| 1394 | + const ref = branchSha ? t.branch : t.base; | |
| 1395 | + const startSha = branchSha ?? (await stub.getRef(`refs/heads/${t.base}`)); | |
| 1396 | + if (!startSha) throw new HttpError(422, `the branch ${t.base} doesn't exist`); | |
| 1397 | + this.note(t.number, r.seq, `Checking out ${ref} in ${t.person_login}'s terminal, in its own directory.`); | |
| 1398 | + const wt = worktree(r); | |
| 1399 | + // Branch names may hold `$`, backticks, `;` or `|`: everything reaches the script through its environment, quoted. | |
| 1400 | + const prepared = (await this.sandbox(r, "/exec", { | |
| 1401 | + cmd: ["set -e", 'git fetch -q origin "refs/heads/$DIFFINERY_TASK_REF"', 'git worktree remove --force "$DIFFINERY_WORKTREE" 2>/dev/null || rm -rf "$DIFFINERY_WORKTREE"', "git worktree prune", 'git worktree add -q --detach "$DIFFINERY_WORKTREE" "$DIFFINERY_TASK_START"'].join("\n"), | |
| 1402 | + env: { DIFFINERY_TASK_REF: ref, DIFFINERY_WORKTREE: wt, DIFFINERY_TASK_START: startSha }, | |
| 1403 | + timeoutMs: 5 * 60_000, | |
| 1404 | + })) as { exitCode: number; stderr: string }; | |
| 1405 | + if (prepared.exitCode !== 0) throw new HttpError(422, `checking out ${ref} in the terminal failed: ${prepared.stderr.trim().split("\n").slice(-3).join(" ")}`); | |
| 1406 | + if (this.run(t.number, r.seq)?.step !== "boot") return; | |
| 1407 | + this.sql.exec("UPDATE runs SET start_sha = ?, step = 'install', proc = NULL, checked_at = ? WHERE task = ? AND seq = ?", startSha, now(), t.number, r.seq); | |
| 1408 | + } | |
| 1409 | + | |
| 1410 | + /** The terminal a run is in must still be open, the task's person's, with the opt-in in force. */ | |
| 1411 | + private async checkTerminal(t: TaskRow, r: RunRow): Promise<void> { | |
| 1412 | + const terminals = new Terminals(this.env); | |
| 1413 | + const term = await terminals.get(r.terminal_id!); | |
| 1414 | + if (!term || term.status !== "running" || term.sandbox_id !== r.sandbox_id || term.person_id !== t.person_id || term.agent_id !== t.agent_id) throw new TaskAccessError("the terminal it ran in is no longer open"); | |
| 1415 | + if (!(await terminals.termsAt(t.person_id))) throw new TaskAccessError(`${t.person_login} hasn't accepted the notice for terminals`); | |
| 1416 | + const block = blockedBy(await killSwitches(this.env, t.repo_id), "sandbox"); | |
| 1417 | + if (block) throw new TaskAccessError(block); | |
| 1418 | + } | |
| 1419 | + | |
| 1420 | + /** | |
| 1308 | 1421 | * A coordinator's sandbox: the default branch, read-only, with the prompt |
| 1309 | 1422 | * built from the project as it is now and the posts it handles. |
| 1310 | 1423 | */ |
| @@ -1638,9 +1751,9 @@ | ||
| 1638 | 1751 | return repo ? `${repo.owner_login}/${repo.name}` : ""; |
| 1639 | 1752 | } |
| 1640 | 1753 | |
| 1641 | - private async startProc(r: RunRow, id: string, cmd: string, env: Record<string, string>, secretEnv: Record<string, string>, timeoutMs: number, note: string | null): Promise<void> { | |
| 1754 | + private async startProc(r: RunRow, id: string, cmd: string, env: Record<string, string>, secretEnv: Record<string, string>, timeoutMs: number, note: string | null, cwd?: string): Promise<void> { | |
| 1642 | 1755 | if (note) this.note(r.task, r.seq, note); |
| 1643 | - await this.sandbox(r, "/proc/start", { id, cmd, env, secretEnv, timeoutMs: Math.max(timeoutMs, 1000) }); | |
| 1756 | + await this.sandbox(r, "/proc/start", { id, cmd, env, secretEnv, timeoutMs: Math.max(timeoutMs, 1000), ...(cwd ? { cwd } : {}) }); | |
| 1644 | 1757 | this.sql.exec("UPDATE runs SET proc = ?, out_off = 0, err_off = 0 WHERE task = ? AND seq = ?", id, r.task, r.seq); |
| 1645 | 1758 | } |
| 1646 | 1759 | |
| @@ -1736,9 +1849,12 @@ | ||
| 1736 | 1849 | await this.index(r.task); |
| 1737 | 1850 | if (r.sandbox_id) { |
| 1738 | 1851 | if (r.proc) await this.sandbox(r, "/proc/kill", { id: r.proc, signal: "SIGKILL" }).catch(() => {}); |
| 1739 | - await this.env.REPO.get(this.env.REPO.idFromName(t.repo_id)) | |
| 1740 | - .closeTaskSandbox(r.sandbox_id) | |
| 1741 | - .catch((e) => console.error("closing a task sandbox failed", e)); | |
| 1852 | + // A terminal's sandbox stays the person's: only the run's own checkout goes. | |
| 1853 | + if (r.terminal_id) await this.sandbox(r, "/exec", { cmd: 'git worktree remove --force "$DIFFINERY_WORKTREE" 2>/dev/null; rm -rf "$DIFFINERY_WORKTREE"; git worktree prune', env: { DIFFINERY_WORKTREE: worktree(r) }, timeoutMs: 60_000 }).catch(() => {}); | |
| 1854 | + else | |
| 1855 | + await this.env.REPO.get(this.env.REPO.idFromName(t.repo_id)) | |
| 1856 | + .closeTaskSandbox(r.sandbox_id) | |
| 1857 | + .catch((e) => console.error("closing a task sandbox failed", e)); | |
| 1742 | 1858 | } |
| 1743 | 1859 | if (t.kind === "review" && step !== "done" && r.head_sha) { |
| 1744 | 1860 | await this.reviewCheck(t, r.head_sha, "completed", step === "stopped" ? "cancelled" : "failure", step === "stopped" ? "The review was stopped" : "The review didn't finish", error ?? ""); |
| @@ -1834,6 +1950,12 @@ | ||
| 1834 | 1950 | } |
| 1835 | 1951 | } |
| 1836 | 1952 | |
| 1953 | +/** A terminal run's own checkout, beside the terminal's (relative to the sandbox's repository directory). */ | |
| 1954 | +const worktree = (r: RunRow): string => `../task-${r.task}-${r.seq}`; | |
| 1955 | + | |
| 1956 | +/** The CLI a terminal run looks for before installing it. */ | |
| 1957 | +const TERMINAL_BIN: Partial<Record<RuntimeId, string>> = { "claude-code": "claude", codex: "codex" }; | |
| 1958 | + | |
| 1837 | 1959 | const timeoutOf = (t: TaskRow): number => (t.kind === "review" ? REVIEW_TIMEOUT_MS : t.kind === "coordinator" ? COORDINATOR_TIMEOUT_MS : RUN_TIMEOUT_MS); |
| 1838 | 1960 | |
| 1839 | 1961 | function splitLines(s: string): string[] { |
modifiedapps/diffinery/src/agentic/tasks-web.tsx+30−3View file
| @@ -15,6 +15,7 @@ | ||
| 15 | 15 | import { OPENROUTER_DEFAULT_MODEL, parseRuntimeChoice, runtimeChoices, runtimeLabel, transcriptItem, type TranscriptItem } from "./runtimes.js"; |
| 16 | 16 | import { approvePlan, compareAttempts, drivableAgents, getTask, pickAttempt, setTaskOptions, type AttemptCompare, mayStop, messageTask, projectStub, requireTaskReader, retryTask, startTask, stopTask, taskNumber } from "./tasks.js"; |
| 17 | 17 | import { splitPlan } from "./projects.js"; |
| 18 | +import { runTaskInTerminal, terminalForTask, terminalId } from "./terminals.js"; | |
| 18 | 19 | |
| 19 | 20 | /** |
| 20 | 21 | * Web pages for agent tasks: Settings → Model access, and a repository's |
| @@ -191,7 +192,7 @@ | ||
| 191 | 192 | return <pre class={`tx-pre${item.kind === "error" ? " tx-err" : ""}`}>{item.body}</pre>; |
| 192 | 193 | }; |
| 193 | 194 | |
| 194 | -function taskPage(c: AppContext, l: Loaded, t: TaskView, items: Array<{ run: number; item: TranscriptItem }>, truncated: boolean, canSplit = false, attempts: AttemptCompare[] = []) { | |
| 195 | +function taskPage(c: AppContext, l: Loaded, t: TaskView, items: Array<{ run: number; item: TranscriptItem }>, truncated: boolean, canSplit = false, attempts: AttemptCompare[] = [], terminal: string | null = null) { | |
| 195 | 196 | const base = repoUrl(l.repo); |
| 196 | 197 | const tk = csrfToken(c); |
| 197 | 198 | const me = requireAccount(c); |
| @@ -227,6 +228,14 @@ | ||
| 227 | 228 | <button class="btn sm">Retry</button> |
| 228 | 229 | </Form> |
| 229 | 230 | ) : null} |
| 231 | + {!running && terminal ? ( | |
| 232 | + <Form action={`${base}/tasks/${t.number}/terminal`} t={tk} class="inline"> | |
| 233 | + <input type="hidden" name="terminal" value={terminal} /> | |
| 234 | + <button class="btn sm" title={`Runs it again in your open terminal, with the ${runtimeLabel(t.runtime, t)} you're signed in to there, on your own subscription.`}> | |
| 235 | + Run in my terminal | |
| 236 | + </button> | |
| 237 | + </Form> | |
| 238 | + ) : null} | |
| 230 | 239 | </div> |
| 231 | 240 | {t.coordinator_post ? ( |
| 232 | 241 | <div class="flash info"> |
| @@ -315,7 +324,7 @@ | ||
| 315 | 324 | ) : null} |
| 316 | 325 | {items.length ? items.map((x, i) => ( |
| 317 | 326 | <> |
| 318 | - {i === 0 || items[i - 1]!.run !== x.run ? <h4 class="tx-run">Run {x.run}{t.runs[x.run - 1]?.attempt ? ` · attempt ${t.runs[x.run - 1]!.attempt}` : ""}{t.runs[x.run - 1]?.message ? " · follow-up" : ""}</h4> : null} | |
| 327 | + {i === 0 || items[i - 1]!.run !== x.run ? <h4 class="tx-run">Run {x.run}{t.runs[x.run - 1]?.attempt ? ` · attempt ${t.runs[x.run - 1]!.attempt}` : ""}{t.runs[x.run - 1]?.message ? " · follow-up" : ""}{t.runs[x.run - 1]?.terminal_id ? ` · in ${t.person_login}'s terminal` : ""}</h4> : null} | |
| 319 | 328 | <ItemView item={x.item} base={base} /> |
| 320 | 329 | </> |
| 321 | 330 | )) : <div class="empty">Starting…</div>} |
| @@ -333,6 +342,14 @@ | ||
| 333 | 342 | <textarea name="body" rows={4} required maxlength={20000} placeholder={running ? "It starts a new run on the same branch when this run ends." : "What should the agent do next? It continues on the same branch and pull request."} /> |
| 334 | 343 | </div> |
| 335 | 344 | <button class="btn primary">Send</button> |
| 345 | + {!running && terminal ? ( | |
| 346 | + <> | |
| 347 | + {" "} | |
| 348 | + <button class="btn" formaction={`${base}/tasks/${t.number}/terminal`} name="terminal" value={terminal} title="Runs it in your open terminal, with the CLI you're signed in to there, on your own subscription."> | |
| 349 | + Send to my terminal | |
| 350 | + </button> | |
| 351 | + </> | |
| 352 | + ) : null} | |
| 336 | 353 | </div> |
| 337 | 354 | </Form> |
| 338 | 355 | ) : review ? ( |
| @@ -597,7 +614,8 @@ | ||
| 597 | 614 | const items = tail.flatMap((line) => transcriptItem(line).map((item) => ({ run: line.run, item }))); |
| 598 | 615 | const canSplit = t.plan_state === "waiting" && !!(await stub.coordinator(requireAccount(c).id)); |
| 599 | 616 | const attempts = t.attempts > 1 ? await compareAttempts(c, l, n) : []; |
| 600 | - return taskPage(c, l, t, items, lines.length > tail.length, canSplit, attempts); | |
| 617 | + const terminal = await terminalForTask(c.env, requireAccount(c), l.repo, t).catch(() => null); | |
| 618 | + return taskPage(c, l, t, items, lines.length > tail.length, canSplit, attempts, terminal); | |
| 601 | 619 | }); |
| 602 | 620 | |
| 603 | 621 | post(web, "/:owner/:repo/tasks/:number/messages", async (c, f) => { |
| @@ -650,6 +668,15 @@ | ||
| 650 | 668 | }, "Retrying."); |
| 651 | 669 | }); |
| 652 | 670 | |
| 671 | + // Phase 4b: only the person's own click here runs a task in their terminal (with the follow-up, if they wrote one). | |
| 672 | + post(web, "/:owner/:repo/tasks/:number/terminal", async (c, f) => { | |
| 673 | + const l = await forTasks(c); | |
| 674 | + const n = taskNumber(c.req.param("number")); | |
| 675 | + return act(c, `${repoUrl(l.repo)}/tasks/${n}`, async () => { | |
| 676 | + await runTaskInTerminal(c, l, n, terminalId(f.s("terminal")), f.s("body")); | |
| 677 | + }, "Running in your terminal."); | |
| 678 | + }); | |
| 679 | + | |
| 653 | 680 | post(web, "/:owner/:repo/tasks/:number/stop", async (c) => { |
| 654 | 681 | const l = await forTasks(c); |
| 655 | 682 | const n = taskNumber(c.req.param("number")); |
modifiedapps/diffinery/src/agentic/tasks.ts+1−1View file
| @@ -323,7 +323,7 @@ | ||
| 323 | 323 | html_url: `${base}/${repoFull}/tasks/${t.number}`, |
| 324 | 324 | created_at: t.created_at, |
| 325 | 325 | updated_at: t.updated_at, |
| 326 | - ...("runs" in t ? { runs: t.runs.map((r) => ({ number: r.seq, kind: r.kind, message: r.message, state: r.step, exit_code: r.exit_code, error: r.error, model_requests: r.model_calls ?? 0, tokens: r.tokens ?? 0, ...(r.attempt ? { attempt: r.attempt } : {}), started_at: r.started_at, ended_at: r.ended_at })) } : {}), | |
| 326 | + ...("runs" in t ? { runs: t.runs.map((r) => ({ number: r.seq, kind: r.kind, message: r.message, state: r.step, exit_code: r.exit_code, error: r.error, model_requests: r.model_calls ?? 0, tokens: r.tokens ?? 0, ...(r.attempt ? { attempt: r.attempt } : {}), ...(r.terminal_id ? { in_terminal: true } : {}), started_at: r.started_at, ended_at: r.ended_at })) } : {}), | |
| 327 | 327 | }; |
| 328 | 328 | } |
| 329 | 329 |
modifiedapps/diffinery/src/agentic/terminal-web.tsx+49−4View file
| @@ -5,14 +5,14 @@ | ||
| 5 | 5 | import { loadRepo } from "../http/common.js"; |
| 6 | 6 | import { HttpError } from "../lib/util.js"; |
| 7 | 7 | import { csrfToken } from "../web/csrf.js"; |
| 8 | -import { KCheckbox, KSelect } from "../web/kumo.js"; | |
| 8 | +import { KCheckbox, KRadio, KSelect } from "../web/kumo.js"; | |
| 9 | 9 | import { Form, Time, act, post, prm, render, repoUrl, statusOf, type WebApp } from "../web/ui.js"; |
| 10 | 10 | import TERMINAL_JS from "../../build/islands/terminal.js.txt"; |
| 11 | 11 | import TERMINAL_CSS from "../../build/islands/terminal.css.txt"; |
| 12 | 12 | import { taskCaller, TaskAccessError } from "./access.js"; |
| 13 | 13 | import { RUNTIME_VERSIONS } from "./runtimes.js"; |
| 14 | -import { drivableAgents, requirePersonSession, requireTaskReader } from "./tasks.js"; | |
| 15 | -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"; | |
| 14 | +import { drivableAgents, projectStub, requirePersonSession, requireTaskReader } from "./tasks.js"; | |
| 15 | +import { endTerminal, MAX_TERMINALS, MAX_TERMINALS_PER_REPO, openTerminal, startTaskInTerminal, TERMINAL_END_TEXT, TERMINAL_IDLE_MS, TERMINAL_MAX_MS, terminalId, terminalsEnabled, Terminals, type TerminalEndReason, type TerminalRow } from "./terminals.js"; | |
| 16 | 16 | |
| 17 | 17 | /** |
| 18 | 18 | * Web pages for terminals (phase 4): the list and open form on a repository, the terminal page |
| @@ -147,7 +147,7 @@ | ||
| 147 | 147 | label={ |
| 148 | 148 | <> |
| 149 | 149 | I use Claude Code and Codex in my terminals with my own Anthropic or OpenAI account, under that provider's terms (<TermsLinks /> |
| 150 | - ). Usage is billed to my account. Diffinery only provides the sandbox, and never sees or keeps my sign-in. | |
| 150 | + ). Usage is billed to my account. Diffinery only provides the sandbox, and never sees or keeps my sign-in. A task I give a terminal runs the agent without asking before each step, in that terminal, where my sign-in is. Content in the repository can steer it, so I give it tasks only in repositories whose contents I trust. | |
| 151 | 151 | </> |
| 152 | 152 | } |
| 153 | 153 | /> |
| @@ -172,6 +172,7 @@ | ||
| 172 | 172 | const agent = await c.get("dir").account(t.agent_id); |
| 173 | 173 | const endsAt = t.started_at ? new Date(Date.parse(t.started_at) + TERMINAL_MAX_MS).toISOString() : null; |
| 174 | 174 | const running = t.status === "running"; |
| 175 | + const current = running ? await projectStub(c.env, l.repo.id).terminalTask(t.id) : null; | |
| 175 | 176 | return render( |
| 176 | 177 | c, |
| 177 | 178 | { |
| @@ -231,6 +232,38 @@ | ||
| 231 | 232 | {running ? ( |
| 232 | 233 | <> |
| 233 | 234 | <div id="dfy-terminal" class="term" data-socket={`${base}/terminals/${t.id}/socket`}></div> |
| 235 | + <div class="box mt"> | |
| 236 | + <div class="head"> | |
| 237 | + <h3>Give it a task</h3> | |
| 238 | + </div> | |
| 239 | + <div class="body"> | |
| 240 | + {current ? ( | |
| 241 | + <p class="m0"> | |
| 242 | + <a href={`${base}/tasks/${current}`}>Task #{current}</a> is running in this terminal. One task at a time: it ends when the task does, or when you stop it. | |
| 243 | + </p> | |
| 244 | + ) : ( | |
| 245 | + <Form action={`${base}/terminals/${t.id}/task`} t={tk}> | |
| 246 | + <div class="field"> | |
| 247 | + <label for="goal">Goal</label> | |
| 248 | + <textarea id="goal" name="goal" rows={3} required maxlength={20000} placeholder="Fix the Contact sales link on the pricing page so it goes to /contact, and add a test."></textarea> | |
| 249 | + </div> | |
| 250 | + <div class="field"> | |
| 251 | + <KRadio | |
| 252 | + name="runtime" | |
| 253 | + value="claude-code" | |
| 254 | + orientation="horizontal" | |
| 255 | + label="Runs with" | |
| 256 | + items={[{ value: "claude-code", label: "Claude Code" }, ...(l.repo.visibility !== "public" ? [{ value: "codex", label: "Codex" }] : [])]} | |
| 257 | + /> | |
| 258 | + </div> | |
| 259 | + <p class="muted small"> | |
| 260 | + It runs here with the one you're signed in to, on your own subscription, as {agent?.login ?? "your agent"}, in a checkout of {t.ref} of its own (yours is left alone). Diffinery pushes its work to a new branch and opens a pull request. Its checks don't start fix runs by themselves. | |
| 261 | + </p> | |
| 262 | + <button class="btn primary">Run with my subscription</button> | |
| 263 | + </Form> | |
| 264 | + )} | |
| 265 | + </div> | |
| 266 | + </div> | |
| 234 | 267 | <details class="box mt"> |
| 235 | 268 | <summary class="body">Signing in</summary> |
| 236 | 269 | <div class="body muted small"> |
| @@ -284,6 +317,18 @@ | ||
| 284 | 317 | return terminalPage(c, l, t); |
| 285 | 318 | }); |
| 286 | 319 | |
| 320 | + // Phase 4b: only the opener's own click here starts a task in their terminal. | |
| 321 | + post(web, "/:owner/:repo/terminals/:id/task", async (c, f) => { | |
| 322 | + const l = await forTerminals(c); | |
| 323 | + const id = terminalId(c.req.param("id")); | |
| 324 | + return act( | |
| 325 | + c, | |
| 326 | + `${repoUrl(l.repo)}/terminals/${encodeURIComponent(id)}`, | |
| 327 | + async () => `${repoUrl(l.repo)}/tasks/${(await startTaskInTerminal(c, l, id, { goal: f.s("goal"), runtime: f.s("runtime") })).number}`, | |
| 328 | + "The task is running in your terminal.", | |
| 329 | + ); | |
| 330 | + }); | |
| 331 | + | |
| 287 | 332 | post(web, "/:owner/:repo/terminals/:id/end", async (c) => { |
| 288 | 333 | const l = await forTerminals(c); |
| 289 | 334 | const me = requireAccount(c); |
modifiedapps/diffinery/src/agentic/terminals.ts+97−5View file
| @@ -6,8 +6,9 @@ | ||
| 6 | 6 | import { mayStart } from "../lib/quotas.js"; |
| 7 | 7 | import { HttpError, newId, now } from "../lib/util.js"; |
| 8 | 8 | import { taskCaller, TaskAccessError } from "./access.js"; |
| 9 | -import { RUNTIME_VERSIONS } from "./runtimes.js"; | |
| 10 | -import { projectStub, requirePersonSession } from "./tasks.js"; | |
| 9 | +import type { TaskView } from "./ProjectDO.js"; | |
| 10 | +import { MAX_GOAL, RUNTIME_VERSIONS, type RuntimeId } from "./runtimes.js"; | |
| 11 | +import { projectStub, requirePersonSession, starter } from "./tasks.js"; | |
| 11 | 12 | |
| 12 | 13 | /** |
| 13 | 14 | * Web terminals (phase 4, /mnt/project-files/agentic/phase4-design.md): a sandbox one person opens |
| @@ -29,7 +30,7 @@ | ||
| 29 | 30 | export const TERMINAL_IDLE_MS = 30 * 60_000; |
| 30 | 31 | export const TERMINAL_MAX_MS = 8 * 3600_000; |
| 31 | 32 | /** Bump when the notice changes: everyone opts in again. */ |
| 32 | -export const TERMINAL_TERMS_VERSION = 1; | |
| 33 | +export const TERMINAL_TERMS_VERSION = 2; | |
| 33 | 34 | |
| 34 | 35 | export type TerminalStatus = "starting" | "running" | "ended"; |
| 35 | 36 | export type TerminalEndReason = "person" | "idle" | "limit" | "access" | "stopped" | "failed" | "disabled" | "suspended"; |
| @@ -176,9 +177,13 @@ | ||
| 176 | 177 | return t; |
| 177 | 178 | } |
| 178 | 179 | |
| 179 | -/** Ends a terminal: marks it ended, then stops its sandbox (which revokes its git credential). */ | |
| 180 | -export async function endTerminal(env: Env, dir: Directory, t: TerminalRow, reason: TerminalEndReason, by?: Account): Promise<void> { | |
| 180 | +/** | |
| 181 | + * Ends a terminal: marks it ended, stops a task running in it, then stops its sandbox (which revokes its git | |
| 182 | + * credential). `project` is the repository's ProjectDO when the caller is that object itself. | |
| 183 | + */ | |
| 184 | +export async function endTerminal(env: Env, dir: Directory, t: TerminalRow, reason: TerminalEndReason, by?: Account, project?: { terminalEnded(id: string): Promise<void> }): Promise<void> { | |
| 181 | 185 | if (!(await new Terminals(env).markEnded(t.id, reason))) return; |
| 186 | + await (project ?? projectStub(env, t.repo_id)).terminalEnded(t.id).catch((e: unknown) => console.error("stopping the terminal's task failed", e)); | |
| 182 | 187 | 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)); |
| 183 | 188 | const repo = await dir.repoById(t.repo_id).catch(() => null); |
| 184 | 189 | const actor = by ?? (await dir.account(t.person_id).catch(() => null)); |
| @@ -195,3 +200,90 @@ | ||
| 195 | 200 | .catch((e) => console.error("audit failed", e)), |
| 196 | 201 | ); |
| 197 | 202 | } |
| 203 | + | |
| 204 | +// ---------------------------------------------------------------- 4b: tasks in a terminal | |
| 205 | + | |
| 206 | +/** | |
| 207 | + * The runtimes a terminal runs tasks with, signed in there: Claude Code, and Codex on private repositories only | |
| 208 | + * (OpenAI allows signing in with a ChatGPT plan only there). | |
| 209 | + */ | |
| 210 | +export function terminalRuntimes(repo: Repo): RuntimeId[] { | |
| 211 | + return repo.visibility === "public" ? ["claude-code"] : ["claude-code", "codex"]; | |
| 212 | +} | |
| 213 | + | |
| 214 | +/** The person's own open terminal in this repository, with the opt-in in force, checked again at every dispatch. */ | |
| 215 | +async function dispatchTerminal(c: AppContext, l: Loaded, me: Account, id: string | undefined, agentId?: string): Promise<{ t: TerminalRow; to: { id: string; sandboxId: string } }> { | |
| 216 | + if (!terminalsEnabled(c.env)) throw new HttpError(403, "terminals are switched off on this Diffinery"); | |
| 217 | + const terminals = new Terminals(c.env); | |
| 218 | + const t = await terminals.mine(terminalId(id), me, l.repo); | |
| 219 | + if (t.status !== "running" || !t.sandbox_id) throw new HttpError(409, "the terminal isn't running"); | |
| 220 | + if (!(await terminals.termsAt(me.id))) throw new HttpError(403, "accept the notice for terminals first: tasks there use your own provider account"); | |
| 221 | + // Kill switches (lib/killswitch.ts) stop sandboxes here: the terminal's is ending, and starts no task meanwhile. | |
| 222 | + const block = blockedBy(await killSwitches(c.env, l.repo.id), "sandbox"); | |
| 223 | + if (block) throw new HttpError(403, block); | |
| 224 | + if (agentId && t.agent_id !== agentId) throw new HttpError(422, "this terminal acts as a different agent than the task's: open one with the task's agent"); | |
| 225 | + if (await projectStub(c.env, l.repo.id).terminalTask(t.id)) throw new HttpError(409, "your terminal is already running a task; one at a time"); | |
| 226 | + return { t, to: { id: t.id, sandboxId: t.sandbox_id } }; | |
| 227 | +} | |
| 228 | + | |
| 229 | +function terminalRuntime(l: Loaded, runtime: unknown): RuntimeId { | |
| 230 | + const ok = terminalRuntimes(l.repo); | |
| 231 | + if (typeof runtime !== "string" || !ok.includes(runtime as RuntimeId)) throw new HttpError(422, l.repo.visibility === "public" ? "a public repository's terminal runs tasks with Claude Code" : "a terminal runs tasks with Claude Code or Codex"); | |
| 232 | + return runtime as RuntimeId; | |
| 233 | +} | |
| 234 | + | |
| 235 | +/** | |
| 236 | + * Start a task in the person's terminal (phase 4b): only from their own click on the terminal's page. The CLI signed | |
| 237 | + * in there does the work, on their own subscription, as the terminal's agent; Diffinery pushes and opens the pull | |
| 238 | + * request as for any task. Its checks don't start fix runs by themselves. | |
| 239 | + */ | |
| 240 | +export async function startTaskInTerminal(c: AppContext, l: Loaded, id: string | undefined, input: { goal?: unknown; runtime?: unknown }): Promise<TaskView> { | |
| 241 | + const me = requirePersonSession(c); | |
| 242 | + if (l.repo.archived) throw new HttpError(403, "repository is archived (read-only)"); | |
| 243 | + const goal = typeof input.goal === "string" ? input.goal.trim() : ""; | |
| 244 | + if (!goal) throw new HttpError(422, "write the goal"); | |
| 245 | + if (goal.length > MAX_GOAL) throw new HttpError(422, `the goal is longer than ${MAX_GOAL} characters`); | |
| 246 | + const runtime = terminalRuntime(l, input.runtime); | |
| 247 | + const { t, to } = await dispatchTerminal(c, l, me, id); | |
| 248 | + const dir = c.get("dir"); | |
| 249 | + const agent = await dir.account(t.agent_id); | |
| 250 | + if (!agent) throw new HttpError(403, "the terminal's agent no longer exists"); | |
| 251 | + try { | |
| 252 | + await taskCaller(dir, me, agent, l.repo); | |
| 253 | + } catch (e) { | |
| 254 | + if (e instanceof TaskAccessError) throw new HttpError(403, e.message); | |
| 255 | + throw e; | |
| 256 | + } | |
| 257 | + if (!(await l.stub.getRef(`refs/heads/${t.ref}`))) throw new HttpError(422, `the branch ${t.ref} doesn't exist any more`); | |
| 258 | + const project = projectStub(c.env, l.repo.id); | |
| 259 | + if ((await project.projectSettings()).planFirst === "required") throw new HttpError(422, "this project requires a plan first, which is written in a sandbox of its own: start the task from New task"); | |
| 260 | + const task = await project.create({ repoId: l.repo.id, goal, runtime, command: null, agent: { id: agent.id, login: agent.login }, person: { id: me.id, login: me.login }, base: t.ref, providers: [], planFirst: false, attempts: 1, fixChecks: false, land: false, terminal: to }); | |
| 261 | + audit(c, me, l.repo, "task.start", { agent: agent.login, runtime, terminal: t.id, fix_checks: false, land: false }); | |
| 262 | + audit(c, me, l.repo, "terminal.task", { terminal: t.id, task: task.number, runtime, version: RUNTIME_VERSIONS[runtime as "claude-code" | "codex"] }); | |
| 263 | + return task; | |
| 264 | +} | |
| 265 | + | |
| 266 | +/** Run an existing task again in the person's terminal (phase 4b): only from their own click on the task's page. */ | |
| 267 | +export async function runTaskInTerminal(c: AppContext, l: Loaded, number: number, id: string | undefined, message: unknown): Promise<TaskView> { | |
| 268 | + const { person, task } = await starter(c, l, number); | |
| 269 | + if (task.kind !== "task") throw new HttpError(409, "only agent tasks run in a terminal"); | |
| 270 | + const runtime = terminalRuntime(l, task.runtime); | |
| 271 | + const text = typeof message === "string" ? message.trim() : ""; | |
| 272 | + if (text.length > MAX_GOAL) throw new HttpError(422, `the message is longer than ${MAX_GOAL} characters`); | |
| 273 | + const { t, to } = await dispatchTerminal(c, l, person, id, task.agent_id); | |
| 274 | + const out = await projectStub(c.env, l.repo.id).runInTerminal(number, to, text || null); | |
| 275 | + audit(c, person, l.repo, "terminal.task", { terminal: t.id, task: number, runtime, version: RUNTIME_VERSIONS[runtime as "claude-code" | "codex"] }); | |
| 276 | + return out; | |
| 277 | +} | |
| 278 | + | |
| 279 | +/** The person's open terminal a task could run in now, for its page's Run in my terminal button; null when none. */ | |
| 280 | +export async function terminalForTask(env: Env, me: Account, repo: Repo, task: TaskView): Promise<string | null> { | |
| 281 | + if (!terminalsEnabled(env) || task.kind !== "task" || task.person_id !== me.id || !terminalRuntimes(repo).includes(task.runtime)) return null; | |
| 282 | + if ((task.attempts > 1 && task.picked === null) || task.plan_state === "planning" || task.plan_state === "waiting") return null; | |
| 283 | + const terminals = new Terminals(env); | |
| 284 | + const open = (await terminals.list(me.id, repo.id)).filter((t) => t.status === "running" && t.agent_id === task.agent_id); | |
| 285 | + if (!open.length || !(await terminals.termsAt(me.id))) return null; | |
| 286 | + const project = projectStub(env, repo.id); | |
| 287 | + for (const t of open) if ((await project.terminalTask(t.id)) === null) return t.id; | |
| 288 | + return null; | |
| 289 | +} |
modifiedapps/diffinery/test/e2e/agents-harness.mjs+17−1View file
| @@ -115,9 +115,25 @@ | ||
| 115 | 115 | await new Promise((r) => upstream.listen(0, "127.0.0.1", r)); |
| 116 | 116 | const upstreamUrl = `http://127.0.0.1:${upstream.address().port}`; |
| 117 | 117 | |
| 118 | + // A stand-in for a Claude Code someone signed in to in their terminal (phase 4b): it reports what it was given and | |
| 119 | + // where it ran, and changes a file, without any model. | |
| 120 | + const fakeBin = join(work, "bin"); | |
| 121 | + mkdirSync(fakeBin, { recursive: true }); | |
| 122 | + writeFileSync( | |
| 123 | + join(fakeBin, "claude"), | |
| 124 | + [ | |
| 125 | + "#!/bin/sh", | |
| 126 | + 'd=$(basename "$PWD")', | |
| 127 | + 'echo "{\\"type\\":\\"assistant\\",\\"message\\":{\\"content\\":[{\\"type\\":\\"text\\",\\"text\\":\\"signed-in claude: key=${ANTHROPIC_API_KEY:-none} base=${ANTHROPIC_BASE_URL:-none} dir=$d\\"}]}}"', | |
| 128 | + 'printf "%s\\n" "$DIFFINERY_TASK_PROMPT"', | |
| 129 | + 'echo "made in the terminal" > terminal.txt', | |
| 130 | + 'echo \'{"type":"result","subtype":"success","result":"Done in the terminal."}\'', | |
| 131 | + ].join("\n"), | |
| 132 | + { mode: 0o755 }, | |
| 133 | + ); | |
| 118 | 134 | const poolPort = await freePort(); |
| 119 | 135 | const poolSecret = randomBytes(16).toString("hex"); |
| 120 | - pool = spawn("node", [AGENT, "--sandbox-pool", "--work-root", poolRoot], { env: { ...process.env, DIFFINERY_SANDBOX_POOL_SECRET: poolSecret, DIFFINERY_SANDBOX_PORT: String(poolPort) }, stdio: ["ignore", "pipe", "pipe"], detached: true }); | |
| 136 | + pool = spawn("node", [AGENT, "--sandbox-pool", "--work-root", poolRoot], { env: { ...process.env, PATH: `${fakeBin}:${process.env.PATH}`, DIFFINERY_SANDBOX_POOL_SECRET: poolSecret, DIFFINERY_SANDBOX_PORT: String(poolPort) }, stdio: ["ignore", "pipe", "pipe"], detached: true }); | |
| 121 | 137 | pool.stdout.on("data", (d) => (poolLog += d)); |
| 122 | 138 | pool.stderr.on("data", (d) => (poolLog += d)); |
| 123 | 139 | await until("sandbox pool", async () => poolLog.includes("listening"), 30_000); |
modifiedapps/diffinery/test/e2e/terminals.test.mjs+102−1View file
| @@ -1,4 +1,4 @@ | ||
| 1 | -// End-to-end web terminals and kill switches on them, on the same setup as tasks.test.mjs | |
| 1 | +// End-to-end web terminals, kill switches on them, and tasks run in them (phase 4b), on the same setup as tasks.test.mjs | |
| 2 | 2 | // (agents-harness.mjs). In a file of their own so each file stays well under the test runner's per-file limit. |
| 3 | 3 | // |
| 4 | 4 | // pnpm --filter @diffinery/app e2e |
| @@ -175,6 +175,10 @@ | ||
| 175 | 175 | // Suspending the repository's Actions ends the terminal, refuses attaching and opening another. |
| 176 | 176 | const S = `/admin/repos/${owner}/term-demo/actions-suspension`; |
| 177 | 177 | assert.equal((await api("PUT", S, { reason: "abuse report" })).status, 200); |
| 178 | + // Nor does it start a task in the terminal (phase 4b). | |
| 179 | + const before = (await ok("GET", `/repos/${owner}/term-demo/diffinery/tasks`)).length; | |
| 180 | + assert.equal((await form(`terminals/${id}/task`, { goal: "Do it while suspended", runtime: "claude-code" })).status, 303); | |
| 181 | + assert.equal((await ok("GET", `/repos/${owner}/term-demo/diffinery/tasks`)).length, before, "a suspended repository's terminal starts no task"); | |
| 178 | 182 | const refused = await termSocket(url, { origin }); |
| 179 | 183 | assert.ok([403, 409].includes(refused.status), JSON.stringify(refused)); |
| 180 | 184 | const again = await form("terminals", { agent: "term-bot-2", ref: "main" }); |
| @@ -188,3 +192,100 @@ | ||
| 188 | 192 | assert.equal(after.status, 303, await after.text()); |
| 189 | 193 | assert.equal((await form(`${new URL(after.headers.get("location"), base).pathname.split("/").slice(3).join("/")}/end`, {})).status, 303); |
| 190 | 194 | }); |
| 195 | + | |
| 196 | +test("a task in my terminal: only on my click, with the CLI signed in there, in a checkout of its own", async () => { | |
| 197 | + const owner = (await ok("GET", "/user")).login; | |
| 198 | + await ok("POST", "/user/repos", { name: "term-tasks" }); | |
| 199 | + await ok("PUT", `/repos/${owner}/term-tasks/contents/README.md`, { message: "init", content: Buffer.from("# term tasks\n").toString("base64") }); | |
| 200 | + assert.equal((await asPerson("POST", "/diffinery/agents", { login: "term-hand", name: "Term Hand" })).status, 201); | |
| 201 | + const R = `/repos/${owner}/term-tasks/diffinery/tasks`; | |
| 202 | + const page = (path, headers = {}) => fetch(`${base}/${owner}/term-tasks/${path}`, { headers }).then(async (r) => ({ status: r.status, text: await r.text() })); | |
| 203 | + async function form(path, fields, headers = {}) { | |
| 204 | + const res = await fetch(`${base}/${owner}/term-tasks/terminals`, { headers }); | |
| 205 | + const csrf = /name="_csrf" value="([^"]+)"/.exec(await res.text())?.[1]; | |
| 206 | + const cookie = /dfy_csrf=([^;]+)/.exec(res.headers.get("set-cookie") ?? "")?.[1]; | |
| 207 | + return fetch(`${base}/${owner}/term-tasks/${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 }) }); | |
| 208 | + } | |
| 209 | + // The terminal opens on a branch whose name would run a command wherever it reached a shell unquoted. | |
| 210 | + const EVIL = "t$(touch${IFS}injected)"; | |
| 211 | + const mainSha = (await ok("GET", `/repos/${owner}/term-tasks/git/ref/heads/main`)).object.sha; | |
| 212 | + await ok("POST", `/repos/${owner}/term-tasks/git/refs`, { ref: `refs/heads/${EVIL}`, sha: mainSha }); | |
| 213 | + const opened = await form("terminals", { agent: "term-hand", ref: EVIL, accept: "1" }); | |
| 214 | + assert.equal(opened.status, 303, await opened.text()); | |
| 215 | + const id = new URL(opened.headers.get("location"), base).pathname.split("/").pop(); | |
| 216 | + const running = await until("the terminal to start", async () => { | |
| 217 | + const p = await page(`terminals/${id}`); | |
| 218 | + return p.text.includes("data-socket") ? p : null; | |
| 219 | + }); | |
| 220 | + assert.match(running.text, /Give it a task/); | |
| 221 | + assert.match(running.text, /Run with my subscription/); | |
| 222 | + assert.match(running.text, /on your own subscription/); | |
| 223 | + | |
| 224 | + // Someone else, even an admin of the repository, can't send a task to it; nor can a token (there is no API for it). | |
| 225 | + const invite = await ok("PUT", `/repos/${owner}/term-tasks/collaborators/rival`, { permission: "admin" }); | |
| 226 | + 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" } }); | |
| 227 | + const rival = { cookie: `__Host-dfy_session=${RIVAL_SESSION}` }; | |
| 228 | + assert.equal((await form(`terminals/${id}/task`, { goal: "Do it", runtime: "claude-code" }, rival)).status, 303); | |
| 229 | + assert.deepEqual(await ok("GET", R), [], "someone else's click starts nothing"); | |
| 230 | + const viaToken = await api("POST", R, { goal: "Do it", runtime: "claude-code", agent: "term-hand", terminal: id }); | |
| 231 | + assert.equal(viaToken.status, 403, "a token starts no task, in a terminal or not"); | |
| 232 | + // Only Claude Code or Codex, as signed in there. | |
| 233 | + const custom = await form(`terminals/${id}/task`, { goal: "Do it", runtime: "custom" }); | |
| 234 | + assert.equal(custom.status, 303); | |
| 235 | + assert.deepEqual(await ok("GET", R), [], "a custom command doesn't run in a terminal"); | |
| 236 | + | |
| 237 | + // My click: the task runs in my terminal with the CLI there, with no model key or proxy, in its own checkout. | |
| 238 | + const started = await form(`terminals/${id}/task`, { goal: "Add a terminal file", runtime: "claude-code" }); | |
| 239 | + assert.equal(started.status, 303); | |
| 240 | + const n = Number(new URL(started.headers.get("location"), base).pathname.split("/").pop()); | |
| 241 | + assert.ok(n > 0, started.headers.get("location")); | |
| 242 | + const done = await until("the terminal task to finish", async () => { | |
| 243 | + const t = await ok("GET", `${R}/${n}`); | |
| 244 | + return t.state !== "running" && t.state !== "queued" ? t : null; | |
| 245 | + }); | |
| 246 | + const text = (await ok("GET", `${R}/${n}/transcript`)).map((l) => `[${l.stream}] ${l.text}`).join("\n"); | |
| 247 | + assert.equal(done.state, "ready", text); | |
| 248 | + assert.ok(done.pull_request, text); | |
| 249 | + assert.match(text, /Running in .*'s terminal/); | |
| 250 | + assert.match(text, /signed-in claude: key=none/, "no model key or token reaches it"); | |
| 251 | + assert.ok(!/base=[^ ]*\/-\/model\//.test(text), "nor the model proxy"); | |
| 252 | + assert.match(text, new RegExp(`dir=task-${n}-1`), "in a checkout of its own"); | |
| 253 | + assert.match(text, /\[out\] Add a terminal file/, "the goal reaches it"); | |
| 254 | + const file = await ok("GET", `/repos/${owner}/term-tasks/contents/terminal.txt?ref=${encodeURIComponent(done.branch)}`); | |
| 255 | + assert.equal(Buffer.from(file.content, "base64").toString(), "made in the terminal\n"); | |
| 256 | + assert.equal(done.runs[0].in_terminal, true); | |
| 257 | + assert.equal(done.base, EVIL, "checked out from the terminal's branch, its name passed as data"); | |
| 258 | + | |
| 259 | + // The terminal's own checkout is untouched, and the terminal is still mine to use. | |
| 260 | + const t = await termSocket(`${base.replace("http", "ws")}/${owner}/term-tasks/terminals/${id}/socket?cols=100&rows=30`, { origin: new URL(base).origin }); | |
| 261 | + assert.equal(t.status, 101); | |
| 262 | + t.type("ls terminal.txt 2>&1 | head -1; ls injected ../injected 2>&1 | grep -c 'No such file'; git status --short | wc -l; ls .. | grep -c task- ; echo check-$((6*7))\n"); | |
| 263 | + await t.until(/check-42/); | |
| 264 | + assert.match(t.out(), /No such file/); | |
| 265 | + assert.match(t.out(), /\n2\r?\n0\r?\n0\r?\n/, "the branch name ran nothing"); | |
| 266 | + assert.match(t.out(), /\n0\r?\n0\r?\n/, "a clean checkout, and the task's checkout removed"); | |
| 267 | + | |
| 268 | + // Its page offers to run it again there; doing so is another run in the terminal. | |
| 269 | + const taskPage = await page(`tasks/${n}`); | |
| 270 | + assert.match(taskPage.text, /Run in my terminal/); | |
| 271 | + assert.match(taskPage.text, /in [a-z0-9-]+('|')s terminal/); | |
| 272 | + assert.doesNotMatch((await page(`tasks/${n}`, rival)).text, /Run in my terminal/, "not for anyone else"); | |
| 273 | + assert.equal((await form(`tasks/${n}/terminal`, { terminal: id }, rival)).status, 303); | |
| 274 | + assert.equal((await ok("GET", `${R}/${n}`)).runs.length, 1, "someone else's click starts nothing"); | |
| 275 | + const again = await form(`tasks/${n}/terminal`, { terminal: id, body: "Say it again" }); | |
| 276 | + assert.equal(again.status, 303); | |
| 277 | + const twice = await until("the second terminal run", async () => { | |
| 278 | + const x = await ok("GET", `${R}/${n}`); | |
| 279 | + return x.runs.length === 2 && x.state === "ready" ? x : null; | |
| 280 | + }); | |
| 281 | + assert.equal(twice.runs[1].state, "done"); | |
| 282 | + assert.equal(twice.runs[1].in_terminal, true); | |
| 283 | + assert.equal(twice.runs[1].kind, "message"); | |
| 284 | + | |
| 285 | + // Ending the terminal ends what it can run: the page no longer offers it, and a click is refused. | |
| 286 | + assert.equal((await form(`terminals/${id}/end`, {})).status, 303); | |
| 287 | + assert.doesNotMatch((await page(`tasks/${n}`)).text, /Run in my terminal/); | |
| 288 | + assert.equal((await form(`tasks/${n}/terminal`, { terminal: id })).status, 303); | |
| 289 | + assert.equal((await ok("GET", `${R}/${n}`)).runs.length, 2); | |
| 290 | + t.close?.(); | |
| 291 | +}); |