Diffinery

codician/diffinerypublic

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

114 lines · 4.2 KBRaw History
1import { spawn, type ChildProcess } from "node:child_process";
2import { writeFileSync } from "node:fs";
3import path from "node:path";
4import type { WebSocket } from "ws";
5import { PTY_HELPER } from "./pty-helper.js";
6
7/** What the screen is redrawn from when someone attaches again. */
8export 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 */
16export 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
111export 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}