@monster-orange/den 0.4.0 → 0.4.1

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.
Files changed (53) hide show
  1. package/CHANGELOG.md +409 -0
  2. package/docs/distributions.md +204 -14
  3. package/package.json +3 -1
  4. package/src/shared/actor-token.ts +275 -0
  5. package/src/shared/agent-actor.ts +12 -2
  6. package/src/shared/agents/den-claude.ts +99 -1
  7. package/src/shared/agents/runtime.ts +7 -0
  8. package/src/shared/agents/runtimes.ts +9 -2
  9. package/src/shared/environment-manager/scope.ts +22 -0
  10. package/src/shared/environment-manager/service-runner.ts +94 -2
  11. package/src/shared/profile.ts +15 -4
  12. package/src/shared/sessions/client.ts +9 -2
  13. package/src/shared/sessions/env.ts +13 -3
  14. package/src/shared/sessions/host.ts +23 -0
  15. package/src/shared/web-secret.ts +181 -0
  16. package/src/shared/web-session.ts +491 -0
  17. package/src/ui/cli/agent-ops.ts +843 -0
  18. package/src/ui/cli/agents-command.ts +96 -614
  19. package/src/ui/cli/flag-parser.ts +21 -0
  20. package/src/ui/cli/index.ts +94 -10
  21. package/src/ui/cli/sessions-command.ts +32 -0
  22. package/src/ui/cli/target-workspace.ts +105 -0
  23. package/src/ui/cli/web-command.ts +536 -14
  24. package/src/ui/mcp/api.ts +7 -0
  25. package/src/ui/mcp/server.ts +74 -6
  26. package/src/ui/server/agent-routes.ts +323 -0
  27. package/src/ui/server/audit.ts +81 -27
  28. package/src/ui/server/http-errors.ts +21 -3
  29. package/src/ui/server/index.ts +814 -66
  30. package/src/ui/server/public/agents-page.js +648 -0
  31. package/src/ui/server/public/agents-section.js +299 -0
  32. package/src/ui/server/public/agents.html +439 -0
  33. package/src/ui/server/public/appearance.js +3 -0
  34. package/src/ui/server/public/error-page.js +103 -0
  35. package/src/ui/server/public/header.js +45 -11
  36. package/src/ui/server/public/i18n/es.js +90 -1
  37. package/src/ui/server/public/i18n/ja.js +86 -1
  38. package/src/ui/server/public/local-dev.html +53 -0
  39. package/src/ui/server/public/logs.html +3 -0
  40. package/src/ui/server/public/panel.js +1 -1
  41. package/src/ui/server/public/release.html +8 -0
  42. package/src/ui/server/public/settings-page.js +5 -0
  43. package/src/ui/server/public/settings-registry.js +33 -0
  44. package/src/ui/server/public/shell.js +248 -5
  45. package/src/ui/server/public/topbar.js +51 -2
  46. package/src/ui/server/request-guard.ts +670 -0
  47. package/src/ui/server/route-classes.ts +317 -0
  48. package/src/ui/server/session-routes.ts +445 -0
  49. package/src/ui/server/shutdown-guard.ts +91 -0
  50. package/src/ui/server/sudo-store.ts +29 -3
  51. package/src/ui/server/ui-assets.ts +10 -1
  52. package/src/ui/server/websocket.ts +326 -0
  53. package/src/ui/server/workspace-routes.ts +13 -7
@@ -0,0 +1,326 @@
1
+ /**
2
+ * A small WebSocket server side (RFC 6455), for the one route that needs
3
+ * one: a session's terminal io (`/api/sessions/<id>/io`, AG-4a-term). Den
4
+ * has no `ws` dependency and needs very little of one: a handshake on the
5
+ * http server's `upgrade` event, then masked client frames in and unmasked
6
+ * frames out. Nothing here knows about sessions or credentials: the
7
+ * request checks (request-guard.ts) ran before acceptWebSocket is called.
8
+ *
9
+ * What it takes, and what it refuses:
10
+ * - version 13 only; a key that is 16 bytes of base64;
11
+ * - text, binary, continuation (a fragmented message is reassembled),
12
+ * ping (answered with pong), pong, close (answered, then the socket
13
+ * ends);
14
+ * - a client frame that isn't masked: close 1002; a control frame over
15
+ * 125 bytes or fragmented: 1002; a reserved bit or opcode: 1002;
16
+ * - a message over `maxMessageBytes` (default 1 MiB): 1009;
17
+ * - a peer that stops reading (more than `maxBacklogBytes` queued): the
18
+ * socket is destroyed, as the session host drops a slow client.
19
+ * A ping goes out every `pingMs` (default 30 s); a peer that hasn't answered
20
+ * the last one by the next is gone, and the socket is destroyed.
21
+ */
22
+ import { createHash } from "crypto";
23
+ import { EventEmitter } from "events";
24
+ import type * as http from "http";
25
+ import type { Duplex } from "stream";
26
+
27
+ const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
28
+ const KEY_RE = /^[A-Za-z0-9+/]{22}==$/;
29
+
30
+ export const WS_CLOSE = {
31
+ normal: 1000,
32
+ goingAway: 1001,
33
+ protocol: 1002,
34
+ tooBig: 1009,
35
+ internal: 1011,
36
+ } as const;
37
+
38
+ /** The Sec-WebSocket-Accept value for a client's key. */
39
+ export function acceptKey(key: string): string {
40
+ return createHash("sha1")
41
+ .update(key + GUID)
42
+ .digest("base64");
43
+ }
44
+
45
+ /** The subprotocols a client offered (Sec-WebSocket-Protocol), in order. */
46
+ export function offeredProtocols(req: http.IncomingMessage): string[] {
47
+ const raw = req.headers["sec-websocket-protocol"];
48
+ const v = Array.isArray(raw) ? raw.join(",") : (raw ?? "");
49
+ return v
50
+ .split(",")
51
+ .map((s) => s.trim())
52
+ .filter(Boolean);
53
+ }
54
+
55
+ /** Why an upgrade request is not a WebSocket handshake Den takes, or null. */
56
+ export function handshakeRefusal(req: http.IncomingMessage): string | null {
57
+ if ((req.method ?? "GET").toUpperCase() !== "GET")
58
+ return "a WebSocket handshake is a GET";
59
+ const upgrade = String(req.headers.upgrade ?? "").toLowerCase();
60
+ if (upgrade !== "websocket") return "only a WebSocket upgrade is served";
61
+ if (String(req.headers["sec-websocket-version"] ?? "") !== "13")
62
+ return "WebSocket version 13 only";
63
+ const key = String(req.headers["sec-websocket-key"] ?? "");
64
+ if (!KEY_RE.test(key) || Buffer.from(key, "base64").length !== 16)
65
+ return "a WebSocket key is 16 bytes, base64";
66
+ return null;
67
+ }
68
+
69
+ export interface WebSocketOptions {
70
+ /** The subprotocol to answer with (it must be one the client offered). */
71
+ protocol?: string;
72
+ maxMessageBytes?: number;
73
+ maxBacklogBytes?: number;
74
+ /** 0: never ping. */
75
+ pingMs?: number;
76
+ }
77
+
78
+ /**
79
+ * One accepted connection. Events: `message` (data: Buffer, binary:
80
+ * boolean), `close` (code: number, reason: string), once, whoever closed.
81
+ */
82
+ export class WsConnection extends EventEmitter {
83
+ private buf: Buffer = Buffer.alloc(0);
84
+ private parts: Buffer[] = [];
85
+ private partBytes = 0;
86
+ private partBinary = false;
87
+ private closing = false;
88
+ private closed = false;
89
+ private awaitingPong = false;
90
+ private readonly pinger: NodeJS.Timeout | null;
91
+ private readonly maxMessage: number;
92
+ private readonly maxBacklog: number;
93
+
94
+ constructor(
95
+ private readonly socket: Duplex,
96
+ head: Buffer,
97
+ o: WebSocketOptions = {},
98
+ ) {
99
+ super();
100
+ this.maxMessage = o.maxMessageBytes ?? 1024 * 1024;
101
+ this.maxBacklog = o.maxBacklogBytes ?? 8 * 1024 * 1024;
102
+ socket.on("data", (c: Buffer) => this.onData(c));
103
+ socket.on("error", () => undefined);
104
+ socket.on("close", () => this.finish(1006, ""));
105
+ const ping = o.pingMs ?? 30_000;
106
+ this.pinger =
107
+ ping > 0
108
+ ? setInterval(() => {
109
+ if (this.awaitingPong) {
110
+ this.socket.destroy();
111
+ return;
112
+ }
113
+ this.awaitingPong = true;
114
+ this.frame(0x9, Buffer.alloc(0));
115
+ }, ping)
116
+ : null;
117
+ this.pinger?.unref();
118
+ if (head.length) this.onData(head);
119
+ }
120
+
121
+ /** Whether frames can still be sent. */
122
+ get open(): boolean {
123
+ return !this.closing && !this.closed;
124
+ }
125
+
126
+ send(data: Buffer | string): void {
127
+ if (!this.open) return;
128
+ if (typeof data === "string") this.frame(0x1, Buffer.from(data, "utf-8"));
129
+ else this.frame(0x2, data);
130
+ }
131
+
132
+ /** Start the closing handshake; the socket ends once the peer answers. */
133
+ close(code: number = WS_CLOSE.normal, reason = ""): void {
134
+ if (this.closing || this.closed) return;
135
+ this.closing = true;
136
+ const r = Buffer.from(reason.slice(0, 120), "utf-8");
137
+ const body = Buffer.alloc(2 + r.length);
138
+ body.writeUInt16BE(code, 0);
139
+ r.copy(body, 2);
140
+ this.frame(0x8, body);
141
+ // A peer that never answers the close is let go of anyway.
142
+ const t = setTimeout(() => this.socket.destroy(), 2000);
143
+ t.unref();
144
+ this.finish(code, reason);
145
+ }
146
+
147
+ /** Drop the connection without a closing handshake. */
148
+ terminate(): void {
149
+ this.socket.destroy();
150
+ this.finish(1006, "");
151
+ }
152
+
153
+ private finish(code: number, reason: string): void {
154
+ if (this.closed) return;
155
+ this.closed = true;
156
+ if (this.pinger) clearInterval(this.pinger);
157
+ this.emit("close", code, reason);
158
+ }
159
+
160
+ private frame(opcode: number, payload: Buffer): void {
161
+ if (this.socket.destroyed) return;
162
+ const n = payload.length;
163
+ const head =
164
+ n < 126
165
+ ? Buffer.from([0x80 | opcode, n])
166
+ : n < 65536
167
+ ? Buffer.from([0x80 | opcode, 126, n >> 8, n & 0xff])
168
+ : (() => {
169
+ const h = Buffer.alloc(10);
170
+ h[0] = 0x80 | opcode;
171
+ h[1] = 127;
172
+ h.writeBigUInt64BE(BigInt(n), 2);
173
+ return h;
174
+ })();
175
+ this.socket.write(Buffer.concat([head, payload]));
176
+ if (this.socket.writableLength > this.maxBacklog) this.socket.destroy();
177
+ }
178
+
179
+ private fail(code: number, reason: string): void {
180
+ this.close(code, reason);
181
+ this.buf = Buffer.alloc(0);
182
+ }
183
+
184
+ private onData(chunk: Buffer): void {
185
+ this.buf = this.buf.length ? Buffer.concat([this.buf, chunk]) : chunk;
186
+ for (;;) {
187
+ if (this.closed && !this.closing) return;
188
+ if (this.buf.length < 2) return;
189
+ const b0 = this.buf[0];
190
+ const b1 = this.buf[1];
191
+ const fin = (b0 & 0x80) !== 0;
192
+ const opcode = b0 & 0x0f;
193
+ if (b0 & 0x70) return this.fail(WS_CLOSE.protocol, "reserved bits set");
194
+ if (!(b1 & 0x80))
195
+ return this.fail(WS_CLOSE.protocol, "client frames must be masked");
196
+ let len = b1 & 0x7f;
197
+ let off = 2;
198
+ if (len === 126) {
199
+ if (this.buf.length < 4) return;
200
+ len = this.buf.readUInt16BE(2);
201
+ off = 4;
202
+ } else if (len === 127) {
203
+ if (this.buf.length < 10) return;
204
+ const big = this.buf.readBigUInt64BE(2);
205
+ if (big > BigInt(this.maxMessage))
206
+ return this.fail(WS_CLOSE.tooBig, "message too big");
207
+ len = Number(big);
208
+ off = 10;
209
+ }
210
+ const control = opcode >= 0x8;
211
+ if (control && (len > 125 || !fin))
212
+ return this.fail(WS_CLOSE.protocol, "bad control frame");
213
+ if (len > this.maxMessage)
214
+ return this.fail(WS_CLOSE.tooBig, "message too big");
215
+ if (this.buf.length < off + 4 + len) return;
216
+ const mask = this.buf.subarray(off, off + 4);
217
+ const payload = Buffer.from(this.buf.subarray(off + 4, off + 4 + len));
218
+ for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i & 3];
219
+ this.buf = this.buf.subarray(off + 4 + len);
220
+ if (!this.handle(fin, opcode, payload)) return;
221
+ }
222
+ }
223
+
224
+ /** One frame; false once the connection is done with. */
225
+ private handle(fin: boolean, opcode: number, payload: Buffer): boolean {
226
+ switch (opcode) {
227
+ case 0x8: {
228
+ const code = payload.length >= 2 ? payload.readUInt16BE(0) : 1005;
229
+ if (!this.closing) {
230
+ this.closing = true;
231
+ this.frame(0x8, payload.subarray(0, 2));
232
+ }
233
+ this.socket.end();
234
+ this.finish(code, payload.subarray(2).toString("utf-8"));
235
+ return false;
236
+ }
237
+ case 0x9:
238
+ if (this.open) this.frame(0xa, payload);
239
+ return true;
240
+ case 0xa:
241
+ this.awaitingPong = false;
242
+ return true;
243
+ case 0x1:
244
+ case 0x2:
245
+ if (this.parts.length) {
246
+ this.fail(WS_CLOSE.protocol, "a new message inside a fragmented one");
247
+ return false;
248
+ }
249
+ this.partBinary = opcode === 0x2;
250
+ return this.part(fin, payload);
251
+ case 0x0:
252
+ if (!this.parts.length) {
253
+ this.fail(
254
+ WS_CLOSE.protocol,
255
+ "a continuation with nothing to continue",
256
+ );
257
+ return false;
258
+ }
259
+ return this.part(fin, payload);
260
+ default:
261
+ this.fail(WS_CLOSE.protocol, "unknown opcode");
262
+ return false;
263
+ }
264
+ }
265
+
266
+ private part(fin: boolean, payload: Buffer): boolean {
267
+ this.partBytes += payload.length;
268
+ if (this.partBytes > this.maxMessage) {
269
+ this.fail(WS_CLOSE.tooBig, "message too big");
270
+ return false;
271
+ }
272
+ this.parts.push(payload);
273
+ if (!fin) return true;
274
+ const msg =
275
+ this.parts.length === 1 ? this.parts[0] : Buffer.concat(this.parts);
276
+ this.parts = [];
277
+ this.partBytes = 0;
278
+ if (this.open) this.emit("message", msg, this.partBinary);
279
+ return true;
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Answer the handshake (101) and return the connection. The caller checked
285
+ * handshakeRefusal and the request checks first.
286
+ */
287
+ export function acceptWebSocket(
288
+ req: http.IncomingMessage,
289
+ socket: Duplex,
290
+ head: Buffer,
291
+ o: WebSocketOptions = {},
292
+ ): WsConnection {
293
+ const key = String(req.headers["sec-websocket-key"]);
294
+ const protocol =
295
+ o.protocol && offeredProtocols(req).includes(o.protocol)
296
+ ? o.protocol
297
+ : null;
298
+ socket.write(
299
+ "HTTP/1.1 101 Switching Protocols\r\n" +
300
+ "Upgrade: websocket\r\nConnection: Upgrade\r\n" +
301
+ `Sec-WebSocket-Accept: ${acceptKey(key)}\r\n` +
302
+ (protocol ? `Sec-WebSocket-Protocol: ${protocol}\r\n` : "") +
303
+ "\r\n",
304
+ );
305
+ (socket as { setNoDelay?: (v: boolean) => void }).setNoDelay?.(true);
306
+ return new WsConnection(socket, head, o);
307
+ }
308
+
309
+ /** An http answer on an upgrade's raw socket (a refusal), then the end. */
310
+ export function refuseUpgrade(
311
+ socket: Duplex,
312
+ status: number,
313
+ body: Record<string, unknown>,
314
+ statusText: string,
315
+ extraHeaders: Record<string, string> = {},
316
+ ): void {
317
+ const text = JSON.stringify(body);
318
+ socket.end(
319
+ `HTTP/1.1 ${status} ${statusText}\r\n` +
320
+ "Content-Type: application/json\r\nConnection: close\r\n" +
321
+ Object.entries(extraHeaders)
322
+ .map(([k, v]) => `${k}: ${v}\r\n`)
323
+ .join("") +
324
+ `Content-Length: ${Buffer.byteLength(text)}\r\n\r\n${text}`,
325
+ );
326
+ }
@@ -102,7 +102,8 @@ import {
102
102
  devManagedPaths,
103
103
  selectSyncTargets,
104
104
  } from "../../shared/environment-manager/sync-targets.js";
105
- import { actorFromHeaders, recordAction } from "./audit.js";
105
+ import { recordAction } from "./audit.js";
106
+ import { auditActorOf, principalOf } from "./request-guard.js";
106
107
  import {
107
108
  GIT_REMOTE_TIMEOUT_MS,
108
109
  DETACH_WINDOW_MS,
@@ -1578,8 +1579,9 @@ export async function handleWorkspaceRoutes(
1578
1579
  const svcCmds = localCommandsFor(resolved, ws.name);
1579
1580
  const hasCmd =
1580
1581
  cmd === "down" ||
1581
- cmd in svcCmds ||
1582
- (cmd === "restart" && "up" in svcCmds);
1582
+ Object.prototype.hasOwnProperty.call(svcCmds, cmd) ||
1583
+ (cmd === "restart" &&
1584
+ Object.prototype.hasOwnProperty.call(svcCmds, "up"));
1583
1585
  if (!hasCmd) {
1584
1586
  runNext(i + 1);
1585
1587
  return;
@@ -2073,10 +2075,14 @@ export async function handleWorkspaceRoutes(
2073
2075
  type: "line",
2074
2076
  text: ` ${r.repoName}: not synced — ${r.reason}`,
2075
2077
  });
2076
- recordAction(actorFromHeaders(req.headers), {
2077
- verb: "sync",
2078
- detail: `${ws.name}${onlyRepo ? ` · repo=${onlyRepo}` : ""} · ${targets.length} managed repo(s)`,
2079
- });
2078
+ recordAction(
2079
+ auditActorOf(req),
2080
+ {
2081
+ verb: "sync",
2082
+ detail: `${ws.name}${onlyRepo ? ` · repo=${onlyRepo}` : ""} · ${targets.length} managed repo(s)`,
2083
+ },
2084
+ principalOf(req),
2085
+ );
2080
2086
 
2081
2087
  if (targets.length === 0) {
2082
2088
  // Distinguish "you asked for a repo we can't sync" from "nothing to do",