@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.
- package/CHANGELOG.md +409 -0
- package/docs/distributions.md +204 -14
- 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/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 +9 -2
- package/src/shared/sessions/env.ts +13 -3
- package/src/shared/sessions/host.ts +23 -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 +96 -614
- package/src/ui/cli/flag-parser.ts +21 -0
- package/src/ui/cli/index.ts +94 -10
- package/src/ui/cli/sessions-command.ts +32 -0
- package/src/ui/cli/target-workspace.ts +105 -0
- package/src/ui/cli/web-command.ts +536 -14
- package/src/ui/mcp/api.ts +7 -0
- package/src/ui/mcp/server.ts +74 -6
- package/src/ui/server/agent-routes.ts +323 -0
- package/src/ui/server/audit.ts +81 -27
- package/src/ui/server/http-errors.ts +21 -3
- package/src/ui/server/index.ts +814 -66
- package/src/ui/server/public/agents-page.js +648 -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,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 {
|
|
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
|
|
1582
|
-
(cmd === "restart" &&
|
|
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(
|
|
2077
|
-
|
|
2078
|
-
|
|
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",
|