@monster-orange/den 0.4.0 → 0.4.2
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 +457 -0
- package/README.md +88 -158
- package/docs/distributions.md +216 -16
- package/docs/reference.md +177 -0
- package/package.json +3 -1
- package/src/shared/actor-token.ts +275 -0
- package/src/shared/agent-actor.ts +12 -2
- package/src/shared/agents/den-claude.ts +99 -1
- package/src/shared/agents/runtime.ts +7 -0
- package/src/shared/agents/runtimes.ts +9 -2
- package/src/shared/den-config.ts +52 -3
- package/src/shared/environment-manager/scope.ts +22 -0
- package/src/shared/environment-manager/service-runner.ts +94 -2
- package/src/shared/profile.ts +15 -4
- package/src/shared/sessions/client.ts +16 -2
- package/src/shared/sessions/den-pty.ts +10 -2
- package/src/shared/sessions/env.ts +13 -3
- package/src/shared/sessions/host.ts +56 -8
- package/src/shared/sessions/types.ts +8 -0
- package/src/shared/web-secret.ts +181 -0
- package/src/shared/web-session.ts +491 -0
- package/src/ui/cli/agent-ops.ts +843 -0
- package/src/ui/cli/agents-command.ts +97 -615
- package/src/ui/cli/flag-parser.ts +21 -0
- package/src/ui/cli/index.ts +127 -11
- package/src/ui/cli/sessions-command.ts +49 -5
- package/src/ui/cli/target-workspace.ts +105 -0
- package/src/ui/cli/web-command.ts +542 -14
- package/src/ui/mcp/api.ts +7 -0
- package/src/ui/mcp/server.ts +74 -6
- package/src/ui/server/agent-routes.ts +324 -0
- package/src/ui/server/audit.ts +81 -27
- package/src/ui/server/http-errors.ts +21 -3
- package/src/ui/server/index.ts +821 -66
- package/src/ui/server/public/agents-page.js +654 -0
- package/src/ui/server/public/agents-section.js +299 -0
- package/src/ui/server/public/agents.html +439 -0
- package/src/ui/server/public/appearance.js +3 -0
- package/src/ui/server/public/error-page.js +103 -0
- package/src/ui/server/public/header.js +45 -11
- package/src/ui/server/public/i18n/es.js +90 -1
- package/src/ui/server/public/i18n/ja.js +86 -1
- package/src/ui/server/public/local-dev.html +53 -0
- package/src/ui/server/public/logs.html +3 -0
- package/src/ui/server/public/panel.js +1 -1
- package/src/ui/server/public/release.html +8 -0
- package/src/ui/server/public/settings-page.js +5 -0
- package/src/ui/server/public/settings-registry.js +33 -0
- package/src/ui/server/public/shell.js +248 -5
- package/src/ui/server/public/topbar.js +51 -2
- package/src/ui/server/request-guard.ts +670 -0
- package/src/ui/server/route-classes.ts +317 -0
- package/src/ui/server/session-routes.ts +445 -0
- package/src/ui/server/shutdown-guard.ts +91 -0
- package/src/ui/server/sudo-store.ts +29 -3
- package/src/ui/server/ui-assets.ts +10 -1
- package/src/ui/server/websocket.ts +326 -0
- package/src/ui/server/workspace-routes.ts +13 -7
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Agents tab's routes (AG-4a-term, AG spec §5d):
|
|
3
|
+
*
|
|
4
|
+
* GET /api/sessions every session and agent in the active
|
|
5
|
+
* workspaces (read-only class)
|
|
6
|
+
* WS /api/sessions/<id>/io a session's terminal (shell-grade)
|
|
7
|
+
*
|
|
8
|
+
* The list joins the session host's sessions to the agent records, as
|
|
9
|
+
* `<cli> agents list` does (agent-ops.ts agentRows), and adds the plain
|
|
10
|
+
* sessions (`sessions start`), labelled kind "session". An agent whose
|
|
11
|
+
* actor token `web logout --all` revoked is marked (tokenRevoked). The
|
|
12
|
+
* host knows nothing of approvals yet, so `waiting` is never set here
|
|
13
|
+
* (AG-6a adds it). Asking never starts a session host.
|
|
14
|
+
*
|
|
15
|
+
* The io route bridges one WebSocket to one attachment on the session host
|
|
16
|
+
* (its own connection to the host, so a browser and the CLI, or two
|
|
17
|
+
* browsers, attach at once and each gets every byte):
|
|
18
|
+
* - on attach, the scrollback replay, then live output: binary frames;
|
|
19
|
+
* - a text frame `{"type":"session",…}` first (what is attached), and
|
|
20
|
+
* `{"type":"exit","exitCode":n}` when the session ends;
|
|
21
|
+
* - in: binary frames are input; a text frame is JSON control:
|
|
22
|
+
* `{"type":"resize","cols":c,"rows":r}` (or `{"type":"input","data":s}`).
|
|
23
|
+
* Closing the socket detaches. Nothing here ever signals a session: leaving
|
|
24
|
+
* the tab never kills it.
|
|
25
|
+
*
|
|
26
|
+
* The request checks ran before (index.ts, request-guard.ts): Host, Origin
|
|
27
|
+
* on the upgrade, CSRF (the `den.csrf.<token>` subprotocol: a browser's
|
|
28
|
+
* WebSocket can't send a header), a signed-in session beyond loopback, and
|
|
29
|
+
* an agent token's own session only. The attach and the detach are audited
|
|
30
|
+
* with the actor from the credential.
|
|
31
|
+
*/
|
|
32
|
+
import type * as http from "http";
|
|
33
|
+
import type { Duplex } from "stream";
|
|
34
|
+
import { getActiveWorkspaces } from "../../shared/environment-manager/workspaces.js";
|
|
35
|
+
import { getStateDir } from "../../shared/paths.js";
|
|
36
|
+
import { product } from "../../shared/product.js";
|
|
37
|
+
import { revokedAgentIds } from "../../shared/actor-token.js";
|
|
38
|
+
import { listRecords } from "../../shared/agents/records.js";
|
|
39
|
+
import {
|
|
40
|
+
connectHost,
|
|
41
|
+
noHost,
|
|
42
|
+
type HostClient,
|
|
43
|
+
} from "../../shared/sessions/client.js";
|
|
44
|
+
import { parseSessionName } from "../../shared/sessions/names.js";
|
|
45
|
+
import { sessionPaths } from "../../shared/sessions/paths.js";
|
|
46
|
+
import { loadPty, type PtyLoad } from "../../shared/sessions/pty.js";
|
|
47
|
+
import type { SessionInfo } from "../../shared/sessions/types.js";
|
|
48
|
+
import { agentRows } from "../cli/agent-ops.js";
|
|
49
|
+
import { state as sessionState } from "../cli/sessions-command.js";
|
|
50
|
+
import { recordAction } from "./audit.js";
|
|
51
|
+
import { sendJson } from "./http-helpers.js";
|
|
52
|
+
import { auditActorOf, principalOf } from "./request-guard.js";
|
|
53
|
+
import {
|
|
54
|
+
acceptWebSocket,
|
|
55
|
+
handshakeRefusal,
|
|
56
|
+
refuseUpgrade,
|
|
57
|
+
WS_CLOSE,
|
|
58
|
+
} from "./websocket.js";
|
|
59
|
+
|
|
60
|
+
export interface SessionRouteDeps {
|
|
61
|
+
stateDir?: () => string;
|
|
62
|
+
/** The distribution's name: the `<dist>` of a session name. */
|
|
63
|
+
dist?: () => string;
|
|
64
|
+
/** A client of the running host, or null (never starts one). */
|
|
65
|
+
connectExisting?: () => Promise<HostClient | null>;
|
|
66
|
+
activeWorkspaces?: () => string[];
|
|
67
|
+
pty?: () => PtyLoad;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface Resolved {
|
|
71
|
+
stateDir: () => string;
|
|
72
|
+
dist: () => string;
|
|
73
|
+
connectExisting: () => Promise<HostClient | null>;
|
|
74
|
+
activeWorkspaces: () => string[];
|
|
75
|
+
pty: () => PtyLoad;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function resolve(d: SessionRouteDeps = {}): Resolved {
|
|
79
|
+
const stateDir = d.stateDir ?? (() => getStateDir());
|
|
80
|
+
return {
|
|
81
|
+
stateDir,
|
|
82
|
+
dist: d.dist ?? (() => product().name),
|
|
83
|
+
connectExisting:
|
|
84
|
+
d.connectExisting ??
|
|
85
|
+
(async () => {
|
|
86
|
+
try {
|
|
87
|
+
return await connectHost(sessionPaths(stateDir()).socket);
|
|
88
|
+
} catch (e) {
|
|
89
|
+
if (noHost(e)) return null;
|
|
90
|
+
throw e;
|
|
91
|
+
}
|
|
92
|
+
}),
|
|
93
|
+
activeWorkspaces: d.activeWorkspaces ?? (() => getActiveWorkspaces()),
|
|
94
|
+
pty: d.pty ?? (() => loadPty()),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The subprotocol the io route answers with. */
|
|
99
|
+
export const IO_PROTOCOL = "den.io";
|
|
100
|
+
|
|
101
|
+
const IO_PATH = /^\/api\/sessions\/([^/]+)\/io$/;
|
|
102
|
+
|
|
103
|
+
/** One row of the Agents tab's list. */
|
|
104
|
+
export interface ListedSession {
|
|
105
|
+
/** The session host's id; for an agent with no session, its last one. */
|
|
106
|
+
id: string;
|
|
107
|
+
/** `<dist>-<ws>:<name>`. */
|
|
108
|
+
name: string;
|
|
109
|
+
workspace: string;
|
|
110
|
+
kind: "agent" | "session";
|
|
111
|
+
/** The `<name>` part: an agent's id, a plain session's name. */
|
|
112
|
+
label: string;
|
|
113
|
+
agent?: string;
|
|
114
|
+
persona?: string;
|
|
115
|
+
runtime?: string;
|
|
116
|
+
model?: string;
|
|
117
|
+
cwd?: string;
|
|
118
|
+
/** running, stopping, exited (<code>), stopped, ended. */
|
|
119
|
+
state: string;
|
|
120
|
+
exited: boolean;
|
|
121
|
+
exitCode?: number;
|
|
122
|
+
startedAt?: string;
|
|
123
|
+
/** The host has it (running, or ended with its scrollback): a pane can attach. */
|
|
124
|
+
attachable: boolean;
|
|
125
|
+
resumable?: boolean;
|
|
126
|
+
/** Its actor token was revoked (`web logout --all`): restart or resume it. */
|
|
127
|
+
tokenRevoked?: boolean;
|
|
128
|
+
/** An approval is pending (AG-6a; only when the host knows). */
|
|
129
|
+
waiting?: boolean;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The list, from the host's sessions and the agent records. */
|
|
133
|
+
export function listedSessions(
|
|
134
|
+
sessions: SessionInfo[],
|
|
135
|
+
d: Pick<Resolved, "stateDir" | "dist" | "activeWorkspaces">,
|
|
136
|
+
): ListedSession[] {
|
|
137
|
+
const dist = d.dist();
|
|
138
|
+
const active = new Set(d.activeWorkspaces());
|
|
139
|
+
const mine = sessions.filter((s) => {
|
|
140
|
+
const p = parseSessionName(s.name, dist);
|
|
141
|
+
return p !== null && active.has(p.workspace);
|
|
142
|
+
});
|
|
143
|
+
const records = listRecords(d.stateDir()).filter((r) =>
|
|
144
|
+
active.has(r.workspace),
|
|
145
|
+
);
|
|
146
|
+
const revoked = revokedAgentIds(d.stateDir());
|
|
147
|
+
const out: ListedSession[] = [];
|
|
148
|
+
const agentSessions = new Set<string>();
|
|
149
|
+
for (const r of agentRows(mine, records)) {
|
|
150
|
+
const rec = records.find((x) => x.id === r.agent)!;
|
|
151
|
+
const s = r.session;
|
|
152
|
+
if (s) agentSessions.add(s.ref.id);
|
|
153
|
+
out.push({
|
|
154
|
+
id: s?.ref.id ?? rec.session.id,
|
|
155
|
+
name: s?.name ?? rec.sessionName,
|
|
156
|
+
workspace: rec.workspace,
|
|
157
|
+
kind: "agent",
|
|
158
|
+
label: rec.id,
|
|
159
|
+
agent: rec.id,
|
|
160
|
+
persona: rec.persona,
|
|
161
|
+
runtime: rec.runtime,
|
|
162
|
+
...(rec.model ? { model: rec.model } : {}),
|
|
163
|
+
cwd: rec.cwd,
|
|
164
|
+
state: r.state,
|
|
165
|
+
exited: !s || s.exited === true,
|
|
166
|
+
...(s?.exitCode !== undefined ? { exitCode: s.exitCode } : {}),
|
|
167
|
+
...(r.startedAt ? { startedAt: r.startedAt } : {}),
|
|
168
|
+
attachable: Boolean(s),
|
|
169
|
+
resumable: r.resumable,
|
|
170
|
+
...(revoked.has(rec.id) && s && !s.exited ? { tokenRevoked: true } : {}),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
for (const s of mine) {
|
|
174
|
+
if (agentSessions.has(s.ref.id)) continue;
|
|
175
|
+
const p = parseSessionName(s.name, dist)!;
|
|
176
|
+
out.push({
|
|
177
|
+
id: s.ref.id,
|
|
178
|
+
name: s.name,
|
|
179
|
+
workspace: p.workspace,
|
|
180
|
+
kind: "session",
|
|
181
|
+
label: p.name,
|
|
182
|
+
state: sessionState(s),
|
|
183
|
+
exited: s.exited === true,
|
|
184
|
+
...(s.exitCode !== undefined ? { exitCode: s.exitCode } : {}),
|
|
185
|
+
startedAt: s.startedAt,
|
|
186
|
+
attachable: true,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
const order = [...active];
|
|
190
|
+
return out.sort(
|
|
191
|
+
(a, b) =>
|
|
192
|
+
order.indexOf(a.workspace) - order.indexOf(b.workspace) ||
|
|
193
|
+
(a.startedAt ?? "").localeCompare(b.startedAt ?? "") ||
|
|
194
|
+
a.label.localeCompare(b.label),
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* `GET /api/sessions`; false when `pathname` isn't it. `ioAllowed` is what
|
|
200
|
+
* the request checks say about this browser: beyond loopback without a
|
|
201
|
+
* session, the page lists but can't open a terminal.
|
|
202
|
+
*/
|
|
203
|
+
export async function handleSessionList(
|
|
204
|
+
req: http.IncomingMessage,
|
|
205
|
+
res: http.ServerResponse,
|
|
206
|
+
pathname: string,
|
|
207
|
+
ioAllowed: boolean,
|
|
208
|
+
deps: SessionRouteDeps = {},
|
|
209
|
+
): Promise<boolean> {
|
|
210
|
+
if (pathname !== "/api/sessions") return false;
|
|
211
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
212
|
+
res.setHeader("Allow", "GET");
|
|
213
|
+
sendJson(res, { ok: false, error: "method not allowed" }, 405);
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
const d = resolve(deps);
|
|
217
|
+
const pty = d.pty();
|
|
218
|
+
let sessions: SessionInfo[] = [];
|
|
219
|
+
let host = false;
|
|
220
|
+
let hostError: string | undefined;
|
|
221
|
+
try {
|
|
222
|
+
const c = await d.connectExisting();
|
|
223
|
+
if (c) {
|
|
224
|
+
host = true;
|
|
225
|
+
try {
|
|
226
|
+
sessions = await c.list();
|
|
227
|
+
} finally {
|
|
228
|
+
c.close();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
} catch (e) {
|
|
232
|
+
hostError = (e as Error).message;
|
|
233
|
+
}
|
|
234
|
+
sendJson(res, {
|
|
235
|
+
sessions: listedSessions(sessions, d),
|
|
236
|
+
workspaces: d.activeWorkspaces(),
|
|
237
|
+
host,
|
|
238
|
+
...(hostError ? { hostError } : {}),
|
|
239
|
+
backend: pty.available
|
|
240
|
+
? { id: "den-pty", available: true }
|
|
241
|
+
: { id: "den-pty", available: false, reason: pty.reason },
|
|
242
|
+
io: { allowed: ioAllowed },
|
|
243
|
+
});
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Whether `pathname` is a session's io route. */
|
|
248
|
+
export function isSessionIoPath(pathname: string): boolean {
|
|
249
|
+
return IO_PATH.test(pathname);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const TEXT = (code: number) =>
|
|
253
|
+
code === 400
|
|
254
|
+
? "Bad Request"
|
|
255
|
+
: code === 404
|
|
256
|
+
? "Not Found"
|
|
257
|
+
: code === 426
|
|
258
|
+
? "Upgrade Required"
|
|
259
|
+
: "Service Unavailable";
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* A plain HTTP request to an io route: it is a WebSocket. 426 for a GET,
|
|
263
|
+
* 404 for anything else under /api/sessions/.
|
|
264
|
+
*/
|
|
265
|
+
export function sendSessionHttp(
|
|
266
|
+
req: http.IncomingMessage,
|
|
267
|
+
res: http.ServerResponse,
|
|
268
|
+
pathname: string,
|
|
269
|
+
): void {
|
|
270
|
+
if (
|
|
271
|
+
IO_PATH.test(pathname) &&
|
|
272
|
+
(req.method === "GET" || req.method === "HEAD")
|
|
273
|
+
) {
|
|
274
|
+
res.setHeader("Upgrade", "websocket");
|
|
275
|
+
res.setHeader("Connection", "Upgrade");
|
|
276
|
+
sendJson(
|
|
277
|
+
res,
|
|
278
|
+
{
|
|
279
|
+
ok: false,
|
|
280
|
+
error: "This is a WebSocket: open it with an upgrade",
|
|
281
|
+
code: "upgrade_required",
|
|
282
|
+
},
|
|
283
|
+
426,
|
|
284
|
+
);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
sendJson(
|
|
288
|
+
res,
|
|
289
|
+
{ ok: false, error: "No such session route", code: "not_found" },
|
|
290
|
+
404,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* `GET /api/sessions/<id>/io` with an upgrade, after the request checks.
|
|
296
|
+
* `<id>` is the host's id or the full session name. Refusals answer as
|
|
297
|
+
* HTTP on the raw socket; past them, the WebSocket bridge above.
|
|
298
|
+
*/
|
|
299
|
+
export async function handleSessionUpgrade(
|
|
300
|
+
req: http.IncomingMessage,
|
|
301
|
+
socket: Duplex,
|
|
302
|
+
head: Buffer,
|
|
303
|
+
pathname: string,
|
|
304
|
+
requestId: string,
|
|
305
|
+
deps: SessionRouteDeps = {},
|
|
306
|
+
): Promise<void> {
|
|
307
|
+
const refuse = (status: number, error: string, code: string) =>
|
|
308
|
+
refuseUpgrade(
|
|
309
|
+
socket,
|
|
310
|
+
status,
|
|
311
|
+
{ ok: false, error, code, requestId },
|
|
312
|
+
TEXT(status),
|
|
313
|
+
);
|
|
314
|
+
const m = IO_PATH.exec(pathname);
|
|
315
|
+
if (!m) return refuse(404, "No WebSocket route here", "not_found");
|
|
316
|
+
const bad = handshakeRefusal(req);
|
|
317
|
+
if (bad) return refuse(400, bad, "bad_handshake");
|
|
318
|
+
let target = m[1];
|
|
319
|
+
try {
|
|
320
|
+
target = decodeURIComponent(target);
|
|
321
|
+
} catch {
|
|
322
|
+
/* as given */
|
|
323
|
+
}
|
|
324
|
+
const d = resolve(deps);
|
|
325
|
+
let c: HostClient | null;
|
|
326
|
+
try {
|
|
327
|
+
c = await d.connectExisting();
|
|
328
|
+
} catch (e) {
|
|
329
|
+
return refuse(
|
|
330
|
+
503,
|
|
331
|
+
`the session host: ${(e as Error).message}`,
|
|
332
|
+
"session_host",
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
if (!c)
|
|
336
|
+
return refuse(
|
|
337
|
+
404,
|
|
338
|
+
`no session "${target}" (no session host is running)`,
|
|
339
|
+
"not_found",
|
|
340
|
+
);
|
|
341
|
+
const client = c;
|
|
342
|
+
let info: SessionInfo | undefined;
|
|
343
|
+
try {
|
|
344
|
+
const dist = d.dist();
|
|
345
|
+
const active = new Set(d.activeWorkspaces());
|
|
346
|
+
info = (await client.list()).find((s) => {
|
|
347
|
+
const p = parseSessionName(s.name, dist);
|
|
348
|
+
return (
|
|
349
|
+
p !== null &&
|
|
350
|
+
active.has(p.workspace) &&
|
|
351
|
+
(s.ref.id === target || s.name === target)
|
|
352
|
+
);
|
|
353
|
+
});
|
|
354
|
+
} catch (e) {
|
|
355
|
+
client.close();
|
|
356
|
+
return refuse(
|
|
357
|
+
503,
|
|
358
|
+
`the session host: ${(e as Error).message}`,
|
|
359
|
+
"session_host",
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
if (!info) {
|
|
363
|
+
client.close();
|
|
364
|
+
return refuse(404, `no session "${target}" here`, "not_found");
|
|
365
|
+
}
|
|
366
|
+
if (socket.destroyed) {
|
|
367
|
+
client.close();
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const s = info;
|
|
372
|
+
const actor = auditActorOf(req);
|
|
373
|
+
const principal = principalOf(req);
|
|
374
|
+
const audit = (detail: string) =>
|
|
375
|
+
recordAction(actor, { verb: "sessions", detail }, principal);
|
|
376
|
+
const ws = acceptWebSocket(req, socket, head, { protocol: IO_PROTOCOL });
|
|
377
|
+
const since = Date.now();
|
|
378
|
+
audit(`attach ${s.name} (browser)`);
|
|
379
|
+
const p = parseSessionName(s.name, d.dist())!;
|
|
380
|
+
ws.send(
|
|
381
|
+
JSON.stringify({
|
|
382
|
+
type: "session",
|
|
383
|
+
id: s.ref.id,
|
|
384
|
+
name: s.name,
|
|
385
|
+
workspace: p.workspace,
|
|
386
|
+
label: p.name,
|
|
387
|
+
exited: s.exited === true,
|
|
388
|
+
...(s.exitCode !== undefined ? { exitCode: s.exitCode } : {}),
|
|
389
|
+
}),
|
|
390
|
+
);
|
|
391
|
+
let exited = s.exited === true;
|
|
392
|
+
const att = client.attach(s.ref.id, {
|
|
393
|
+
onData: (data) => ws.send(data),
|
|
394
|
+
onExit: (exitCode) => {
|
|
395
|
+
exited = true;
|
|
396
|
+
ws.send(
|
|
397
|
+
JSON.stringify({
|
|
398
|
+
type: "exit",
|
|
399
|
+
...(exitCode !== undefined ? { exitCode } : {}),
|
|
400
|
+
}),
|
|
401
|
+
);
|
|
402
|
+
},
|
|
403
|
+
});
|
|
404
|
+
att.ready.catch((e: Error) => {
|
|
405
|
+
ws.send(JSON.stringify({ type: "error", error: e.message }));
|
|
406
|
+
ws.close(WS_CLOSE.internal, "attach failed");
|
|
407
|
+
});
|
|
408
|
+
ws.on("message", (data: Buffer, binary: boolean) => {
|
|
409
|
+
if (binary) {
|
|
410
|
+
if (!exited && data.length) client.write(s.ref.id, data);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
let msg: { type?: unknown; cols?: unknown; rows?: unknown; data?: unknown };
|
|
414
|
+
try {
|
|
415
|
+
msg = JSON.parse(data.toString("utf-8")) as typeof msg;
|
|
416
|
+
} catch {
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
if (msg?.type === "resize") {
|
|
420
|
+
const ok = (n: unknown): n is number =>
|
|
421
|
+
typeof n === "number" && Number.isInteger(n) && n >= 1 && n <= 1000;
|
|
422
|
+
if (!exited && ok(msg.cols) && ok(msg.rows))
|
|
423
|
+
client.resize(s.ref.id, msg.cols, msg.rows);
|
|
424
|
+
} else if (msg?.type === "input" && typeof msg.data === "string") {
|
|
425
|
+
if (!exited && msg.data)
|
|
426
|
+
client.write(s.ref.id, Buffer.from(msg.data, "utf-8"));
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
let done = false;
|
|
430
|
+
const detach = () => {
|
|
431
|
+
if (done) return;
|
|
432
|
+
done = true;
|
|
433
|
+
// Detach only: the session keeps running in the host.
|
|
434
|
+
att.detach();
|
|
435
|
+
client.close();
|
|
436
|
+
audit(
|
|
437
|
+
`detach ${s.name} (browser, ${Math.round((Date.now() - since) / 1000)}s)`,
|
|
438
|
+
);
|
|
439
|
+
};
|
|
440
|
+
ws.on("close", detach);
|
|
441
|
+
client.once("close", () => {
|
|
442
|
+
if (ws.open) ws.close(WS_CLOSE.goingAway, "the session host went away");
|
|
443
|
+
detach();
|
|
444
|
+
});
|
|
445
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The web server's exit, bounded whatever happens.
|
|
3
|
+
*
|
|
4
|
+
* 1. **Lost output.** When the reader of the server's stdout or stderr goes
|
|
5
|
+
* away (the terminal, test runner or script that started it was killed),
|
|
6
|
+
* every write fails with EPIPE. Unhandled, that error reached the
|
|
7
|
+
* "continue" uncaughtException guard, which logged it with console.error
|
|
8
|
+
* to the equally dead stderr, which failed again: a tight loop that pinned
|
|
9
|
+
* a core and stopped the server answering requests, for as long as the
|
|
10
|
+
* orphan lived. Now the streams' errors are handled here, nothing is
|
|
11
|
+
* written once the output is gone, and the server shuts down (its launcher
|
|
12
|
+
* is gone; nobody can see it).
|
|
13
|
+
* 2. **A deadline.** The first SIGTERM or SIGINT starts the shutdown; when
|
|
14
|
+
* nothing waits on the person (stdin isn't a TTY) a timer forces the exit
|
|
15
|
+
* after `DEN_SHUTDOWN_DEADLINE_MS` (10 s), whatever the shutdown is stuck
|
|
16
|
+
* on. A second signal exits at once (it was ignored, which left a stuck
|
|
17
|
+
* shutdown deaf to every later SIGTERM).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const DEFAULT_DEADLINE_MS = 10_000;
|
|
21
|
+
|
|
22
|
+
let lost = false;
|
|
23
|
+
let started = false;
|
|
24
|
+
|
|
25
|
+
/** An error that says the stream's reader is gone. */
|
|
26
|
+
export function isLostOutputError(e: unknown): boolean {
|
|
27
|
+
const code = (e as { code?: unknown } | null)?.code;
|
|
28
|
+
return code === "EPIPE" || code === "ERR_STREAM_DESTROYED";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** True once stdout or stderr has lost its reader: write nothing more. */
|
|
32
|
+
export function outputLost(): boolean {
|
|
33
|
+
return lost;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Handle stdout's and stderr's errors (so none is ever uncaught); on the
|
|
38
|
+
* first lost-output error, call `onLost` once.
|
|
39
|
+
*/
|
|
40
|
+
export function guardStdio(onLost: () => void): void {
|
|
41
|
+
for (const s of [process.stdout, process.stderr]) {
|
|
42
|
+
s.on("error", (e: unknown) => {
|
|
43
|
+
if (!isLostOutputError(e) || lost) return;
|
|
44
|
+
lost = true;
|
|
45
|
+
onLost();
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The deadline, from DEN_SHUTDOWN_DEADLINE_MS (ms, > 0), else 10 s. */
|
|
51
|
+
export function shutdownDeadlineMs(
|
|
52
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
53
|
+
): number {
|
|
54
|
+
const n = Number(env.DEN_SHUTDOWN_DEADLINE_MS);
|
|
55
|
+
return Number.isFinite(n) && n > 0 ? n : DEFAULT_DEADLINE_MS;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function say(msg: string): void {
|
|
59
|
+
if (lost) return;
|
|
60
|
+
try {
|
|
61
|
+
process.stderr.write(msg + "\n");
|
|
62
|
+
} catch {
|
|
63
|
+
/* nowhere left to say it */
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Start the shutdown for `signal` (`run`), once. A later call exits at once;
|
|
69
|
+
* a shutdown nobody waits on is forced to exit after the deadline.
|
|
70
|
+
*/
|
|
71
|
+
export function requestShutdown(
|
|
72
|
+
signal: string,
|
|
73
|
+
run: (signal: string) => void,
|
|
74
|
+
opts: { interactive: boolean; deadlineMs?: number },
|
|
75
|
+
): void {
|
|
76
|
+
if (started) {
|
|
77
|
+
say(`[server] ${signal} again: exiting now.`);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
started = true;
|
|
81
|
+
if (!opts.interactive) {
|
|
82
|
+
const ms = opts.deadlineMs ?? shutdownDeadlineMs();
|
|
83
|
+
setTimeout(() => {
|
|
84
|
+
say(
|
|
85
|
+
`[server] ${signal}: the shutdown did not finish within ${ms} ms; exiting.`,
|
|
86
|
+
);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}, ms).unref();
|
|
89
|
+
}
|
|
90
|
+
run(signal);
|
|
91
|
+
}
|
|
@@ -9,7 +9,15 @@
|
|
|
9
9
|
* because intermediate launchers (opctl, make, etc.) may reset the environment
|
|
10
10
|
* before sudo is invoked. The cache is valid for ~5 min by default, which is
|
|
11
11
|
* more than enough for a service to finish its startup sudo calls.
|
|
12
|
+
*
|
|
13
|
+
* SEC-1c (R8, SEC-1 §5): an agent's request never passes the lock. The
|
|
14
|
+
* server runs every request in its own lock scope (inRequestLockScope) and
|
|
15
|
+
* closes it for an agent's actor token (closeLockForThisRequest): inside a
|
|
16
|
+
* closed scope, and in everything the request starts (its spawned
|
|
17
|
+
* commands' callbacks included), the store reads as locked and hands out
|
|
18
|
+
* no sudo wrapper and warms no sudo cache.
|
|
12
19
|
*/
|
|
20
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
13
21
|
|
|
14
22
|
import * as fs from "fs";
|
|
15
23
|
import * as os from "os";
|
|
@@ -19,6 +27,23 @@ import { CLI_SPAWN_TIMEOUT_MS } from "../../shared/constants.js";
|
|
|
19
27
|
|
|
20
28
|
import { product } from "../../shared/product.js";
|
|
21
29
|
let _sudoPassword: string | null = null;
|
|
30
|
+
const lockScope = new AsyncLocalStorage<{ closed: boolean }>();
|
|
31
|
+
|
|
32
|
+
/** Run one request in its own lock scope (open unless closed below). */
|
|
33
|
+
export function inRequestLockScope<T>(fn: () => T): T {
|
|
34
|
+
return lockScope.run({ closed: false }, fn);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** This request is an agent's: the lock stays shut for all it starts. */
|
|
38
|
+
export function closeLockForThisRequest(): void {
|
|
39
|
+
const s = lockScope.getStore();
|
|
40
|
+
if (s) s.closed = true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Whether the current request's lock scope is closed (an agent's). */
|
|
44
|
+
export function lockClosedHere(): boolean {
|
|
45
|
+
return lockScope.getStore()?.closed === true;
|
|
46
|
+
}
|
|
22
47
|
let _sudoDir: string | null = null;
|
|
23
48
|
|
|
24
49
|
export function setSudoPassword(password: string): void {
|
|
@@ -53,7 +78,7 @@ export function clearSudoPassword(): void {
|
|
|
53
78
|
* 'sudo' call in the spawned process tree uses the stored password.
|
|
54
79
|
*/
|
|
55
80
|
export function getSudoEnv(): NodeJS.ProcessEnv {
|
|
56
|
-
if (!_sudoDir) return {};
|
|
81
|
+
if (!_sudoDir || lockClosedHere()) return {};
|
|
57
82
|
const currentPath = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
|
|
58
83
|
return { PATH: `${_sudoDir}:${currentPath}` };
|
|
59
84
|
}
|
|
@@ -63,7 +88,7 @@ export function getSudoEnv(): NodeJS.ProcessEnv {
|
|
|
63
88
|
* belt-and-suspenders fallback alongside the PATH wrapper.
|
|
64
89
|
*/
|
|
65
90
|
export function prewarmSudo(): void {
|
|
66
|
-
if (!_sudoPassword) return;
|
|
91
|
+
if (!_sudoPassword || lockClosedHere()) return;
|
|
67
92
|
try {
|
|
68
93
|
execSync("sudo -S -v", {
|
|
69
94
|
input: _sudoPassword + "\n",
|
|
@@ -77,7 +102,7 @@ export function prewarmSudo(): void {
|
|
|
77
102
|
|
|
78
103
|
/** True when a sudo password has been set. */
|
|
79
104
|
export function hasSudoPassword(): boolean {
|
|
80
|
-
return _sudoDir !== null;
|
|
105
|
+
return _sudoDir !== null && !lockClosedHere();
|
|
81
106
|
}
|
|
82
107
|
|
|
83
108
|
let _passwordlessCache: { value: boolean; at: number } | null = null;
|
|
@@ -92,6 +117,7 @@ const PASSWORDLESS_TTL_MS = 60_000;
|
|
|
92
117
|
* status endpoint doesn't spawn sudo on every check.
|
|
93
118
|
*/
|
|
94
119
|
export function hasPasswordlessSudo(): boolean {
|
|
120
|
+
if (lockClosedHere()) return false;
|
|
95
121
|
const now = Date.now();
|
|
96
122
|
if (_passwordlessCache && now - _passwordlessCache.at < PASSWORDLESS_TTL_MS) {
|
|
97
123
|
return _passwordlessCache.value;
|
|
@@ -32,6 +32,8 @@ export const UI_MODULES: readonly string[] = [
|
|
|
32
32
|
"/topbar.js",
|
|
33
33
|
"/edge-page.js",
|
|
34
34
|
"/error-page.js",
|
|
35
|
+
"/agents-section.js",
|
|
36
|
+
"/agents-page.js",
|
|
35
37
|
// The UI's catalogs other than English (R19; en is in shell.js).
|
|
36
38
|
"/i18n/ja.js",
|
|
37
39
|
"/i18n/es.js",
|
|
@@ -40,6 +42,11 @@ export const UI_MODULES: readonly string[] = [
|
|
|
40
42
|
/** URL path → file inside node_modules. */
|
|
41
43
|
export const VENDOR_ASSETS: Readonly<Record<string, string>> = {
|
|
42
44
|
"/vendor/elk.bundled.js": "elkjs/lib/elk.bundled.js",
|
|
45
|
+
// The Agents tab's terminal panes (AG-4a-term): xterm.js and its fit
|
|
46
|
+
// addon, ES modules, MIT.
|
|
47
|
+
"/vendor/xterm.mjs": "@xterm/xterm/lib/xterm.mjs",
|
|
48
|
+
"/vendor/xterm.css": "@xterm/xterm/css/xterm.css",
|
|
49
|
+
"/vendor/addon-fit.mjs": "@xterm/addon-fit/lib/addon-fit.mjs",
|
|
43
50
|
};
|
|
44
51
|
|
|
45
52
|
/** The installed file for a vendored URL path, or null. */
|
|
@@ -81,7 +88,9 @@ export function serveUiAsset(
|
|
|
81
88
|
// Pinned versions: the bytes only change with Den itself. Not run through
|
|
82
89
|
// the product-token fill the UI's own scripts get.
|
|
83
90
|
res.writeHead(200, {
|
|
84
|
-
"Content-Type": "
|
|
91
|
+
"Content-Type": file.endsWith(".css")
|
|
92
|
+
? "text/css"
|
|
93
|
+
: "application/javascript",
|
|
85
94
|
"Cache-Control": "public, max-age=86400",
|
|
86
95
|
});
|
|
87
96
|
res.end(fs.readFileSync(file));
|