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/CHANGELOG.md +40 -0
- package/README.md +0 -1
- package/dist/assets/index-Cdivbl1d.css +1 -0
- package/dist/assets/{index-DgXF8Kqx.js → index-CfqwI0KM.js} +266 -261
- package/dist/index.html +26 -30
- package/docs/README.md +0 -1
- package/docs/claude-access.md +3 -4
- package/docs/configuration.md +4 -5
- package/docs/development.md +16 -0
- package/docs/getting-started.md +0 -1
- package/docs/guides/troubleshooting.md +0 -10
- package/docs/guides/updating.md +24 -20
- package/docs/how-it-works.md +0 -2
- package/docs/jobs.md +21 -0
- package/docs/linked-offices.md +1 -1
- package/docs/packaging.md +3 -4
- package/docs/phone.md +2 -3
- package/docs/security.md +3 -9
- package/docs/using-the-office.md +1 -1
- package/package.json +8 -13
- package/server/chief.js +2 -1
- package/server/icons.js +82 -0
- package/server/index.js +197 -52
- package/server/office-mcp.js +5 -0
- package/server/settings.js +85 -0
- package/dist/assets/_commonjsHelpers-CqkleIqs.js +0 -1
- package/dist/assets/addon-fit-BwprjgOP.js +0 -1
- package/dist/assets/index-D5IzEWKU.css +0 -32
- package/dist/assets/xterm-C7kOH91j.js +0 -9
- package/docs/terminal.md +0 -46
- package/server/terminal.js +0 -255
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`.
|
package/server/terminal.js
DELETED
|
@@ -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
|
-
}
|