| 1 | import type { AppContext } from "../auth/principal.js"; |
| 2 | import { blockedBy, killSwitches } from "../lib/killswitch.js"; |
| 3 | import type { Env } from "../env.js"; |
| 4 | import type { loadRepo } from "../http/common.js"; |
| 5 | import type { Account, Directory, Repo } from "../lib/directory.js"; |
| 6 | import { mayStart } from "../lib/quotas.js"; |
| 7 | import { HttpError, newId, now } from "../lib/util.js"; |
| 8 | import { taskCaller, TaskAccessError } from "./access.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"; |
| 12 | import { terminalRefusal } from "./org-policy.js"; |
| 13 | |
| 14 | /** |
| 15 | * Web terminals (phase 4, /mnt/project-files/agentic/phase4-design.md): a sandbox one person opens |
| 16 | * to work in directly, for example signed in to their own Claude or ChatGPT subscription. |
| 17 | * |
| 18 | * - Only a person in a browser session opens one, with write access, driving one of their agents |
| 19 | * (the sandbox's git credential acts as that agent, capped at write, as for tasks). |
| 20 | * - Only that person sees it or attaches to it: not an admin, not the repository's owner, because |
| 21 | * the shell may hold their provider sign-in. The sandbox API never reaches it either. |
| 22 | * - It ends on End, after TERMINAL_IDLE_MS with nobody attached, or after TERMINAL_MAX_MS; ending |
| 23 | * stops the container (its disk, and any sign-in, go with it) and revokes its git credential. |
| 24 | * - Nothing typed or shown is stored outside the container. |
| 25 | */ |
| 26 | |
| 27 | type Loaded = Awaited<ReturnType<typeof loadRepo>>; |
| 28 | |
| 29 | export const MAX_TERMINALS_PER_REPO = 2; |
| 30 | export const MAX_TERMINALS = 5; |
| 31 | export const TERMINAL_IDLE_MS = 30 * 60_000; |
| 32 | export const TERMINAL_MAX_MS = 8 * 3600_000; |
| 33 | /** Bump when the notice changes: everyone opts in again. */ |
| 34 | export const TERMINAL_TERMS_VERSION = 3; |
| 35 | |
| 36 | export type TerminalStatus = "starting" | "running" | "ended"; |
| 37 | export type TerminalEndReason = "person" | "idle" | "limit" | "access" | "stopped" | "failed" | "disabled" | "suspended"; |
| 38 | |
| 39 | export interface TerminalRow { |
| 40 | id: string; |
| 41 | repo_id: string; |
| 42 | person_id: string; |
| 43 | agent_id: string; |
| 44 | ref: string; |
| 45 | status: TerminalStatus; |
| 46 | sandbox_id: string | null; |
| 47 | created_at: string; |
| 48 | started_at: string | null; |
| 49 | ended_at: string | null; |
| 50 | end_reason: string | null; |
| 51 | /** 1 when the person opened it to run agent tasks (phase 4b); a terminal opened without it never runs one. */ |
| 52 | tasks: number; |
| 53 | } |
| 54 | |
| 55 | export const TERMINAL_END_TEXT: Record<TerminalEndReason, string> = { |
| 56 | person: "You ended it.", |
| 57 | idle: `Nobody was attached for ${TERMINAL_IDLE_MS / 60_000} minutes.`, |
| 58 | limit: `It reached the ${TERMINAL_MAX_MS / 3600_000}-hour limit.`, |
| 59 | access: "You or the agent no longer have write access here.", |
| 60 | stopped: "Its sandbox stopped.", |
| 61 | failed: "Its sandbox didn't start.", |
| 62 | disabled: "Terminals were switched off on this Diffinery.", |
| 63 | suspended: "A site administrator suspended Actions and sandboxes here, or paused hosted compute on this Diffinery.", |
| 64 | }; |
| 65 | |
| 66 | /** Terminals can be switched off for the whole instance (`TERMINALS=off`). */ |
| 67 | export function terminalsEnabled(env: Env): boolean { |
| 68 | return (env.TERMINALS ?? "on").toLowerCase() !== "off"; |
| 69 | } |
| 70 | |
| 71 | export function terminalId(s: string | undefined): string { |
| 72 | if (!s || !/^[A-Za-z0-9_-]{1,64}$/.test(s)) throw new HttpError(404, "terminal not found"); |
| 73 | return s; |
| 74 | } |
| 75 | |
| 76 | export class Terminals { |
| 77 | constructor(private env: Env) {} |
| 78 | |
| 79 | private get db() { |
| 80 | return this.env.DB; |
| 81 | } |
| 82 | |
| 83 | async get(id: string): Promise<TerminalRow | null> { |
| 84 | return (await this.db.prepare("SELECT * FROM agent_terminals WHERE id = ?").bind(id).first<TerminalRow>()) ?? null; |
| 85 | } |
| 86 | |
| 87 | /** A terminal as its opener sees it; anyone else, admins included, gets 404. */ |
| 88 | async mine(id: string, person: Account, repo: Repo): Promise<TerminalRow> { |
| 89 | const t = await this.get(id); |
| 90 | if (!t || t.person_id !== person.id || t.repo_id !== repo.id) throw new HttpError(404, "terminal not found"); |
| 91 | return t; |
| 92 | } |
| 93 | |
| 94 | async list(personId: string, repoId: string, limit = 20): Promise<TerminalRow[]> { |
| 95 | 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; |
| 96 | } |
| 97 | |
| 98 | async open(personId: string): Promise<{ total: number; byRepo: Map<string, number> }> { |
| 99 | 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; |
| 100 | return { total: rows.reduce((a, r) => a + r.n, 0), byRepo: new Map(rows.map((r) => [r.repo_id, r.n])) }; |
| 101 | } |
| 102 | |
| 103 | async termsAt(personId: string): Promise<string | null> { |
| 104 | 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 }>(); |
| 105 | return r && r.version >= TERMINAL_TERMS_VERSION ? r.opted_in_at : null; |
| 106 | } |
| 107 | |
| 108 | async acceptTerms(personId: string): Promise<string> { |
| 109 | const at = now(); |
| 110 | await this.db |
| 111 | .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") |
| 112 | .bind(personId, TERMINAL_TERMS_VERSION, at) |
| 113 | .run(); |
| 114 | return at; |
| 115 | } |
| 116 | |
| 117 | async insert(t: Pick<TerminalRow, "repo_id" | "person_id" | "agent_id" | "ref" | "tasks">): Promise<TerminalRow> { |
| 118 | const row: TerminalRow = { ...t, id: newId(), status: "starting", sandbox_id: null, created_at: now(), started_at: null, ended_at: null, end_reason: null, tasks: t.tasks }; |
| 119 | await this.db |
| 120 | .prepare("INSERT INTO agent_terminals (id, repo_id, person_id, agent_id, ref, status, sandbox_id, created_at, tasks) VALUES (?, ?, ?, ?, ?, 'starting', NULL, ?, ?)") |
| 121 | .bind(row.id, row.repo_id, row.person_id, row.agent_id, row.ref, row.created_at, row.tasks) |
| 122 | .run(); |
| 123 | return row; |
| 124 | } |
| 125 | |
| 126 | async started(id: string, sandboxId: string): Promise<boolean> { |
| 127 | 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(); |
| 128 | return (r.meta.changes ?? 0) > 0; |
| 129 | } |
| 130 | |
| 131 | /** Marks it ended (once); the caller stops the sandbox. Returns whether this call ended it. */ |
| 132 | async markEnded(id: string, reason: TerminalEndReason): Promise<boolean> { |
| 133 | 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(); |
| 134 | return (r.meta.changes ?? 0) > 0; |
| 135 | } |
| 136 | |
| 137 | async running(repoId: string): Promise<TerminalRow[]> { |
| 138 | return (await this.db.prepare("SELECT * FROM agent_terminals WHERE repo_id = ? AND status != 'ended' ORDER BY created_at").bind(repoId).all<TerminalRow>()).results; |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | /** |
| 143 | * Opens a terminal for the person in this browser session. The sandbox starts in the project's |
| 144 | * alarm (it can take a minute); the page shows "Starting" until then. |
| 145 | */ |
| 146 | export async function openTerminal(c: AppContext, l: Loaded, input: { agent?: string; ref?: string; accept?: boolean; tasks?: boolean }): Promise<TerminalRow> { |
| 147 | const me = requirePersonSession(c); |
| 148 | if (!terminalsEnabled(c.env)) throw new HttpError(403, "terminals are switched off on this Diffinery"); |
| 149 | if (l.repo.archived) throw new HttpError(403, "repository is archived (read-only)"); |
| 150 | // The organization's agent policy (phase 5b): terminals, and tasks in them, allowed. |
| 151 | const dir = c.get("dir"); |
| 152 | const refused = await terminalRefusal(c.env, l.repo, { tasks: !!input.tasks, person: { dir, account: me } }); |
| 153 | if (refused) throw new HttpError(403, refused); |
| 154 | const agent = input.agent ? await dir.accountByLogin(input.agent) : null; |
| 155 | if (!agent || agent.kind !== "agent") throw new HttpError(422, "choose one of your agents"); |
| 156 | try { |
| 157 | await taskCaller(dir, me, agent, l.repo); |
| 158 | } catch (e) { |
| 159 | if (e instanceof TaskAccessError) throw new HttpError(403, e.message); |
| 160 | throw e; |
| 161 | } |
| 162 | const terminals = new Terminals(c.env); |
| 163 | if (!(await terminals.termsAt(me.id))) { |
| 164 | if (!input.accept) throw new HttpError(422, "accept the notice first: you use your own provider account, under its terms"); |
| 165 | await terminals.acceptTerms(me.id); |
| 166 | audit(c, me, l.repo, "terminal.terms", { version: TERMINAL_TERMS_VERSION }); |
| 167 | } |
| 168 | const ref = (input.ref ?? "").trim() || l.repo.default_branch; |
| 169 | if (!(await l.stub.getRef(`refs/heads/${ref}`))) throw new HttpError(422, `the branch ${ref} doesn't exist`); |
| 170 | const open = await terminals.open(me.id); |
| 171 | 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`); |
| 172 | if (open.total >= MAX_TERMINALS) throw new HttpError(429, `you have ${MAX_TERMINALS} terminals open, the most allowed; end one first`); |
| 173 | // The account that owns the repository pays for the sandbox (lib/quotas.ts); say so now rather than |
| 174 | // after it fails to start. RepoDO.createSandbox checks again when it starts. |
| 175 | // Kill switches (lib/killswitch.ts) refuse new sandboxes, terminals included. |
| 176 | const block = blockedBy(await killSwitches(c.env, l.repo.id), "sandbox"); |
| 177 | if (block) throw new HttpError(403, block); |
| 178 | const quota = await mayStart(c.env, l.repo.owner_id, "sandbox"); |
| 179 | if (!quota.ok) throw new HttpError(429, quota.message); |
| 180 | const t = await terminals.insert({ repo_id: l.repo.id, person_id: me.id, agent_id: agent.id, ref, tasks: input.tasks ? 1 : 0 }); |
| 181 | audit(c, me, l.repo, "terminal.open", { terminal: t.id, agent: agent.login, ref, tasks: !!input.tasks, runtimes: { "claude-code": RUNTIME_VERSIONS["claude-code"], codex: RUNTIME_VERSIONS.codex } }); |
| 182 | await projectStub(c.env, l.repo.id).wakeTerminals(l.repo.id); |
| 183 | return t; |
| 184 | } |
| 185 | |
| 186 | /** |
| 187 | * Ends a terminal: marks it ended, stops a task running in it, then stops its sandbox (which revokes its git |
| 188 | * credential). `project` is the repository's ProjectDO when the caller is that object itself. |
| 189 | */ |
| 190 | export async function endTerminal(env: Env, dir: Directory, t: TerminalRow, reason: TerminalEndReason, by?: Account, project?: { terminalEnded(id: string): Promise<void> }): Promise<void> { |
| 191 | if (!(await new Terminals(env).markEnded(t.id, reason))) return; |
| 192 | await (project ?? projectStub(env, t.repo_id)).terminalEnded(t.id).catch((e: unknown) => console.error("stopping the terminal's task failed", e)); |
| 193 | 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)); |
| 194 | const repo = await dir.repoById(t.repo_id).catch(() => null); |
| 195 | const actor = by ?? (await dir.account(t.person_id).catch(() => null)); |
| 196 | await dir |
| 197 | .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 } }) |
| 198 | .catch((e) => console.error("audit failed", e)); |
| 199 | } |
| 200 | |
| 201 | function audit(c: AppContext, me: Account, repo: Repo, action: string, detail: Record<string, unknown>): void { |
| 202 | c.executionCtx.waitUntil( |
| 203 | c |
| 204 | .get("dir") |
| 205 | .audit({ actorId: me.id, actorLogin: me.login, action, target: `${repo.owner_login}/${repo.name}`, detail }) |
| 206 | .catch((e) => console.error("audit failed", e)), |
| 207 | ); |
| 208 | } |
| 209 | |
| 210 | // ---------------------------------------------------------------- 4b: tasks in a terminal |
| 211 | |
| 212 | /** |
| 213 | * The runtimes a terminal runs tasks with, signed in there: Claude Code, and Codex on private repositories only |
| 214 | * (OpenAI allows signing in with a ChatGPT plan only there). |
| 215 | */ |
| 216 | export function terminalRuntimes(repo: Repo): RuntimeId[] { |
| 217 | return repo.visibility === "public" ? ["claude-code"] : ["claude-code", "codex"]; |
| 218 | } |
| 219 | |
| 220 | /** The person's own open terminal in this repository, with the opt-in in force, checked again at every dispatch. */ |
| 221 | async function dispatchTerminal(c: AppContext, l: Loaded, me: Account, id: string | undefined, runtime: RuntimeId, agentId?: string): Promise<{ t: TerminalRow; to: { id: string; sandboxId: string } }> { |
| 222 | if (!terminalsEnabled(c.env)) throw new HttpError(403, "terminals are switched off on this Diffinery"); |
| 223 | const refused = await terminalRefusal(c.env, l.repo, { tasks: true, runtime, person: { dir: c.get("dir"), account: me } }); |
| 224 | if (refused) throw new HttpError(403, refused); |
| 225 | const terminals = new Terminals(c.env); |
| 226 | const t = await terminals.mine(terminalId(id), me, l.repo); |
| 227 | if (t.status !== "running" || !t.sandbox_id) throw new HttpError(409, "the terminal isn't running"); |
| 228 | if (!t.tasks) throw new HttpError(409, "this terminal was opened without agent tasks: open one with \"Will run agent tasks\" to give it one"); |
| 229 | if (!(await terminals.termsAt(me.id))) throw new HttpError(403, "accept the notice for terminals first: tasks there use your own provider account"); |
| 230 | // Kill switches (lib/killswitch.ts) stop sandboxes here: the terminal's is ending, and starts no task meanwhile. |
| 231 | const block = blockedBy(await killSwitches(c.env, l.repo.id), "sandbox"); |
| 232 | if (block) throw new HttpError(403, block); |
| 233 | 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"); |
| 234 | 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"); |
| 235 | return { t, to: { id: t.id, sandboxId: t.sandbox_id } }; |
| 236 | } |
| 237 | |
| 238 | function terminalRuntime(l: Loaded, runtime: unknown): RuntimeId { |
| 239 | const ok = terminalRuntimes(l.repo); |
| 240 | 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"); |
| 241 | return runtime as RuntimeId; |
| 242 | } |
| 243 | |
| 244 | /** |
| 245 | * Start a task in the person's terminal (phase 4b): only from their own click on the terminal's page. The CLI signed |
| 246 | * in there does the work, on their own subscription, as the terminal's agent; Diffinery pushes and opens the pull |
| 247 | * request as for any task. Its checks don't start fix runs by themselves. |
| 248 | */ |
| 249 | export async function startTaskInTerminal(c: AppContext, l: Loaded, id: string | undefined, input: { goal?: unknown; runtime?: unknown }): Promise<TaskView> { |
| 250 | const me = requirePersonSession(c); |
| 251 | if (l.repo.archived) throw new HttpError(403, "repository is archived (read-only)"); |
| 252 | const goal = typeof input.goal === "string" ? input.goal.trim() : ""; |
| 253 | if (!goal) throw new HttpError(422, "write the goal"); |
| 254 | if (goal.length > MAX_GOAL) throw new HttpError(422, `the goal is longer than ${MAX_GOAL} characters`); |
| 255 | const runtime = terminalRuntime(l, input.runtime); |
| 256 | const { t, to } = await dispatchTerminal(c, l, me, id, runtime); |
| 257 | const dir = c.get("dir"); |
| 258 | const agent = await dir.account(t.agent_id); |
| 259 | if (!agent) throw new HttpError(403, "the terminal's agent no longer exists"); |
| 260 | try { |
| 261 | await taskCaller(dir, me, agent, l.repo); |
| 262 | } catch (e) { |
| 263 | if (e instanceof TaskAccessError) throw new HttpError(403, e.message); |
| 264 | throw e; |
| 265 | } |
| 266 | if (!(await l.stub.getRef(`refs/heads/${t.ref}`))) throw new HttpError(422, `the branch ${t.ref} doesn't exist any more`); |
| 267 | const project = projectStub(c.env, l.repo.id); |
| 268 | 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"); |
| 269 | 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 }); |
| 270 | audit(c, me, l.repo, "task.start", { agent: agent.login, runtime, terminal: t.id, fix_checks: false, land: false }); |
| 271 | audit(c, me, l.repo, "terminal.task", { terminal: t.id, task: task.number, runtime, version: RUNTIME_VERSIONS[runtime as "claude-code" | "codex"] }); |
| 272 | return task; |
| 273 | } |
| 274 | |
| 275 | /** Run an existing task again in the person's terminal (phase 4b): only from their own click on the task's page. */ |
| 276 | export async function runTaskInTerminal(c: AppContext, l: Loaded, number: number, id: string | undefined, message: unknown): Promise<TaskView> { |
| 277 | const { person, task } = await starter(c, l, number); |
| 278 | if (task.kind !== "task") throw new HttpError(409, "only agent tasks run in a terminal"); |
| 279 | const runtime = terminalRuntime(l, task.runtime); |
| 280 | const text = typeof message === "string" ? message.trim() : ""; |
| 281 | if (text.length > MAX_GOAL) throw new HttpError(422, `the message is longer than ${MAX_GOAL} characters`); |
| 282 | const { t, to } = await dispatchTerminal(c, l, person, id, runtime, task.agent_id); |
| 283 | const out = await projectStub(c.env, l.repo.id).runInTerminal(number, to, text || null); |
| 284 | audit(c, person, l.repo, "terminal.task", { terminal: t.id, task: number, runtime, version: RUNTIME_VERSIONS[runtime as "claude-code" | "codex"] }); |
| 285 | return out; |
| 286 | } |
| 287 | |
| 288 | /** The person's open terminal a task could run in now, for its page's Run in my terminal button; null when none. */ |
| 289 | export async function terminalForTask(env: Env, me: Account, repo: Repo, task: TaskView): Promise<string | null> { |
| 290 | if (!terminalsEnabled(env) || task.kind !== "task" || task.person_id !== me.id || !terminalRuntimes(repo).includes(task.runtime)) return null; |
| 291 | if ((task.attempts > 1 && task.picked === null) || task.plan_state === "planning" || task.plan_state === "waiting") return null; |
| 292 | const terminals = new Terminals(env); |
| 293 | const open = (await terminals.list(me.id, repo.id)).filter((t) => t.status === "running" && t.tasks && t.agent_id === task.agent_id); |
| 294 | if (!open.length || !(await terminals.termsAt(me.id))) return null; |
| 295 | const project = projectStub(env, repo.id); |
| 296 | for (const t of open) if ((await project.terminalTask(t.id)) === null) return t.id; |
| 297 | return null; |
| 298 | } |