@monster-orange/den 0.4.2 → 0.4.4
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 +273 -0
- package/README.md +9 -9
- package/docs/distributions.md +313 -50
- package/package.json +1 -1
- package/src/shared/agents/catalog-runtime.ts +178 -0
- package/src/shared/agents/catalog.ts +343 -0
- package/src/shared/agents/den-claude.ts +53 -12
- package/src/shared/agents/generic.ts +1 -0
- package/src/shared/agents/runtime-search.ts +543 -0
- package/src/shared/agents/runtime.ts +43 -1
- package/src/shared/agents/runtimes.ts +53 -4
- package/src/shared/auth/sudo-vault.ts +16 -0
- package/src/shared/den-config.ts +92 -2
- package/src/shared/edge/render.ts +31 -0
- package/src/shared/edge/status.ts +134 -0
- package/src/shared/environment-manager/health.ts +82 -8
- package/src/shared/environment-manager/workspaces.ts +54 -0
- package/src/shared/health/not-checked.ts +42 -0
- package/src/shared/health/rollup.ts +4 -0
- package/src/shared/process/spawn-streaming.ts +8 -3
- package/src/shared/sessions/client.ts +5 -1
- package/src/shared/sessions/host.ts +29 -1
- package/src/shared/sessions/protocol.ts +6 -1
- package/src/shared/sessions/types.ts +6 -0
- package/src/shared/stacks/status.ts +24 -0
- package/src/shared/strategies/registry.ts +5 -0
- package/src/shared/types/index.ts +6 -0
- package/src/ui/cli/agent-ops.ts +298 -19
- package/src/ui/cli/agents-command.ts +129 -4
- package/src/ui/cli/edge-command.ts +16 -3
- package/src/ui/cli/health-display.ts +4 -1
- package/src/ui/cli/sessions-command.ts +9 -4
- package/src/ui/cli/web-command.ts +53 -8
- package/src/ui/mcp/scope.ts +23 -3
- package/src/ui/mcp/server.ts +31 -7
- package/src/ui/mcp/tools.ts +327 -20
- package/src/ui/mcp/views.ts +450 -0
- package/src/ui/server/agent-routes.ts +123 -26
- package/src/ui/server/edge-exposure.ts +143 -0
- package/src/ui/server/index.ts +171 -25
- package/src/ui/server/public/agent-start.js +241 -0
- package/src/ui/server/public/agents-page.js +246 -45
- package/src/ui/server/public/agents-section.js +7 -21
- package/src/ui/server/public/agents.html +55 -1
- package/src/ui/server/public/graph-model.js +2 -0
- package/src/ui/server/public/header.js +2 -3
- package/src/ui/server/public/health-rollup.js +20 -8
- package/src/ui/server/public/i18n/es.js +26 -13
- package/src/ui/server/public/i18n/ja.js +25 -13
- package/src/ui/server/public/index.html +17 -4
- package/src/ui/server/public/local-dev.html +62 -22
- package/src/ui/server/public/services.html +21 -5
- package/src/ui/server/public/settings-page.js +3 -2
- package/src/ui/server/public/settings-registry.js +226 -1
- package/src/ui/server/public/settings.html +90 -1
- package/src/ui/server/public/shell.js +67 -22
- package/src/ui/server/public/topbar.js +217 -158
- package/src/ui/server/readonly-routes.ts +29 -5
- package/src/ui/server/request-guard.ts +123 -8
- package/src/ui/server/route-classes.ts +14 -2
- package/src/ui/server/session-routes.ts +9 -1
- package/src/ui/server/sudo-store.ts +170 -6
- package/src/ui/server/ui-assets.ts +2 -0
- package/src/ui/server/workspace-routes.ts +10 -3
|
@@ -19,12 +19,17 @@
|
|
|
19
19
|
* session's /api/sessions/<id>/*: agent start and resume mint tokens.
|
|
20
20
|
* 403 `agent_forbidden`. WS-3a (0.4.2) adds the workspace check at
|
|
21
21
|
* agentRefusal's seam.
|
|
22
|
-
* - Sign-in (§4, route-classes.ts): beyond loopback (
|
|
23
|
-
*
|
|
24
|
-
* or a bearer. Otherwise 401
|
|
25
|
-
* does, shell-grade ones
|
|
26
|
-
*
|
|
27
|
-
*
|
|
22
|
+
* - Sign-in (§4, route-classes.ts): beyond loopback (a LAN name, a peer
|
|
23
|
+
* that isn't loopback, or the edge hostname for a client on another
|
|
24
|
+
* machine) every route needs a session or a bearer. Otherwise 401
|
|
25
|
+
* `signin_required`. On loopback no route does, shell-grade ones
|
|
26
|
+
* included (Ward, 2026-11-06: the lock, R8, is the only prompt there;
|
|
27
|
+
* Host, Origin and CSRF cover CSRF and DNS rebinding). SIGNIN_FREE
|
|
28
|
+
* routes never need one.
|
|
29
|
+
* - The edge counts as local (0.4.3, edgeClientIsLocal): the edge hostname
|
|
30
|
+
* from an edge peer gets loopback's treatment unless the edge's
|
|
31
|
+
* X-Forwarded-For names another machine, or this server is deliberately
|
|
32
|
+
* exposed (`exposed`: a non-loopback DEN_BIND_HOST bind).
|
|
28
33
|
* - Origin, on POST/PUT/PATCH/DELETE and every WebSocket upgrade: an
|
|
29
34
|
* allowed host (http or https). No Origin needs a valid bearer.
|
|
30
35
|
* Otherwise 403 `origin_rejected`.
|
|
@@ -45,6 +50,7 @@
|
|
|
45
50
|
import { randomBytes, timingSafeEqual, createHash } from "crypto";
|
|
46
51
|
import type * as http from "http";
|
|
47
52
|
import * as net from "net";
|
|
53
|
+
import * as os from "os";
|
|
48
54
|
import {
|
|
49
55
|
actorFromHeaders,
|
|
50
56
|
type Actor,
|
|
@@ -125,6 +131,18 @@ export interface GuardContext {
|
|
|
125
131
|
* server's port gets 421. Unset: taken as yes (a unit test).
|
|
126
132
|
*/
|
|
127
133
|
peerIsEdge?: (req: http.IncomingMessage) => boolean;
|
|
134
|
+
/**
|
|
135
|
+
* This machine's own addresses and container/VM bridges (localNet), for
|
|
136
|
+
* telling whether an edge request's client is this machine. Unset: none
|
|
137
|
+
* (a unit test): only loopback and the Docker ranges count as local.
|
|
138
|
+
*/
|
|
139
|
+
localNet?: () => LocalNet;
|
|
140
|
+
/**
|
|
141
|
+
* A deliberate network exposure (a non-loopback DEN_BIND_HOST bind other
|
|
142
|
+
* than the Docker bridge the edge uses on Linux): the edge hostname
|
|
143
|
+
* needs a sign-in too, as before 0.4.3.
|
|
144
|
+
*/
|
|
145
|
+
exposed?: boolean;
|
|
128
146
|
}
|
|
129
147
|
|
|
130
148
|
export type GuardResult =
|
|
@@ -263,6 +281,97 @@ export function isEdgePeer(addr: string | undefined): boolean {
|
|
|
263
281
|
);
|
|
264
282
|
}
|
|
265
283
|
|
|
284
|
+
/** An address without its zone, IPv4-mapped prefix or brackets, lowercase. */
|
|
285
|
+
function normAddress(a: string): string {
|
|
286
|
+
return a
|
|
287
|
+
.trim()
|
|
288
|
+
.toLowerCase()
|
|
289
|
+
.replace(/^\[|\]$/g, "")
|
|
290
|
+
.replace(/%.*$/, "")
|
|
291
|
+
.replace(/^::ffff:(?=\d+\.)/, "");
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** This machine's own addresses, and the container/VM bridges it hosts. */
|
|
295
|
+
export interface LocalNet {
|
|
296
|
+
/** Every interface address (loopback, LAN, a VPN's such as Tailscale's 100.x). */
|
|
297
|
+
own: readonly string[];
|
|
298
|
+
/** IPv4 subnets of its container/VM bridges (Docker, OrbStack, VMs). */
|
|
299
|
+
bridges: readonly { address: string; netmask: string }[];
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Interface names of container/VM bridges: their peers are this machine's. */
|
|
303
|
+
const BRIDGE_IFACE =
|
|
304
|
+
/^(docker\d*|br-|bridge\d+|vmnet|virbr|veth|cni|podman|vboxnet)/;
|
|
305
|
+
|
|
306
|
+
/** localNet from os.networkInterfaces() (or a given table, in a test). */
|
|
307
|
+
export function localNet(
|
|
308
|
+
ifaces: ReturnType<typeof os.networkInterfaces> = os.networkInterfaces(),
|
|
309
|
+
): LocalNet {
|
|
310
|
+
const own: string[] = [];
|
|
311
|
+
const bridges: { address: string; netmask: string }[] = [];
|
|
312
|
+
for (const [name, list] of Object.entries(ifaces))
|
|
313
|
+
for (const a of list ?? []) {
|
|
314
|
+
own.push(normAddress(a.address));
|
|
315
|
+
if (a.family === "IPv4" && BRIDGE_IFACE.test(name))
|
|
316
|
+
bridges.push({ address: a.address, netmask: a.netmask });
|
|
317
|
+
}
|
|
318
|
+
return { own: [...new Set(own)], bridges };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function ipv4(a: string): number {
|
|
322
|
+
return a.split(".").reduce((n, o) => n * 256 + Number(o), 0);
|
|
323
|
+
}
|
|
324
|
+
function inSubnet(a: string, net4: { address: string; netmask: string }) {
|
|
325
|
+
if (!net.isIPv4(a) || !net.isIPv4(net4.address)) return false;
|
|
326
|
+
const m = ipv4(net4.netmask);
|
|
327
|
+
// Unsigned compare of the masked halves (no 32-bit sign games).
|
|
328
|
+
const lo = (x: number) => x - (x % (2 ** 32 - m));
|
|
329
|
+
return m > 0 && lo(ipv4(a)) === lo(ipv4(net4.address));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* The original client X-Forwarded-For names: its left-most entry, as an
|
|
334
|
+
* IP (a port or brackets dropped), or undefined when there is none.
|
|
335
|
+
*/
|
|
336
|
+
export function forwardedClient(xff: string | undefined): string | undefined {
|
|
337
|
+
const first = xff?.split(",")[0]?.trim();
|
|
338
|
+
if (!first) return undefined;
|
|
339
|
+
let a = first;
|
|
340
|
+
const b = /^\[([^\]]+)\](?::\d+)?$/.exec(a);
|
|
341
|
+
if (b) a = b[1];
|
|
342
|
+
else if (/^\d+\.\d+\.\d+\.\d+:\d+$/.test(a)) a = a.replace(/:\d+$/, "");
|
|
343
|
+
a = normAddress(a);
|
|
344
|
+
return net.isIP(a) ? a : undefined;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* The edge counts as local (0.4.3): whether an edge request (the edge
|
|
349
|
+
* hostname from an edge peer) comes from this machine, by the left-most
|
|
350
|
+
* X-Forwarded-For entry (the original client):
|
|
351
|
+
* - loopback, or one of this machine's own interface addresses (its LAN
|
|
352
|
+
* or VPN address, a Tailscale 100.x one included): local;
|
|
353
|
+
* - missing, unreadable, or a container/VM network's address (the Docker
|
|
354
|
+
* ranges isEdgePeer takes, a subnet of one of this machine's bridges,
|
|
355
|
+
* one of the edge's own Docker networks, or OrbStack's 0.250.250.254):
|
|
356
|
+
* we can't tell (Docker Desktop and OrbStack on macOS hand the
|
|
357
|
+
* edge their gateway's address, and a TCP passthrough in front hides the
|
|
358
|
+
* client), so local;
|
|
359
|
+
* - anything else: another machine, beyond loopback (sign-in required).
|
|
360
|
+
* The edge's own page warns when it is reachable beyond this machine
|
|
361
|
+
* (edge-exposure.ts): with no sign-in, anyone who reaches it is local.
|
|
362
|
+
*/
|
|
363
|
+
export function edgeClientIsLocal(
|
|
364
|
+
xff: string | undefined,
|
|
365
|
+
local: LocalNet = { own: [], bridges: [] },
|
|
366
|
+
): boolean {
|
|
367
|
+
const c = forwardedClient(xff);
|
|
368
|
+
if (!c || isEdgePeer(c) || local.own.includes(c)) return true;
|
|
369
|
+
// 0.0.0.0/8 ("this network") is never a client on another machine:
|
|
370
|
+
// OrbStack presents the host to its containers as 0.250.250.254.
|
|
371
|
+
if (net.isIPv4(c) && c.startsWith("0.")) return true;
|
|
372
|
+
return local.bridges.some((b) => inSubnet(c, b));
|
|
373
|
+
}
|
|
374
|
+
|
|
266
375
|
/**
|
|
267
376
|
* Whether the request came over HTTPS: the socket's own encryption, or,
|
|
268
377
|
* only for the edge hostname from an edge peer, `X-Forwarded-Proto: https`.
|
|
@@ -370,7 +479,13 @@ export function checkRequest(
|
|
|
370
479
|
"127.0.0.1, [::1] with its port, or its edge hostname).",
|
|
371
480
|
};
|
|
372
481
|
const peerLoopback = ctx.peerIsLoopback ? ctx.peerIsLoopback(req) : true;
|
|
373
|
-
|
|
482
|
+
// The edge from this machine (edgeClientIsLocal) is local, unless this
|
|
483
|
+
// server is deliberately exposed; a LAN name or peer is beyond.
|
|
484
|
+
const beyond =
|
|
485
|
+
via === "edge"
|
|
486
|
+
? !!ctx.exposed ||
|
|
487
|
+
!edgeClientIsLocal(header(req, "x-forwarded-for"), ctx.localNet?.())
|
|
488
|
+
: via !== "loopback" || !peerLoopback;
|
|
374
489
|
|
|
375
490
|
let principal: Principal | null = null;
|
|
376
491
|
const auth = header(req, "authorization");
|
|
@@ -497,7 +612,7 @@ export const AGENT_FORBIDDEN: readonly string[] = [
|
|
|
497
612
|
];
|
|
498
613
|
|
|
499
614
|
/**
|
|
500
|
-
* Routes an agent's token may read but never change: the
|
|
615
|
+
* Routes an agent's token may read but never change: the admin password (R8).
|
|
501
616
|
* Privileged actions stay with people (AG-5); its status is a read.
|
|
502
617
|
*/
|
|
503
618
|
export const AGENT_READ_ONLY: readonly string[] = [
|
|
@@ -145,6 +145,7 @@ export const ROUTES: readonly RouteEntry[] = [
|
|
|
145
145
|
"/api/graph",
|
|
146
146
|
"/api/applications",
|
|
147
147
|
"/api/personas",
|
|
148
|
+
"/api/agents/runtimes",
|
|
148
149
|
"/api/workspaces/:ws/agents",
|
|
149
150
|
].map((p) => R(p)),
|
|
150
151
|
R(
|
|
@@ -209,8 +210,13 @@ export const ROUTES: readonly RouteEntry[] = [
|
|
|
209
210
|
M(["POST", "DELETE"], "/api/release/deploy", "starts or dismisses a deploy"),
|
|
210
211
|
M(["POST"], "/api/manifest/validate", "validates posted content (no write)"),
|
|
211
212
|
M(["POST"], "/api/local-dev"),
|
|
212
|
-
M(["POST"], "/api/sudo-password", "
|
|
213
|
-
M(
|
|
213
|
+
M(["POST"], "/api/sudo-password", "sets the admin password, for sudo (R8)"),
|
|
214
|
+
M(
|
|
215
|
+
["POST"],
|
|
216
|
+
"/api/sudo-password/load-from-vault",
|
|
217
|
+
"sets the admin password from the vault, for sudo (R8)",
|
|
218
|
+
),
|
|
219
|
+
M(["POST"], "/api/sudo-password/forget", "forgets the admin password (R8)"),
|
|
214
220
|
M(["POST"], "/api/scope"),
|
|
215
221
|
M(["POST"], "/api/settings"),
|
|
216
222
|
M(
|
|
@@ -241,6 +247,11 @@ export const ROUTES: readonly RouteEntry[] = [
|
|
|
241
247
|
"/api/workspaces/:ws/agents/:id/resume",
|
|
242
248
|
"resumes an agent: a runtime in a PTY",
|
|
243
249
|
),
|
|
250
|
+
S(
|
|
251
|
+
["POST"],
|
|
252
|
+
"/api/agents/runtimes/:id/path",
|
|
253
|
+
"runs the path given (its version command) before saving it as a runtime's CLI (AG-0.5)",
|
|
254
|
+
),
|
|
244
255
|
S(
|
|
245
256
|
["GET", "HEAD"],
|
|
246
257
|
"/api/sessions/:id/io",
|
|
@@ -272,6 +283,7 @@ export const REGEX_ROUTES: Readonly<Record<string, readonly string[]>> = {
|
|
|
272
283
|
"/api/workspaces/:ws/agents/:id/stop",
|
|
273
284
|
"/api/workspaces/:ws/agents/:id/resume",
|
|
274
285
|
],
|
|
286
|
+
"agent-routes.ts:RUNTIME_PATH": ["/api/agents/runtimes/:id/path"],
|
|
275
287
|
"session-routes.ts:IO_PATH": ["/api/sessions/:id/io"],
|
|
276
288
|
};
|
|
277
289
|
|
|
@@ -18,6 +18,10 @@
|
|
|
18
18
|
* - on attach, the scrollback replay, then live output: binary frames;
|
|
19
19
|
* - a text frame `{"type":"session",…}` first (what is attached), and
|
|
20
20
|
* `{"type":"exit","exitCode":n}` when the session ends;
|
|
21
|
+
* - `{"type":"size","cols":c,"rows":r}`: the PTY's size, on attach and
|
|
22
|
+
* each time any viewer (another browser, a CLI attach) resizes it. The
|
|
23
|
+
* size is last-writer-wins; a pane that learns it is out of step says
|
|
24
|
+
* its own again when it is used (focused, typed in, shown);
|
|
21
25
|
* - in: binary frames are input; a text frame is JSON control:
|
|
22
26
|
* `{"type":"resize","cols":c,"rows":r}` (or `{"type":"input","data":s}`).
|
|
23
27
|
* Closing the socket detaches. Nothing here ever signals a session: leaving
|
|
@@ -391,6 +395,9 @@ export async function handleSessionUpgrade(
|
|
|
391
395
|
let exited = s.exited === true;
|
|
392
396
|
const att = client.attach(s.ref.id, {
|
|
393
397
|
onData: (data) => ws.send(data),
|
|
398
|
+
onSize: (cols, rows) => {
|
|
399
|
+
if (ws.open) ws.send(JSON.stringify({ type: "size", cols, rows }));
|
|
400
|
+
},
|
|
394
401
|
onExit: (exitCode) => {
|
|
395
402
|
exited = true;
|
|
396
403
|
ws.send(
|
|
@@ -417,8 +424,9 @@ export async function handleSessionUpgrade(
|
|
|
417
424
|
return;
|
|
418
425
|
}
|
|
419
426
|
if (msg?.type === "resize") {
|
|
427
|
+
// Never below 2x2: a pane measured before it was laid out.
|
|
420
428
|
const ok = (n: unknown): n is number =>
|
|
421
|
-
typeof n === "number" && Number.isInteger(n) && n >=
|
|
429
|
+
typeof n === "number" && Number.isInteger(n) && n >= 2 && n <= 1000;
|
|
422
430
|
if (!exited && ok(msg.cols) && ok(msg.rows))
|
|
423
431
|
client.resize(s.ref.id, msg.cols, msg.rows);
|
|
424
432
|
} else if (msg?.type === "input" && typeof msg.data === "string") {
|
|
@@ -4,6 +4,16 @@
|
|
|
4
4
|
* Keeps the user's sudo password in memory so spawned service commands
|
|
5
5
|
* (make up / down / etc.) can authenticate without a terminal.
|
|
6
6
|
*
|
|
7
|
+
* LK-0 (Ward, 2026-11-06): the password is for sudo, and nothing else. It
|
|
8
|
+
* is never an access check. Den asks for it only at the moment a command
|
|
9
|
+
* it runs is about to run sudo: every service command gets a `sudo` shim
|
|
10
|
+
* first on its PATH (getSudoEnv). With a password held, the shim feeds it
|
|
11
|
+
* to the real sudo. Without one, sudo that needs no password (NOPASSWD, a
|
|
12
|
+
* warm cache) still runs; anything else never prompts, it prints one
|
|
13
|
+
* needs-sudo line naming the command and fails. The route turns that line
|
|
14
|
+
* (or sudo's own "a password is required") into a `needs-sudo` event
|
|
15
|
+
* (watchSudo), and the page asks for the password, then runs it again.
|
|
16
|
+
*
|
|
7
17
|
* Strategy: pre-warm the sudo credential cache immediately before every
|
|
8
18
|
* service spawn. This is more reliable than SUDO_ASKPASS env-var injection
|
|
9
19
|
* because intermediate launchers (opctl, make, etc.) may reset the environment
|
|
@@ -45,22 +55,95 @@ export function lockClosedHere(): boolean {
|
|
|
45
55
|
return lockScope.getStore()?.closed === true;
|
|
46
56
|
}
|
|
47
57
|
let _sudoDir: string | null = null;
|
|
58
|
+
let _askDir: string | null = null;
|
|
59
|
+
|
|
60
|
+
/** Den's own sudo shim dirs (never the real sudo). */
|
|
61
|
+
function shimDirPrefix(): string {
|
|
62
|
+
return path.join(os.tmpdir(), `${product().cli}-sudo-`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The real sudo: the first `sudo` on PATH that isn't one of Den's shims,
|
|
67
|
+
* else /usr/bin/sudo. Read when a shim is written.
|
|
68
|
+
*/
|
|
69
|
+
export function realSudoPath(envPath: string = process.env.PATH ?? ""): string {
|
|
70
|
+
const prefix = shimDirPrefix();
|
|
71
|
+
for (const dir of envPath.split(path.delimiter)) {
|
|
72
|
+
if (!dir || dir.startsWith(prefix)) continue;
|
|
73
|
+
const file = path.join(dir, "sudo");
|
|
74
|
+
try {
|
|
75
|
+
fs.accessSync(file, fs.constants.X_OK);
|
|
76
|
+
if (fs.statSync(file).isFile()) return file;
|
|
77
|
+
} catch {
|
|
78
|
+
/* not here */
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return "/usr/bin/sudo";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A path inside single quotes, for a shim. */
|
|
85
|
+
function shq(s: string): string {
|
|
86
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
87
|
+
}
|
|
48
88
|
|
|
49
89
|
export function setSudoPassword(password: string): void {
|
|
50
90
|
_sudoPassword = password;
|
|
51
|
-
const dir =
|
|
91
|
+
const dir = `${shimDirPrefix()}${process.pid}`;
|
|
52
92
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
53
93
|
const pwFile = path.join(dir, "pw");
|
|
54
94
|
fs.writeFileSync(pwFile, password + "\n", { mode: 0o600 });
|
|
55
95
|
const wrapper = path.join(dir, "sudo");
|
|
56
96
|
fs.writeFileSync(
|
|
57
97
|
wrapper,
|
|
58
|
-
`#!/bin/sh\nexec
|
|
98
|
+
`#!/bin/sh\nexec ${shq(realSudoPath())} -S "$@" < ${shq(pwFile)}\n`,
|
|
59
99
|
{ mode: 0o700 },
|
|
60
100
|
);
|
|
61
101
|
_sudoDir = dir;
|
|
62
102
|
}
|
|
63
103
|
|
|
104
|
+
/** What the no-password shim prints before it fails (LK-0). */
|
|
105
|
+
export const NEEDS_SUDO_MARK = "needs-sudo:";
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The shim for when no password is held (LK-0): sudo that needs none runs
|
|
109
|
+
* as it would; anything else prints `<cli>: needs-sudo: sudo <args>` and
|
|
110
|
+
* fails, never prompting. It holds no secret.
|
|
111
|
+
*/
|
|
112
|
+
function askShim(): string {
|
|
113
|
+
const real = shq(realSudoPath());
|
|
114
|
+
const mark = `${product().cli}: ${NEEDS_SUDO_MARK}`;
|
|
115
|
+
return [
|
|
116
|
+
"#!/bin/sh",
|
|
117
|
+
`# ${product().title}: no admin password is held (LK-0).`,
|
|
118
|
+
`REAL=${real}`,
|
|
119
|
+
// A caller feeding its own password, or a passwordless sudo: as is.
|
|
120
|
+
'case " $* " in *" -S "*) exec "$REAL" "$@";; esac',
|
|
121
|
+
'if "$REAL" -n true >/dev/null 2>&1; then exec "$REAL" -n "$@"; fi',
|
|
122
|
+
'err=$(mktemp "${TMPDIR:-/tmp}/sudo-err.XXXXXX") || exit 1',
|
|
123
|
+
'"$REAL" -n "$@" 2>"$err"; rc=$?',
|
|
124
|
+
'if [ "$rc" -ne 0 ] && grep -qi -e "password is required" -e "terminal is required" "$err"; then',
|
|
125
|
+
' rm -f "$err"',
|
|
126
|
+
` printf '%s sudo %s\\n' ${shq(mark)} "$*" >&2`,
|
|
127
|
+
" exit 1",
|
|
128
|
+
"fi",
|
|
129
|
+
'cat "$err" >&2; rm -f "$err"; exit "$rc"',
|
|
130
|
+
"",
|
|
131
|
+
].join("\n");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function ensureAskDir(): string | null {
|
|
135
|
+
if (_askDir) return _askDir;
|
|
136
|
+
try {
|
|
137
|
+
const dir = `${shimDirPrefix()}${process.pid}-ask`;
|
|
138
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
139
|
+
fs.writeFileSync(path.join(dir, "sudo"), askShim(), { mode: 0o700 });
|
|
140
|
+
_askDir = dir;
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
return _askDir;
|
|
145
|
+
}
|
|
146
|
+
|
|
64
147
|
export function clearSudoPassword(): void {
|
|
65
148
|
_sudoPassword = null;
|
|
66
149
|
if (_sudoDir) {
|
|
@@ -73,14 +156,95 @@ export function clearSudoPassword(): void {
|
|
|
73
156
|
}
|
|
74
157
|
}
|
|
75
158
|
|
|
159
|
+
/** On exit: the password and the shims go. */
|
|
160
|
+
export function clearSudoShims(): void {
|
|
161
|
+
clearSudoPassword();
|
|
162
|
+
if (_askDir) {
|
|
163
|
+
try {
|
|
164
|
+
fs.rmSync(_askDir, { recursive: true });
|
|
165
|
+
} catch {
|
|
166
|
+
/* ignore */
|
|
167
|
+
}
|
|
168
|
+
_askDir = null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
76
172
|
/**
|
|
77
|
-
* Returns env additions that prepend our sudo
|
|
78
|
-
* 'sudo' call in the spawned process tree uses the stored password
|
|
173
|
+
* Returns env additions that prepend our sudo shim to PATH so that any
|
|
174
|
+
* 'sudo' call in the spawned process tree uses the stored password, or,
|
|
175
|
+
* with none, says it needs one instead of prompting (LK-0).
|
|
79
176
|
*/
|
|
80
177
|
export function getSudoEnv(): NodeJS.ProcessEnv {
|
|
81
|
-
if (
|
|
178
|
+
if (lockClosedHere()) return {};
|
|
179
|
+
const dir = _sudoDir ?? ensureAskDir();
|
|
180
|
+
if (!dir) return {};
|
|
82
181
|
const currentPath = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
|
|
83
|
-
return { PATH: `${
|
|
182
|
+
return { PATH: `${dir}:${currentPath}` };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** A run's needs-sudo event (LK-0): what needed sudo, and in which step. */
|
|
186
|
+
export interface NeedsSudo {
|
|
187
|
+
type: "needs-sudo";
|
|
188
|
+
/** The sudo command, when the shim saw it (`sudo <args>`). */
|
|
189
|
+
command?: string;
|
|
190
|
+
/** The service address (or step) that ran it. */
|
|
191
|
+
step?: string;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const MAX_COMMAND = 200;
|
|
195
|
+
const NATIVE_ASK =
|
|
196
|
+
/^\s*sudo: (?:a password is required|a terminal is required to read the password)/;
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Whether one output line says sudo needed the password: the shim's own
|
|
200
|
+
* line (with the command), or sudo's own refusal when a launcher reset the
|
|
201
|
+
* PATH (no command). Null otherwise.
|
|
202
|
+
*/
|
|
203
|
+
export function needsSudoOf(line: string): { command?: string } | null {
|
|
204
|
+
const i = line.indexOf(NEEDS_SUDO_MARK);
|
|
205
|
+
if (i >= 0) {
|
|
206
|
+
const command = line
|
|
207
|
+
.slice(i + NEEDS_SUDO_MARK.length)
|
|
208
|
+
.trim()
|
|
209
|
+
.slice(0, MAX_COMMAND);
|
|
210
|
+
return command ? { command } : {};
|
|
211
|
+
}
|
|
212
|
+
return NATIVE_ASK.test(line) ? {} : null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Wrap a run's SSE `emit`: a `line` that says sudo needed the password is
|
|
217
|
+
* followed by one `needs-sudo` event (once per step and command). The step
|
|
218
|
+
* is the last `label` event's text (a workspace run's service), else
|
|
219
|
+
* `step`.
|
|
220
|
+
*/
|
|
221
|
+
export function watchSudo(
|
|
222
|
+
emit: (obj: unknown) => void,
|
|
223
|
+
step?: string,
|
|
224
|
+
): (obj: unknown) => void {
|
|
225
|
+
let current = step;
|
|
226
|
+
const said = new Set<string>();
|
|
227
|
+
return (obj) => {
|
|
228
|
+
emit(obj);
|
|
229
|
+
const o = obj as { type?: unknown; text?: unknown } | null;
|
|
230
|
+
if (!o || typeof o.text !== "string") return;
|
|
231
|
+
if (o.type === "label") {
|
|
232
|
+
current = o.text;
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (o.type !== "line" && o.type !== "error") return;
|
|
236
|
+
const hit = needsSudoOf(o.text);
|
|
237
|
+
if (!hit) return;
|
|
238
|
+
const key = `${current ?? ""}\u0000${hit.command ?? ""}`;
|
|
239
|
+
if (said.has(key)) return;
|
|
240
|
+
said.add(key);
|
|
241
|
+
const ev: NeedsSudo = {
|
|
242
|
+
type: "needs-sudo",
|
|
243
|
+
...(hit.command ? { command: hit.command } : {}),
|
|
244
|
+
...(current ? { step: current } : {}),
|
|
245
|
+
};
|
|
246
|
+
emit(ev);
|
|
247
|
+
};
|
|
84
248
|
}
|
|
85
249
|
|
|
86
250
|
/**
|
|
@@ -34,6 +34,8 @@ export const UI_MODULES: readonly string[] = [
|
|
|
34
34
|
"/error-page.js",
|
|
35
35
|
"/agents-section.js",
|
|
36
36
|
"/agents-page.js",
|
|
37
|
+
// The Start agent form's choices, shared by both (AG-10.0).
|
|
38
|
+
"/agent-start.js",
|
|
37
39
|
// The UI's catalogs other than English (R19; en is in shell.js).
|
|
38
40
|
"/i18n/ja.js",
|
|
39
41
|
"/i18n/es.js",
|
|
@@ -97,7 +97,7 @@ import { getReposRoot, readUserConfig } from "../../shared/den-config.js";
|
|
|
97
97
|
import { redactRemote } from "../../shared/environment-manager/git-remote.js";
|
|
98
98
|
import { getDistRoot } from "../../shared/paths.js";
|
|
99
99
|
import { recordRemovedFromWorkspace } from "../../shared/environment-manager/default-workspace.js";
|
|
100
|
-
import { getSudoEnv, prewarmSudo } from "./sudo-store.js";
|
|
100
|
+
import { getSudoEnv, prewarmSudo, watchSudo } from "./sudo-store.js";
|
|
101
101
|
import {
|
|
102
102
|
devManagedPaths,
|
|
103
103
|
selectSyncTargets,
|
|
@@ -1155,7 +1155,9 @@ export async function handleWorkspaceRoutes(
|
|
|
1155
1155
|
}
|
|
1156
1156
|
});
|
|
1157
1157
|
|
|
1158
|
-
|
|
1158
|
+
// LK-0: a service's sudo without the password is a needs-sudo event,
|
|
1159
|
+
// naming the command and the service (the last label).
|
|
1160
|
+
const emit = watchSudo((obj: unknown): void => {
|
|
1159
1161
|
if (cancelled) return;
|
|
1160
1162
|
try {
|
|
1161
1163
|
res.write(`data: ${JSON.stringify(obj)}\n\n`);
|
|
@@ -1163,7 +1165,7 @@ export async function handleWorkspaceRoutes(
|
|
|
1163
1165
|
// Socket closed mid-write — mark cancelled so subsequent loops bail.
|
|
1164
1166
|
cancelled = true;
|
|
1165
1167
|
}
|
|
1166
|
-
};
|
|
1168
|
+
});
|
|
1167
1169
|
|
|
1168
1170
|
const wsParam = url.searchParams.get("workspace");
|
|
1169
1171
|
if (wsParam && !isWorkspaceActive(wsParam)) {
|
|
@@ -1959,6 +1961,8 @@ export async function handleWorkspaceRoutes(
|
|
|
1959
1961
|
emit: (o: unknown) => void,
|
|
1960
1962
|
isCancelled: () => boolean,
|
|
1961
1963
|
): Promise<boolean> {
|
|
1964
|
+
// LK-0: this step's sudo without the password is a needs-sudo event.
|
|
1965
|
+
emit = watchSudo(emit, `${repoName} ${command}`);
|
|
1962
1966
|
emit({ type: "line", text: ` ${repoName}: ${command}…` });
|
|
1963
1967
|
return new Promise<boolean>((resolve) => {
|
|
1964
1968
|
let settled = false;
|
|
@@ -1972,6 +1976,9 @@ export async function handleWorkspaceRoutes(
|
|
|
1972
1976
|
const result = runServiceCommand({
|
|
1973
1977
|
address,
|
|
1974
1978
|
command,
|
|
1979
|
+
// The sudo shim (LK-0): the password when one is held, else a
|
|
1980
|
+
// needs-sudo line instead of a prompt nobody can answer.
|
|
1981
|
+
env: getSudoEnv(),
|
|
1975
1982
|
onLine: (line) => {
|
|
1976
1983
|
if (!isCancelled()) emit({ type: "line", text: ` ${line}` });
|
|
1977
1984
|
},
|