agent-office-studio 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/terminal.md DELETED
@@ -1,46 +0,0 @@
1
- # The terminal
2
-
3
- There is a real shell in the drawer under the office: `⌃``, or the terminal
4
- button in the top right. Tabs across the top, `+` opens another, and the picker
5
- beside it chooses which project folder the next one starts in. Drag the top
6
- edge to resize — the office makes room rather than hiding behind it. It is a
7
- proper pty, so `vim`, `top`, `git add -p`, colours and your own prompt all work
8
- the way they do in a terminal app.
9
-
10
- It is off until you ask for it:
11
-
12
- ```bash
13
- OFFICE_TERMINAL=1 npm start
14
- ```
15
-
16
- That switch exists because shell access is the one door in the building that
17
- doesn't care about clearance. A job runs with the mode you picked and leaves a
18
- log; a shell is you, with everything you can do, and no record of what it did.
19
- So:
20
-
21
- - **Off by default.** No flag, no terminal — the socket won't even open.
22
- - **This machine's browser only.** The server already refuses any request whose
23
- host or origin isn't local. On top of that, a session needs a single-use
24
- ticket that expires in ten seconds, and tickets are only handed out on that
25
- origin-checked route — the socket accepts nothing else.
26
- - **Not for the staff.** No agent can open one. There's no office tool and no
27
- `/api/agent` route that reaches it, and a shell never inherits the job
28
- credentials the office gives its own children.
29
- - **Nothing is kept.** Not in `data/state.json`, not in the chat, not on the
30
- main socket. Scrollback lives in the tab and dies with it. `data/terminal.log`
31
- records when sessions opened and closed and in which folder — never
32
- keystrokes, because a log of what you typed into a shell is a file full of
33
- passwords.
34
- - **It tidies up.** Closing a tab hangs up the shell, a session with nothing
35
- happening closes itself after 30 minutes, and there's a cap on how many can
36
- be open at once.
37
-
38
- What it is *not* is a sandbox. The shell runs as you and `cd ..` walks out of
39
- whichever folder it started in; picking the folder is a convenience, not a
40
- boundary. Leave it off on any machine where "whoever has the browser" isn't
41
- just you.
42
-
43
- The terminal needs [`node-pty`](https://github.com/microsoft/node-pty), which
44
- builds natively — it's an optional dependency, so `npm install` on a machine
45
- without build tools still gets you the whole office, just without this. On
46
- macOS: `xcode-select --install`, then `npm install`.
@@ -1,255 +0,0 @@
1
- // A real terminal inside the office: a pty on this machine, driven from the
2
- // browser over a socket of its own.
3
- //
4
- // Shell access is the sharpest thing in the building — the one door that
5
- // doesn't care what clearance a job was given — so it is fenced four ways:
6
- //
7
- // * It is shut until you open it. `OFFICE_TERMINAL=1` on the server, or
8
- // there is no terminal at all.
9
- // * Only a browser on this machine can start a session. A socket carries no
10
- // proof of its own; it has to present a ticket, and tickets are only
11
- // minted on a POST that passed the host and origin checks in index.js.
12
- // One use, ten seconds.
13
- // * No agent can reach it. There is no office tool and no /api/agent route
14
- // that opens a session, and nothing here spawns one on an agent's behalf.
15
- // If you are adding agent-facing routes later: not this one.
16
- // * Nothing typed or printed here is kept. It never reaches state.json, the
17
- // chat, or the main socket. Scrollback lives in the tab and dies with it.
18
- //
19
- // What this is not is a sandbox. The shell runs as you, and `cd ..` leaves
20
- // whichever folder it started in. Choosing the folder is a convenience, not a
21
- // boundary — the boundary is the four points above.
22
-
23
- import crypto from 'node:crypto';
24
- import fs from 'node:fs';
25
- import path from 'node:path';
26
-
27
- const TICKET_MS = 10_000;
28
- const COLS = [20, 500];
29
- const ROWS = [5, 200];
30
- // How far the socket may fall behind the pty before we stop reading it. A
31
- // `yes` or a cat of something huge outruns any browser; pausing the pty is
32
- // what a real terminal's flow control does, and it keeps the server's memory
33
- // flat instead of queueing megabytes per session.
34
- const BACKPRESSURE = 1 << 20;
35
- const DRAIN_MS = 50;
36
- const KILL_GRACE_MS = 2000;
37
-
38
- const SHELL = process.env.OFFICE_TERMINAL_SHELL || process.env.SHELL || '/bin/zsh';
39
-
40
- const clamp = (n, [lo, hi], fallback) => {
41
- const v = Math.round(Number(n));
42
- return Number.isFinite(v) ? Math.min(hi, Math.max(lo, v)) : fallback;
43
- };
44
-
45
- // node-pty is a native module, so it is an optional dependency: a machine
46
- // without build tools still gets the whole office, just no terminal, and says
47
- // so instead of failing to boot.
48
- let pty = null;
49
- let ptyError = '';
50
- async function loadPty() {
51
- if (pty || ptyError) return pty;
52
- try {
53
- const mod = await import('node-pty');
54
- pty = typeof mod.spawn === 'function' ? mod : mod.default;
55
- if (typeof pty?.spawn !== 'function') throw new Error('no spawn export');
56
- } catch (err) {
57
- ptyError = /Cannot find module|ERR_MODULE_NOT_FOUND/.test(String(err?.message))
58
- ? 'node-pty is not installed. Run `npm install` with the Xcode command line tools present (`xcode-select --install`).'
59
- : `node-pty would not load: ${err.message}`;
60
- pty = null;
61
- }
62
- return pty;
63
- }
64
-
65
- export class Terminals {
66
- constructor({ root, dataDir, folders }) {
67
- this.root = path.resolve(root);
68
- this.folders = folders;
69
- this.logFile = path.join(dataDir, 'terminal.log');
70
- this.on = process.env.OFFICE_TERMINAL === '1';
71
- this.max = clamp(process.env.OFFICE_TERMINAL_MAX, [1, 16], 4);
72
- // A shell you forgot about is a shell someone else could walk up to, so
73
- // one that has neither taken input nor printed anything closes itself.
74
- this.idleMs = clamp(process.env.OFFICE_TERMINAL_IDLE_MIN, [0, 1440], 30) * 60_000;
75
- this.tickets = new Map();
76
- this.sessions = new Set();
77
- }
78
-
79
- async status() {
80
- if (!this.on) {
81
- return { enabled: false, reason: 'The terminal is off. Start the office with OFFICE_TERMINAL=1 to open it.' };
82
- }
83
- if (!(await loadPty())) return { enabled: false, reason: ptyError };
84
- let folders = [];
85
- try { folders = this.folders(); } catch {}
86
- return {
87
- enabled: true,
88
- shell: SHELL,
89
- root: path.basename(this.root),
90
- folders,
91
- open: this.sessions.size,
92
- max: this.max,
93
- idleMinutes: this.idleMs / 60_000,
94
- };
95
- }
96
-
97
- // Where a session may start. One of the project folders, or the folder they
98
- // all sit in — never a path handed to us from the socket.
99
- resolve(folder) {
100
- if (!folder) return this.root;
101
- if (typeof folder !== 'string' || /[\/\\]/.test(folder) || folder.startsWith('.')) return null;
102
- const p = path.join(this.root, folder);
103
- if (p !== this.root && !p.startsWith(this.root + path.sep)) return null;
104
- try { return fs.statSync(p).isDirectory() ? p : null; } catch { return null; }
105
- }
106
-
107
- async ticket({ folder = '' } = {}) {
108
- const status = await this.status();
109
- if (!status.enabled) throw Object.assign(new Error(status.reason), { code: 403 });
110
- if (this.sessions.size >= this.max) {
111
- throw Object.assign(new Error(`${this.max} terminals are already open.`), { code: 429 });
112
- }
113
- const cwd = this.resolve(folder);
114
- if (!cwd) throw Object.assign(new Error('Not a project folder.'), { code: 400 });
115
-
116
- const id = crypto.randomBytes(24).toString('hex');
117
- const timer = setTimeout(() => this.tickets.delete(id), TICKET_MS);
118
- timer.unref?.();
119
- this.tickets.set(id, { cwd, timer });
120
- return { ticket: id, folder, cwd: folder || status.root, expiresIn: TICKET_MS };
121
- }
122
-
123
- // Single use. The socket has nothing else to show, so a replayed or expired
124
- // ticket has to get nothing.
125
- claim(id) {
126
- const t = typeof id === 'string' && id ? this.tickets.get(id) : null;
127
- if (!t) return null;
128
- clearTimeout(t.timer);
129
- this.tickets.delete(id);
130
- return t;
131
- }
132
-
133
- async open(ws, ticketId) {
134
- const claim = this.claim(ticketId);
135
- if (!claim) return ws.close(4003, 'No ticket.');
136
- if (this.sessions.size >= this.max) return ws.close(4029, 'Too many terminals.');
137
- const lib = await loadPty();
138
- if (!lib) return ws.close(4003, 'No terminal.');
139
- // Ten seconds is long enough for a socket to follow its ticket, but the
140
- // tab can still be gone by now.
141
- if (ws.readyState !== ws.OPEN) return;
142
-
143
- const env = { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor', OFFICE_TERMINAL: '1' };
144
- // This shell is yours, not an agent's: never let it inherit credentials
145
- // that would let it call the office's own agent API.
146
- delete env.OFFICE_JOB;
147
- delete env.OFFICE_TOKEN;
148
- delete env.OFFICE_URL;
149
-
150
- let child;
151
- try {
152
- child = lib.spawn(SHELL, ['-l'], { name: 'xterm-256color', cwd: claim.cwd, cols: 80, rows: 24, env });
153
- } catch (err) {
154
- this.log(`fail cwd=${claim.cwd} ${err.message}`);
155
- return ws.close(4000, 'The shell would not start.');
156
- }
157
-
158
- const session = { child, ws };
159
- this.sessions.add(session);
160
- this.log(`open pid=${child.pid} shell=${SHELL} cwd=${claim.cwd}`);
161
-
162
- const send = (msg) => {
163
- if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg));
164
- };
165
-
166
- let done = false;
167
- let idle = null;
168
- let paused = false;
169
- const subs = [];
170
-
171
- const touch = () => {
172
- clearTimeout(idle);
173
- if (!this.idleMs || done) return;
174
- idle = setTimeout(() => {
175
- const mins = Math.round(this.idleMs / 60_000);
176
- send({ t: 'exit', why: `closed after ${mins} ${mins === 1 ? 'minute' : 'minutes'} with nothing happening` });
177
- end('idle');
178
- }, this.idleMs);
179
- idle.unref?.();
180
- };
181
-
182
- const drain = () => {
183
- if (done || paused === false) return;
184
- if (ws.readyState !== ws.OPEN) return end('gone');
185
- if (ws.bufferedAmount > BACKPRESSURE) return setTimeout(drain, DRAIN_MS).unref?.();
186
- paused = false;
187
- try { child.resume(); } catch {}
188
- };
189
-
190
- const end = (why) => {
191
- if (done) return;
192
- done = true;
193
- clearTimeout(idle);
194
- this.sessions.delete(session);
195
- for (const s of subs) { try { s.dispose(); } catch {} }
196
- if (why !== 'exit') {
197
- // SIGHUP is what closing a terminal window sends, so shells and the
198
- // jobs under them get their usual chance to tidy up first.
199
- try { child.kill('SIGHUP'); } catch {}
200
- const hard = setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, KILL_GRACE_MS);
201
- hard.unref?.();
202
- this.log(`close pid=${child.pid} why=${why}`);
203
- }
204
- try { ws.close(1000, 'Terminal closed.'); } catch {}
205
- };
206
-
207
- subs.push(child.onData((d) => {
208
- touch();
209
- send({ t: 'out', d });
210
- if (!paused && ws.bufferedAmount > BACKPRESSURE) {
211
- paused = true;
212
- try { child.pause(); } catch { paused = false; }
213
- if (paused) setTimeout(drain, DRAIN_MS).unref?.();
214
- }
215
- }));
216
-
217
- subs.push(child.onExit(({ exitCode, signal }) => {
218
- this.log(`exit pid=${child.pid} code=${exitCode}${signal ? ` signal=${signal}` : ''}`);
219
- send({ t: 'exit', code: exitCode });
220
- end('exit');
221
- }));
222
-
223
- ws.on('message', (raw) => {
224
- if (done) return;
225
- let m;
226
- try { m = JSON.parse(raw); } catch { return; }
227
- if (m?.t === 'in' && typeof m.d === 'string') {
228
- touch();
229
- try { child.write(m.d); } catch {}
230
- } else if (m?.t === 'size') {
231
- try { child.resize(clamp(m.cols, COLS, 80), clamp(m.rows, ROWS, 24)); } catch {}
232
- }
233
- });
234
- ws.on('close', () => end('closed'));
235
- ws.on('error', () => end('error'));
236
-
237
- touch();
238
- send({ t: 'ready', pid: child.pid, shell: SHELL, cwd: claim.cwd });
239
- }
240
-
241
- // When a session opened and closed, and where — never what was typed. A
242
- // keystroke log of a terminal is a file full of passwords, which would make
243
- // the audit trail more dangerous than the thing it audits.
244
- log(line) {
245
- try {
246
- fs.mkdirSync(path.dirname(this.logFile), { recursive: true });
247
- fs.appendFileSync(this.logFile, `${new Date().toISOString()} ${line}\n`, { mode: 0o600 });
248
- } catch {}
249
- }
250
-
251
- killAll() {
252
- for (const { child } of this.sessions) { try { child.kill('SIGHUP'); } catch {} }
253
- this.sessions.clear();
254
- }
255
- }