| 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 | } |