@verax-ai/body 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/desktop.js CHANGED
@@ -1,12 +1,34 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
- import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, unlinkSync } from "node:fs";
3
3
  import { get } from "node:http";
4
- import { createConnection } from "node:net";
5
- import { dirname, join } from "node:path";
4
+ import { createConnection, createServer } from "node:net";
5
+ import { basename, dirname, join, parse, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
+ import { DESKTOP_PARENT_PID } from "./desktop-parent.js";
8
+ import { defaultExec, foreignAclPrincipals, posixOthersCanReplace, readSddlBatch, restrictToOwnerWin32, sddlOwner, systemToolEnv, systemToolPath, toolArgv, windowsDirectorySids, windowsInvokingSid, windowsUserCanWrite, } from "./install.js";
9
+ import { hasRegisteredOperator } from "./operator-credentials.js";
7
10
  import { pidAlive, readLockFile } from "./unlock.js";
11
+ /** One stderr line when this state has no enrolled operator. */
12
+ export const DESKTOP_PASSKEY_HINT = "the panel reads the ledger after a passkey sign-in; run verax operator enroll\n";
13
+ /**
14
+ * The credentials file is written only when an operator enrolls. Its presence
15
+ * is the whole answer; a missing file means the panel's audit doors will
16
+ * refuse the session that has no passkey.
17
+ */
18
+ export function desktopPasskeyHint(stateDir) {
19
+ return hasRegisteredOperator(stateDir) ? null : DESKTOP_PASSKEY_HINT;
20
+ }
8
21
  const here = dirname(fileURLToPath(import.meta.url));
9
- const repoRoot = join(here, "..", "..", "..");
22
+ export function resolveRepoRoot() {
23
+ return join(here, "..", "..", "..");
24
+ }
25
+ export const DESKTOP_CLONE_ONLY = "verax desktop runs from a clone of github.com/verax-ai/verax; it is not in the npm package\n";
26
+ export function desktopCloneError(root) {
27
+ if (!existsSync(join(root, "scripts", "dev-issuer.mjs")) || !existsSync(join(root, "apps", "panel"))) {
28
+ return DESKTOP_CLONE_ONLY;
29
+ }
30
+ return null;
31
+ }
10
32
  /** Newest modification time under a file or directory, ignoring build output. */
11
33
  function newestUnder(path, since) {
12
34
  let entry;
@@ -49,7 +71,11 @@ export function panelBuildNeeded(distIndex, sourceRoots) {
49
71
  }
50
72
  return sourceRoots.some((root) => newestUnder(root, builtAt));
51
73
  }
52
- export function parseDesktopArgs(argv) {
74
+ /** UNC share or a Win32 device path (`\\?\`, `\\.\`, `\\?\UNC\`, `//host`): all start with two separators. */
75
+ export function desktopStateUncOrDevice(stateDir) {
76
+ return stateDir.replaceAll("/", "\\").startsWith("\\\\");
77
+ }
78
+ export function parseDesktopArgs(argv, platform = process.platform) {
53
79
  const rest = argv.slice(1);
54
80
  let stateDir = "";
55
81
  let panelPort = 5173;
@@ -88,6 +114,8 @@ export function parseDesktopArgs(argv) {
88
114
  }
89
115
  if (!stateDir)
90
116
  return { error: "usage" };
117
+ if (platform === "win32" && desktopStateUncOrDevice(stateDir))
118
+ return { error: "desktop-state-unc" };
91
119
  if (![panelPort, issuerPort, bodyPort].every((n) => Number.isInteger(n) && n > 0 && n < 65536)) {
92
120
  return { error: "port-invalid" };
93
121
  }
@@ -103,14 +131,131 @@ export function portOpen(port, host = "127.0.0.1") {
103
131
  sock.once("error", () => resolve(false));
104
132
  });
105
133
  }
106
- async function waitPort(port, ms) {
107
- const start = Date.now();
108
- while (Date.now() - start < ms) {
109
- if (await portOpen(port))
110
- return true;
111
- await new Promise((r) => setTimeout(r, 100));
134
+ function bindLoopback(port, host) {
135
+ return new Promise((resolve) => {
136
+ const server = createServer();
137
+ server.once("error", (err) => {
138
+ if (err.code === "EADDRNOTAVAIL" || err.code === "EAFNOSUPPORT")
139
+ resolve("unsupported");
140
+ else
141
+ resolve("busy");
142
+ });
143
+ server.listen({ port, host, ipv6Only: host === "::1" }, () => {
144
+ server.close(() => resolve("free"));
145
+ });
146
+ });
147
+ }
148
+ /**
149
+ * Bind 127.0.0.1 and [::1], then close both. True only when this process held
150
+ * 127.0.0.1 and [::1] was free or this host has no IPv6 loopback. A connect
151
+ * probe is not this: whoever is already listening would answer it.
152
+ */
153
+ export function desktopPortFree(port) {
154
+ return bindLoopback(port, "127.0.0.1").then(async (v4) => {
155
+ if (v4 !== "free")
156
+ return false;
157
+ const v6 = await bindLoopback(port, "::1");
158
+ return v6 !== "busy";
159
+ });
160
+ }
161
+ /** True when a socket can bind [::1]. False when the host has no IPv6 loopback. */
162
+ export function ipv6LoopbackAvailable() {
163
+ return bindLoopback(0, "::1").then((held) => held !== "unsupported");
164
+ }
165
+ /**
166
+ * Hold `[::1]:port` and pipe each accepted socket to `127.0.0.1:port`.
167
+ * `unsupported` means this host cannot bind the IPv6 loopback.
168
+ */
169
+ export function forwardIpv6Loopback(port) {
170
+ return new Promise((resolve) => {
171
+ let settled = false;
172
+ const server = createServer((inbound) => {
173
+ const upstream = createConnection({ host: "127.0.0.1", port });
174
+ const drop = () => {
175
+ inbound.destroy();
176
+ upstream.destroy();
177
+ };
178
+ inbound.on("error", drop);
179
+ upstream.on("error", drop);
180
+ inbound.pipe(upstream);
181
+ upstream.pipe(inbound);
182
+ });
183
+ server.on("error", (err) => {
184
+ if (settled)
185
+ return;
186
+ settled = true;
187
+ if (err.code === "EADDRNOTAVAIL" || err.code === "EAFNOSUPPORT")
188
+ resolve({ ok: false, reason: "unsupported" });
189
+ else
190
+ resolve({ ok: false, reason: "busy" });
191
+ });
192
+ server.listen({ port, host: "::1", ipv6Only: true }, () => {
193
+ if (settled) {
194
+ server.close();
195
+ return;
196
+ }
197
+ settled = true;
198
+ resolve({ ok: true, server });
199
+ });
200
+ });
201
+ }
202
+ function listeningPort(server) {
203
+ const addr = server.address();
204
+ return addr && typeof addr !== "string" ? addr.port : null;
205
+ }
206
+ export function desktopPortBusyLine(name, port, ipv6 = false) {
207
+ return ipv6 ? `desktop-port-busy:${name}:${port}:ipv6\n` : `desktop-port-busy:${name}:${port}\n`;
208
+ }
209
+ /** JWKS document the dev issuer rewrites under the state directory on every start. */
210
+ export function issuerJwksPinPath(stateDir) {
211
+ return join(stateDir, "dev-issuer", "jwks.json");
212
+ }
213
+ /** Drop a file left by an earlier run. A missing file is the state we want. */
214
+ export function discardStaleFile(file) {
215
+ try {
216
+ unlinkSync(file);
217
+ }
218
+ catch (err) {
219
+ if (err.code !== "ENOENT")
220
+ throw err;
221
+ }
222
+ }
223
+ /**
224
+ * The pin is the file the issuer just wrote. Compact JSON, or a throw when
225
+ * it is not a key set. Nothing is fetched.
226
+ */
227
+ export function readIssuerJwksPin(stateDir) {
228
+ let text;
229
+ try {
230
+ text = readFileSync(issuerJwksPinPath(stateDir), "utf8");
231
+ }
232
+ catch {
233
+ throw new Error("desktop-jwks-pin-failed");
234
+ }
235
+ try {
236
+ const parsed = JSON.parse(text);
237
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.keys) || parsed.keys.length === 0) {
238
+ throw new Error("desktop-jwks-pin-failed");
239
+ }
240
+ return JSON.stringify({ keys: parsed.keys });
241
+ }
242
+ catch (err) {
243
+ if (err instanceof Error && err.message === "desktop-jwks-pin-failed")
244
+ throw err;
245
+ throw new Error("desktop-jwks-pin-failed");
112
246
  }
113
- return false;
247
+ }
248
+ export function issuerReadyLine(port) {
249
+ return `dev-issuer listening on http://127.0.0.1:${port}/`;
250
+ }
251
+ export function bodyReadyLine(port) {
252
+ return `listening 127.0.0.1:${port}`;
253
+ }
254
+ export function panelReadyLine(port) {
255
+ return `http://127.0.0.1:${port}`;
256
+ }
257
+ export function childStillAlive(child) {
258
+ return child.exitCode === null && child.signalCode === null;
114
259
  }
115
260
  /** GET /healthz on the loopback port; true only for a 200. */
116
261
  export function healthzUp(port) {
@@ -126,33 +271,493 @@ export function healthzUp(port) {
126
271
  req.once("error", () => resolve(false));
127
272
  });
128
273
  }
274
+ function listenerArgv(port, platform) {
275
+ if (platform === "win32") {
276
+ // netstat, not Get-NetTCPConnection: loading the NetTCPIP module in PowerShell took long
277
+ // enough on a busy machine to time the lookup out, and a timed-out lookup is no listener.
278
+ return toolArgv("netstat", ["-ano", "-p", "TCP"], "win32");
279
+ }
280
+ if (platform === "darwin") {
281
+ return toolArgv("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-Fp"], "darwin");
282
+ }
283
+ if (platform === "linux") {
284
+ return toolArgv("ss", ["-ltnpH", `sport = :${port}`], "linux");
285
+ }
286
+ return null;
287
+ }
288
+ function onlyPid(pids) {
289
+ const unique = [...new Set(pids)];
290
+ return unique.length === 1 ? unique[0] : null;
291
+ }
292
+ /** Pid listening on 127.0.0.1:`port`, or null when the tool names nobody or more than one pid. */
293
+ export function loopbackListenPid(port, platform = process.platform) {
294
+ if (!Number.isInteger(port) || port <= 0 || port >= 65536)
295
+ return null;
296
+ const argv = listenerArgv(port, platform);
297
+ const file = argv?.[0];
298
+ if (!argv || !file)
299
+ return null;
300
+ const ran = spawnSync(file, argv.slice(1), {
301
+ encoding: "utf8",
302
+ windowsHide: true,
303
+ shell: false,
304
+ timeout: 30_000,
305
+ env: systemToolEnv(platform),
306
+ });
307
+ if (ran.error)
308
+ return null;
309
+ const text = (ran.stdout ?? "").replace(/\u0000/g, "").replace(/^\uFEFF/, "");
310
+ if (platform === "darwin")
311
+ return onlyPid(lsofPids(text));
312
+ return onlyPid(loopbackPids(text, port));
313
+ }
314
+ function loopbackPids(text, port) {
315
+ const pids = [];
316
+ const at = new RegExp(`(?:^|[\\s\\[])127\\.0\\.0\\.1:${port}(?!\\d)`);
317
+ for (const raw of text.split(/\r?\n/)) {
318
+ const line = raw.trim();
319
+ if (line === "")
320
+ continue;
321
+ // netstat -ano: "TCP 127.0.0.1:<port> 0.0.0.0:0 <state> <pid>". The state word is localized;
322
+ // a listening socket is the one whose foreign address is 0.0.0.0:0.
323
+ const win = /^TCP\s+127\.0\.0\.1:(\d+)\s+0\.0\.0\.0:0\s+\S+\s+(\d+)$/i.exec(line);
324
+ if (win) {
325
+ if (Number(win[1]) === port)
326
+ pids.push(Number(win[2]));
327
+ continue;
328
+ }
329
+ if (!at.test(line))
330
+ continue;
331
+ const found = /pid=(\d+)/.exec(line);
332
+ if (found)
333
+ pids.push(Number(found[1]));
334
+ }
335
+ return pids;
336
+ }
337
+ function lsofPids(text) {
338
+ const pids = [];
339
+ for (const raw of text.split(/\r?\n/)) {
340
+ const found = /^p(\d+)$/.exec(raw.trim());
341
+ if (found)
342
+ pids.push(Number(found[1]));
343
+ }
344
+ return pids;
345
+ }
129
346
  /**
130
347
  * One ledger, one body. The desktop used to build its own issuer and body on
131
348
  * every open, which on a machine whose body starts at logon put the window on
132
349
  * a second, empty ledger: the panel looked broken while the real decisions sat
133
- * in the directory the lock names. So the lock is read first. Held by a live
134
- * process that answers /healthz where this run was told to look, the panel
135
- * joins that body. Held by a live process that does not answer there, the
136
- * desktop stops: someone owns the ledger and is not where we were pointed, and
137
- * a second body would be refused by the lock anyway. A dead or unreadable lock
138
- * is the body's own to clear (`verax unlock`); the desktop builds as before.
350
+ * in the directory the lock names. So the lock is read first. A live lock that
351
+ * records this port, whose pid is the listener on 127.0.0.1 there, is
352
+ * `attach`. 0.4.0 does not join that body: `runDesktop` stops before it
353
+ * claims a port or starts a child. A later release may join again, once that
354
+ * body is supervised. A 200 from /healthz is not the check. Any other live
355
+ * lock stops the desktop: `desktop-body-locked:<pid>:<port or unknown>`. A
356
+ * lock with no port is that stop until the body is started again. A dead or
357
+ * unreadable lock is left for `verax unlock`, which is only for a dead lock.
139
358
  */
140
- export async function desktopMode(stateDir, bodyPort, probe = healthzUp) {
359
+ export async function desktopMode(stateDir, bodyPort, listenerPid = loopbackListenPid) {
141
360
  const lockPath = join(stateDir, "ledger.lock");
142
361
  if (!existsSync(lockPath))
143
362
  return { mode: "spawn" };
144
363
  const lock = readLockFile(lockPath);
145
364
  if (!lock || !pidAlive(lock.pid))
146
365
  return { mode: "spawn" };
147
- if (await probe(bodyPort))
148
- return { mode: "attach", pid: lock.pid };
149
- return { error: "desktop-body-locked", pid: lock.pid };
366
+ const lockPort = lock.port ?? null;
367
+ if (lockPort === null || lockPort !== bodyPort) {
368
+ return { error: "desktop-body-locked", pid: lock.pid, lockPort };
369
+ }
370
+ let heard;
371
+ try {
372
+ heard = listenerPid(bodyPort);
373
+ }
374
+ catch {
375
+ heard = null;
376
+ }
377
+ if (heard !== lock.pid)
378
+ return { error: "desktop-body-locked", pid: lock.pid, lockPort };
379
+ return { mode: "attach", pid: lock.pid };
380
+ }
381
+ /**
382
+ * The first of the issuer, the body or the panel to exit stops the rest.
383
+ * A later exit does not stop again. `stillWatching` is false once this run
384
+ * is shutting down on purpose, so those exits are not a child failure.
385
+ */
386
+ export function superviseDesktopChildren(children, stopAll, stillWatching = () => true) {
387
+ return new Promise((resolve) => {
388
+ let done = false;
389
+ const finish = (name) => {
390
+ if (done || !stillWatching())
391
+ return;
392
+ done = true;
393
+ stopAll();
394
+ resolve(name);
395
+ };
396
+ for (const child of children)
397
+ child.onExit(() => finish(child.name));
398
+ });
399
+ }
400
+ export function desktopChildExitedLine(name) {
401
+ return `desktop-child-exited:${name}\n`;
402
+ }
403
+ const DIRECTORY_WRITABLE_BY_OTHERS = "the directory was writable by others; use a new directory";
404
+ function refuseDesktopDirectory(dir, detail) {
405
+ throw new Error(detail === undefined ? `desktop-dir-refused:${dir}` : `desktop-dir-refused:${dir}\n${detail}`);
406
+ }
407
+ /** `dev-issuer` planted as a symlink or junction. A missing entry is not that. */
408
+ function devIssuerIsLink(dir) {
409
+ try {
410
+ return lstatSync(join(dir, "dev-issuer")).isSymbolicLink();
411
+ }
412
+ catch (err) {
413
+ if (err.code === "ENOENT")
414
+ return false;
415
+ throw err;
416
+ }
417
+ }
418
+ /**
419
+ * SDDL of every path from one PowerShell process (the installer's batch
420
+ * reader). One process per path timed out on a loaded Windows runner: the
421
+ * ancestor walk alone reads four to six directories.
422
+ */
423
+ function readWindowsAcls(paths) {
424
+ const read = readSddlBatch(defaultExec, paths);
425
+ const out = new Map();
426
+ for (const p of paths) {
427
+ const hit = read.get(p);
428
+ const text = (hit?.text ?? "").replace(/^\uFEFF/, "").trim();
429
+ if (hit && hit.status === 0 && text !== "") {
430
+ const aliased = /(?:[OG]:|;)L[AG](?=[)OGDS:]|$)/.test(text);
431
+ out.set(p, { sddl: aliased ? expandLocalAccountAliases(text, localAccountSids()) : text, why: "" });
432
+ }
433
+ else {
434
+ out.set(p, { sddl: null, why: text.split(/\r?\n/)[0]?.slice(0, 200) || "no answer" });
435
+ }
436
+ }
437
+ return out;
438
+ }
439
+ let localAccounts = null;
440
+ /** This machine's built-in Administrator and Guest SIDs, which an SDDL writes as `LA` and `LG`. Empty when the lookup fails. */
441
+ function localAccountSids() {
442
+ if (localAccounts)
443
+ return localAccounts;
444
+ const ran = defaultExec(toolArgv("powershell", [
445
+ "-NoProfile",
446
+ "-NonInteractive",
447
+ "-Command",
448
+ "[Security.Principal.SecurityIdentifier]::new('LA').Value; [Security.Principal.SecurityIdentifier]::new('LG').Value",
449
+ ], "win32"));
450
+ const sids = (ran.status ?? 1) === 0 ? (ran.stdout ?? "").match(/S-1-5-21-[0-9-]+/g) ?? [] : [];
451
+ localAccounts = sids.length === 2 && sids[0].endsWith("-500") && sids[1].endsWith("-501") ? { LA: sids[0], LG: sids[1] } : {};
452
+ return localAccounts;
150
453
  }
454
+ /**
455
+ * The account SIDs in place of the `LA` / `LG` aliases, so the built-in
456
+ * Administrator's own entry compares equal to its SID (a GitHub Windows
457
+ * runner runs as that account). An alias with no SID given is left, and the
458
+ * judge treats it as someone else.
459
+ */
460
+ export function expandLocalAccountAliases(sddl, sids) {
461
+ const sid = (alias) => (alias === "LA" ? sids.LA : alias === "LG" ? sids.LG : undefined);
462
+ return sddl
463
+ .replace(/([OG]:)(LA|LG)(?=[OGDS]:|$)/g, (whole, field, alias) => {
464
+ const full = sid(alias);
465
+ return full ? `${field}${full}` : whole;
466
+ })
467
+ .replace(/;(LA|LG)\)/g, (whole, alias) => {
468
+ const full = sid(alias);
469
+ return full ? `;${full})` : whole;
470
+ });
471
+ }
472
+ function unreadable(why) {
473
+ return why === "" ? "" : ` (${why})`;
474
+ }
475
+ /** Who the refused DACL names, so the operator (and a CI log) can see which entry was judged to be someone else. */
476
+ function aclWho(sddl, you) {
477
+ const others = foreignAclPrincipals(sddl, you);
478
+ return ` (owner ${sddlOwner(sddl) ?? "unknown"}; other entries ${others.length > 0 ? others.join(", ") : "none"}; you ${you || "unknown"})`;
479
+ }
480
+ /** Last bytes kept from a child so a long log cannot grow without a bound. */
481
+ export const DESKTOP_CHILD_OUTPUT_CAP = 64 * 1024;
482
+ /** Keep the tail. A ready line at the end of a large chunk stays inside the cap. */
483
+ export function rememberChildOutput(current, chunk, cap = DESKTOP_CHILD_OUTPUT_CAP) {
484
+ if (cap <= 0)
485
+ return "";
486
+ if (chunk.length >= cap)
487
+ return chunk.slice(chunk.length - cap);
488
+ if (current.length + chunk.length <= cap)
489
+ return current + chunk;
490
+ return (current + chunk).slice(current.length + chunk.length - cap);
491
+ }
492
+ function foldDesktopPath(p) {
493
+ if (process.platform !== "win32")
494
+ return p.length > 1 ? p.replace(/\/+$/, "") : p;
495
+ let s = p.replaceAll("/", "\\");
496
+ const lower = s.toLowerCase();
497
+ if (lower.startsWith("\\\\?\\unc\\"))
498
+ s = `\\\\${s.slice(8)}`;
499
+ else if (lower.startsWith("\\\\?\\"))
500
+ s = s.slice(4);
501
+ s = s.toLowerCase();
502
+ if (s.length > 3)
503
+ s = s.replace(/\\+$/, "");
504
+ return s;
505
+ }
506
+ function ancestorPaths(dir) {
507
+ const out = [];
508
+ let cur = resolve(dir);
509
+ for (let i = 0; i < 64; i += 1) {
510
+ const parent = dirname(cur);
511
+ if (parent === cur)
512
+ break;
513
+ out.push(parent);
514
+ cur = parent;
515
+ }
516
+ return out;
517
+ }
518
+ /**
519
+ * An intermediate link makes the real path differ from the path as given.
520
+ * 8.3 names are expanded with `realpathSync.native` on each component that is
521
+ * not a link, then compared case-insensitively on Windows. A missing leaf is
522
+ * the real path of the parent plus the leaf name. The leaf itself being a
523
+ * link is left to the directory check.
524
+ */
525
+ function intermediateLink(dir) {
526
+ const abs = resolve(dir);
527
+ const root = parse(abs).root;
528
+ const segs = abs.slice(root.length).split(/[\\/]/).filter((seg) => seg.length > 0);
529
+ let cursor = root;
530
+ let expanded = root;
531
+ for (let i = 0; i < segs.length; i += 1) {
532
+ const next = join(cursor, segs[i]);
533
+ const leaf = i === segs.length - 1;
534
+ let st;
535
+ try {
536
+ st = lstatSync(next);
537
+ }
538
+ catch (err) {
539
+ if (err.code === "ENOENT") {
540
+ expanded = join(expanded, ...segs.slice(i));
541
+ break;
542
+ }
543
+ throw err;
544
+ }
545
+ if (st.isSymbolicLink()) {
546
+ if (leaf)
547
+ return null;
548
+ return next;
549
+ }
550
+ try {
551
+ expanded = realpathSync.native(next);
552
+ }
553
+ catch {
554
+ return next;
555
+ }
556
+ cursor = next;
557
+ }
558
+ let real;
559
+ try {
560
+ real = realpathSync.native(abs);
561
+ }
562
+ catch (err) {
563
+ if (err.code !== "ENOENT")
564
+ throw err;
565
+ const parent = dirname(abs);
566
+ try {
567
+ real = join(realpathSync.native(parent), basename(abs));
568
+ }
569
+ catch {
570
+ return null;
571
+ }
572
+ }
573
+ if (foldDesktopPath(expanded) !== foldDesktopPath(real))
574
+ return dirname(abs);
575
+ return null;
576
+ }
577
+ function refusePosixAncestors(dir) {
578
+ const uid = typeof process.getuid === "function" ? process.getuid() : 0;
579
+ for (const ancestor of ancestorPaths(dir)) {
580
+ let st;
581
+ try {
582
+ st = lstatSync(ancestor);
583
+ }
584
+ catch (err) {
585
+ if (err.code === "ENOENT")
586
+ continue;
587
+ refuseDesktopDirectory(dir, `ancestor ${ancestor} could not be read`);
588
+ }
589
+ if (!st)
590
+ continue;
591
+ const ownerIsOperator = st.uid === 0 || st.uid === uid;
592
+ if (posixOthersCanReplace({ uid: ownerIsOperator ? 0 : st.uid, mode: st.mode, symlink: st.isSymbolicLink() }, true)) {
593
+ refuseDesktopDirectory(dir, `ancestor ${ancestor} can be replaced by another user`);
594
+ }
595
+ }
596
+ }
597
+ function ancestorInvokingSid(dir, opts) {
598
+ const hooked = opts?.windowsDirectoryOwner?.(dir).invokingSid?.trim().toUpperCase() ?? "";
599
+ if (hooked !== "")
600
+ return hooked;
601
+ if (opts?.windowsDirectoryOwner || opts?.windowsDirectoryDacl || opts?.windowsAncestorDacl)
602
+ return "";
603
+ try {
604
+ return windowsInvokingSid()?.trim().toUpperCase() ?? "";
605
+ }
606
+ catch {
607
+ return "";
608
+ }
609
+ }
610
+ function refuseWindowsAncestors(dir, opts) {
611
+ if ((opts?.windowsDirectoryDacl || opts?.windowsDirectoryOwner) && !opts?.windowsAncestorDacl)
612
+ return;
613
+ const svc = ancestorInvokingSid(dir, opts);
614
+ const ancestors = ancestorPaths(dir);
615
+ let read = null;
616
+ if (!opts?.windowsAncestorDacl) {
617
+ const present = ancestors.filter((ancestor) => {
618
+ try {
619
+ lstatSync(ancestor);
620
+ return true;
621
+ }
622
+ catch (err) {
623
+ if (err.code === "ENOENT")
624
+ return false;
625
+ return refuseDesktopDirectory(dir, `ancestor ${ancestor} could not be read`);
626
+ }
627
+ });
628
+ read = readWindowsAcls(present);
629
+ }
630
+ for (const ancestor of ancestors) {
631
+ let acl;
632
+ if (opts?.windowsAncestorDacl) {
633
+ acl = { sddl: opts.windowsAncestorDacl(ancestor), why: "" };
634
+ }
635
+ else {
636
+ const hit = read?.get(ancestor);
637
+ if (!hit)
638
+ continue;
639
+ acl = hit;
640
+ }
641
+ if (acl.sddl === null || windowsUserCanWrite(acl.sddl, { ancestor: true, svcSid: svc })) {
642
+ refuseDesktopDirectory(dir, acl.sddl === null
643
+ ? `ancestor ${ancestor} ACL could not be read${unreadable(acl.why)}`
644
+ : `ancestor ${ancestor} can be replaced by another user${aclWho(acl.sddl, svc)}`);
645
+ }
646
+ }
647
+ }
648
+ /** Intermediate link, or an ancestor another user can replace. Same rule for a new directory and an existing one. */
649
+ function refuseDesktopAncestors(dir, platform, opts) {
650
+ const link = intermediateLink(dir);
651
+ if (link)
652
+ refuseDesktopDirectory(dir, `ancestor ${link} is a link`);
653
+ if (platform === "win32")
654
+ refuseWindowsAncestors(dir, opts);
655
+ else
656
+ refusePosixAncestors(dir);
657
+ }
658
+ /**
659
+ * Create `dir` as mode 0700. An existing directory another user could write
660
+ * is refused and left as it is: on POSIX that is `mode & 0o022`, on Windows
661
+ * a DACL `windowsUserCanWrite` accepts for someone other than the owner
662
+ * (passed as `svcSid`), Administrators, or SYSTEM. A directory with only
663
+ * owner access keeps the previous path, including a chmod of group/other
664
+ * read or execute bits down to 0700. `dev-issuer` as a symlink or junction
665
+ * is refused. A symbolic link, or a directory owned by another uid, is
666
+ * refused. On Windows an existing directory is refused unless its owner SID
667
+ * is the invoking SID, Administrators, or SYSTEM. An ancestor another user
668
+ * can replace, or an intermediate link, is refused the same way for a new
669
+ * directory and for one that already exists.
670
+ */
671
+ function ensureDesktopDirectory(dir, restrict, opts) {
672
+ const platform = opts?.platform ?? process.platform;
673
+ refuseDesktopAncestors(dir, platform, opts);
674
+ let existing = null;
675
+ try {
676
+ existing = lstatSync(dir);
677
+ }
678
+ catch (err) {
679
+ if (err.code !== "ENOENT")
680
+ throw err;
681
+ }
682
+ if (existing) {
683
+ if (existing.isSymbolicLink() || !existing.isDirectory())
684
+ refuseDesktopDirectory(dir);
685
+ if (platform === "win32") {
686
+ // Production passes no hook: owner and DACL come from one SDDL read.
687
+ const daclHook = opts?.windowsDirectoryDacl;
688
+ const leaf = !opts?.windowsDirectoryOwner && !daclHook ? readWindowsAcls([dir]).get(dir) : undefined;
689
+ if (leaf && leaf.sddl === null) {
690
+ refuseDesktopDirectory(dir, `the directory ACL could not be read${unreadable(leaf.why)}; use a new directory`);
691
+ }
692
+ let owner;
693
+ let invoking;
694
+ if (leaf?.sddl) {
695
+ owner = sddlOwner(leaf.sddl)?.toUpperCase() ?? "";
696
+ invoking = windowsInvokingSid()?.trim().toUpperCase() ?? "";
697
+ }
698
+ else {
699
+ const found = (opts?.windowsDirectoryOwner ?? windowsDirectorySids)(dir);
700
+ owner = found.ownerSid?.trim().toUpperCase() ?? "";
701
+ invoking = found.invokingSid?.trim().toUpperCase() ?? "";
702
+ }
703
+ // An elevated administrator's new directories are owned by Administrators, not by the user;
704
+ // no other local user can make a directory with that owner, or with SYSTEM.
705
+ const ownerOk = owner === invoking || owner === "S-1-5-32-544" || owner === "S-1-5-18";
706
+ if (owner === "" || invoking === "" || !ownerOk)
707
+ refuseDesktopDirectory(dir);
708
+ // R16-12 injects the owner and not a DACL.
709
+ if (daclHook || !opts?.windowsDirectoryOwner) {
710
+ const sddl = daclHook ? daclHook(dir) : (leaf?.sddl ?? null);
711
+ // The invoking user's own entry is not "someone else", also when an elevated shell made Administrators the owner.
712
+ if (sddl === null || windowsUserCanWrite(sddl, { svcSid: invoking })) {
713
+ refuseDesktopDirectory(dir, sddl === null
714
+ ? "the directory ACL could not be read; use a new directory"
715
+ : `${DIRECTORY_WRITABLE_BY_OTHERS}${aclWho(sddl, invoking)}`);
716
+ }
717
+ }
718
+ if (devIssuerIsLink(dir)) {
719
+ refuseDesktopDirectory(dir, "dev-issuer in that directory is a symlink or junction; use a new directory");
720
+ }
721
+ restrict(dir);
722
+ return;
723
+ }
724
+ const uid = typeof process.getuid === "function" ? process.getuid() : existing.uid;
725
+ if (existing.uid !== uid)
726
+ refuseDesktopDirectory(dir);
727
+ if ((existing.mode & 0o022) !== 0)
728
+ refuseDesktopDirectory(dir, DIRECTORY_WRITABLE_BY_OTHERS);
729
+ if (devIssuerIsLink(dir)) {
730
+ refuseDesktopDirectory(dir, "dev-issuer in that directory is a symlink or junction; use a new directory");
731
+ }
732
+ if ((existing.mode & 0o077) !== 0)
733
+ chmodSync(dir, 0o700);
734
+ return;
735
+ }
736
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
737
+ // A missing intermediate directory another user created between the first check and mkdir is theirs now.
738
+ refuseDesktopAncestors(dir, platform, opts);
739
+ const created = lstatSync(dir);
740
+ if (created.isSymbolicLink() || !created.isDirectory())
741
+ refuseDesktopDirectory(dir);
742
+ if (platform === "win32") {
743
+ restrict(dir);
744
+ return;
745
+ }
746
+ const uid = typeof process.getuid === "function" ? process.getuid() : created.uid;
747
+ if (created.uid !== uid)
748
+ refuseDesktopDirectory(dir);
749
+ chmodSync(dir, 0o700);
750
+ }
751
+ /** Synchronous: `spawnSync` on Windows, `process.kill` elsewhere. An `exit` handler can call it. */
151
752
  export function killTree(pid) {
152
- if (pid == null)
753
+ // POSIX kill(-1) signals every process this user may signal, kill(0) our own group. No child has pid 0 or 1.
754
+ if (pid == null || !Number.isInteger(pid) || pid <= 1)
153
755
  return;
154
756
  if (process.platform === "win32") {
155
- spawnSync("taskkill", ["/T", "/PID", String(pid), "/F"], { windowsHide: true, stdio: "ignore" });
757
+ spawnSync(systemToolPath("taskkill", "win32"), ["/T", "/PID", String(pid), "/F"], {
758
+ windowsHide: true,
759
+ stdio: "ignore",
760
+ });
156
761
  return;
157
762
  }
158
763
  try {
@@ -167,8 +772,24 @@ export function killTree(pid) {
167
772
  }
168
773
  }
169
774
  }
775
+ function keptOr(base, key, value) {
776
+ const current = base[key]?.trim() ?? "";
777
+ return current !== "" ? current : value;
778
+ }
779
+ /** Comma-separated origins. An origin already listed is not added again. */
780
+ export function mergeAllowedOrigin(existing, origin) {
781
+ const parts = (existing ?? "")
782
+ .split(",")
783
+ .map((item) => item.trim())
784
+ .filter((item) => item !== "");
785
+ if (!parts.includes(origin))
786
+ parts.push(origin);
787
+ return parts.join(",");
788
+ }
170
789
  /** The panel's redirect is its own origin, so the issuer has to be told which port
171
- * this run put it on: the allow-list default only holds 5173 and 4173. */
790
+ * this run put it on: the allow-list default only holds 5173 and 4173.
791
+ * The passkey ceremony runs on the issuer (`/authorize` and `/enroll`), so the
792
+ * RP ID and RP origins name that origin unless the operator already set them. */
172
793
  export function issuerEnv(base, opts, audience, issuerUrl) {
173
794
  return {
174
795
  ...base,
@@ -177,7 +798,9 @@ export function issuerEnv(base, opts, audience, issuerUrl) {
177
798
  VERAX_DEV_ISSUER_PORT: String(opts.issuerPort),
178
799
  VERAX_AUDIENCE: audience,
179
800
  VERAX_ISSUER: issuerUrl,
180
- VERAX_DEV_REDIRECT_URIS: `http://127.0.0.1:${opts.panelPort}/`,
801
+ VERAX_DEV_REDIRECT_URIS: `http://localhost:${opts.panelPort}/`,
802
+ VERAX_RP_ID: keptOr(base, "VERAX_RP_ID", "localhost"),
803
+ VERAX_RP_ORIGINS: keptOr(base, "VERAX_RP_ORIGINS", issuerUrl),
181
804
  };
182
805
  }
183
806
  function cleanEnv() {
@@ -220,72 +843,239 @@ function defaultBrowser() {
220
843
  return p;
221
844
  return null;
222
845
  }
223
- function collectOutput(child, sink) {
224
- const feed = (c) => {
225
- sink.text += String(c);
226
- };
227
- child.stdout?.on("data", feed);
228
- child.stderr?.on("data", feed);
229
- }
230
- export async function runDesktop(opts, writeErr = (s) => process.stderr.write(s)) {
231
- mkdirSync(opts.stateDir, { recursive: true });
846
+ export async function runDesktop(opts, writeErr = (s) => process.stderr.write(s), hooks) {
847
+ const repoRoot = resolveRepoRoot();
848
+ const cloneOnly = desktopCloneError(repoRoot);
849
+ if (cloneOnly) {
850
+ writeErr(cloneOnly);
851
+ return 78;
852
+ }
853
+ // Before the issuer, the body, or the window: a first run with no operator
854
+ // file otherwise opens a panel that cannot read the ledger and does not say why.
855
+ const passkeyHint = desktopPasskeyHint(opts.stateDir);
856
+ if (passkeyHint)
857
+ writeErr(passkeyHint);
858
+ // Directories that may already hold the ledger and keys: the grants must be inheritable.
859
+ const restrictOwner = hooks?.restrictOwner ?? ((dir) => restrictToOwnerWin32(dir, undefined, true));
232
860
  const tokenPath = join(opts.stateDir, "dev-token");
233
861
  const issuerScript = join(repoRoot, "scripts", "dev-issuer.mjs");
234
862
  const mainTs = join(here, "main.ts");
235
863
  const viteJs = join(repoRoot, "node_modules", "vite", "bin", "vite.js");
236
864
  const panelDir = join(repoRoot, "apps", "panel");
237
865
  const policy = join(repoRoot, "packages", "proxy", "policy", "default.json");
238
- const audience = `http://127.0.0.1:${opts.bodyPort}`;
239
- const issuerUrl = `http://127.0.0.1:${opts.issuerPort}`;
866
+ // Browser-facing origins are localhost: an IP address cannot be a WebAuthn RP ID,
867
+ // and an RP ID of localhost does not match a page on 127.0.0.1. The audience is
868
+ // the same host so the token `aud` and the body's VERAX_AUDIENCE agree; it is
869
+ // not a page. The children bind 127.0.0.1. This process also holds [::1] on
870
+ // those ports and pipes each connection to 127.0.0.1, so a page fetched as
871
+ // localhost cannot land on another local user's socket. Node (the panel
872
+ // proxy, the JWKS URL) uses 127.0.0.1 so it does not follow a resolver that
873
+ // tries ::1 first.
874
+ const audience = `http://localhost:${opts.bodyPort}`;
875
+ const issuerUrl = `http://localhost:${opts.issuerPort}`;
876
+ const panelOrigin = `http://localhost:${opts.panelPort}`;
877
+ const bodyLoopback = `http://127.0.0.1:${opts.bodyPort}`;
878
+ const issuerLoopback = `http://127.0.0.1:${opts.issuerPort}`;
240
879
  const kids = [];
241
880
  const log = { text: "" };
881
+ const gate = { watch: true };
882
+ const exited = { name: null };
883
+ let resolveGone = () => { };
884
+ const childGone = new Promise((resolve) => {
885
+ resolveGone = resolve;
886
+ });
887
+ const forwarders = [];
888
+ const stopForwarders = () => {
889
+ for (const server of forwarders) {
890
+ server.close();
891
+ server.unref();
892
+ }
893
+ forwarders.length = 0;
894
+ };
895
+ const claimIpv6 = async (port) => {
896
+ if (forwarders.some((server) => listeningPort(server) === port))
897
+ return "held";
898
+ const opened = await forwardIpv6Loopback(port);
899
+ if (!opened.ok)
900
+ return opened.reason;
901
+ forwarders.push(opened.server);
902
+ return "held";
903
+ };
904
+ const killPid = hooks?.kill ?? killTree;
242
905
  const stopAll = () => {
906
+ gate.watch = false;
907
+ stopForwarders();
243
908
  for (const c of kids)
244
- killTree(c.pid);
909
+ killPid(c.pid);
910
+ };
911
+ const signalHost = hooks?.signals ?? process;
912
+ const signalPlatform = hooks?.signals?.platform ?? process.platform;
913
+ let disarmSignals = () => { };
914
+ const launch = (name, cmd, args, env, cwd, hideWindow = true) => (hooks?.spawn ? hooks.spawn(name, cmd, args, env, cwd) : spawnLogged(cmd, args, env, cwd, hideWindow));
915
+ const pipe = (name, child, own) => {
916
+ const feed = (chunk) => {
917
+ const text = String(chunk);
918
+ own.text = rememberChildOutput(own.text, text);
919
+ log.text = rememberChildOutput(log.text, text);
920
+ hooks?.childOutput?.(name, own.text);
921
+ };
922
+ child.stdout?.on("data", feed);
923
+ child.stderr?.on("data", feed);
924
+ };
925
+ // Before the wait, so a child that dies during startup is the failure.
926
+ const arm = (name, child) => {
927
+ const note = () => {
928
+ if (!gate.watch || exited.name)
929
+ return;
930
+ exited.name = name;
931
+ resolveGone(name);
932
+ stopAll();
933
+ };
934
+ if (child.exitCode !== null || child.signalCode !== null)
935
+ note();
936
+ else
937
+ child.once("exit", note);
938
+ };
939
+ const waitReady = async (child, own, ready, ms) => {
940
+ const start = Date.now();
941
+ while (Date.now() - start < ms) {
942
+ if (exited.name || !childStillAlive(child))
943
+ return "exited";
944
+ if (ready(own.text))
945
+ return childStillAlive(child) && !exited.name ? "ready" : "exited";
946
+ await new Promise((r) => setTimeout(r, 20));
947
+ }
948
+ if (exited.name || !childStillAlive(child))
949
+ return "exited";
950
+ return ready(own.text) ? "ready" : "timeout";
951
+ };
952
+ const ipv4Free = (port) => bindLoopback(port, "127.0.0.1").then((held) => held === "free");
953
+ const busy = async (name, port) => {
954
+ const v6 = await claimIpv6(port);
955
+ if (v6 === "busy") {
956
+ writeErr(desktopPortBusyLine(name, port, true));
957
+ stopAll();
958
+ return true;
959
+ }
960
+ if (await ipv4Free(port))
961
+ return false;
962
+ writeErr(desktopPortBusyLine(name, port));
963
+ stopAll();
964
+ return true;
965
+ };
966
+ const childFailed = (name) => {
967
+ writeErr(desktopChildExitedLine(exited.name ?? name));
968
+ stopAll();
969
+ return 1;
245
970
  };
246
971
  try {
247
- const decided = await desktopMode(opts.stateDir, opts.bodyPort);
972
+ disarmSignals = armDesktopStop(signalHost, signalPlatform, stopAll);
973
+ ensureDesktopDirectory(opts.stateDir, restrictOwner, {
974
+ platform: hooks?.platform,
975
+ windowsDirectoryOwner: hooks?.windowsDirectoryOwner,
976
+ windowsDirectoryDacl: hooks?.windowsDirectoryDacl,
977
+ windowsAncestorDacl: hooks?.windowsAncestorDacl,
978
+ });
979
+ const decided = await desktopMode(opts.stateDir, opts.bodyPort, hooks?.listenerPid);
248
980
  if ("error" in decided) {
249
- writeErr(`${decided.error}:${decided.pid}\n`);
981
+ const named = decided.lockPort === null ? "unknown" : String(decided.lockPort);
982
+ writeErr(`${decided.error}:${decided.pid}:${named}\n`);
250
983
  return 1;
251
984
  }
252
- // Joining a running body: its issuer is whatever the body's resource
253
- // metadata names, and its token is not ours to read. The panel's own
254
- // origin has to be on that issuer's allow-list (VERAX_DEV_REDIRECT_URIS on
255
- // the running issuer), which this run cannot set after the fact.
256
- let token = null;
257
- if (decided.mode === "spawn") {
258
- const issuer = spawnLogged(process.execPath, ["--experimental-strip-types", issuerScript, "--out", tokenPath], issuerEnv(cleanEnv(), opts, audience, issuerUrl), repoRoot);
259
- kids.push(issuer);
260
- collectOutput(issuer, log);
261
- if (!(await waitPort(opts.issuerPort, 15_000))) {
262
- writeErr("desktop-issuer-timeout\n");
263
- stopAll();
264
- return 1;
265
- }
266
- token = readFileSync(tokenPath, "utf8").trim();
267
- if (token === "") {
268
- writeErr("desktop-token-missing\n");
269
- stopAll();
270
- return 1;
271
- }
272
- const body = spawnLogged(process.execPath, ["--experimental-strip-types", mainTs], {
273
- ...cleanEnv(),
274
- VERAX_STATE_DIR: opts.stateDir,
275
- VERAX_ISSUER: issuerUrl,
276
- VERAX_JWKS_URL: `${issuerUrl}/.well-known/jwks.json`,
277
- VERAX_AUDIENCE: audience,
278
- VERAX_BIND: `127.0.0.1:${opts.bodyPort}`,
279
- VERAX_POLICY_FILE: policy,
280
- ...(opts.inventoryFile ? { VERAX_INVENTORY_FILE: opts.inventoryFile } : {}),
281
- }, repoRoot);
282
- kids.push(body);
283
- collectOutput(body, log);
284
- if (!(await waitPort(opts.bodyPort, 15_000))) {
285
- writeErr("desktop-body-timeout\n");
286
- stopAll();
985
+ // 0.4.0 does not join a body that is already running on this state directory.
986
+ // The attached body is not supervised: if it dies, another local user can
987
+ // bind 127.0.0.1 on its port and the panel would hand over the operator
988
+ // session. Stop before any port is claimed and before any child starts.
989
+ if (decided.mode === "attach") {
990
+ writeErr(`desktop-body-running:${opts.bodyPort}\n`);
991
+ writeErr("a body is already running on this state directory; use the panel of the desktop that started it, or stop that body first. verax unlock is for a dead lock only\n");
992
+ return 1;
993
+ }
994
+ // Only the ports this run will bind must be free.
995
+ const ports = [
996
+ ["issuer", opts.issuerPort],
997
+ ["body", opts.bodyPort],
998
+ ["panel", opts.panelPort],
999
+ ];
1000
+ for (const [name, port] of ports) {
1001
+ if (await busy(name, port))
287
1002
  return 1;
288
- }
1003
+ }
1004
+ let token = null;
1005
+ // A token or pin left on disk is the previous run's. This issuer must write both.
1006
+ // This file is the dev issuer's --out token, not the install agent token
1007
+ // (`%ProgramData%\Verax\agent-token\<SID>\agent.token` or `~/.verax/agent.token`).
1008
+ discardStaleFile(tokenPath);
1009
+ discardStaleFile(issuerJwksPinPath(opts.stateDir));
1010
+ if (await busy("issuer", opts.issuerPort))
1011
+ return 1;
1012
+ const issuer = launch("issuer", process.execPath, ["--experimental-strip-types", issuerScript, "--out", tokenPath], { ...issuerEnv(cleanEnv(), opts, audience, issuerUrl), [DESKTOP_PARENT_PID]: String(process.pid) }, repoRoot);
1013
+ kids.push(issuer);
1014
+ arm("issuer", issuer);
1015
+ const issuerOut = { text: "" };
1016
+ pipe("issuer", issuer, issuerOut);
1017
+ const issuerReady = await waitReady(issuer, issuerOut, (text) => text.includes(issuerReadyLine(opts.issuerPort)), hooks?.readyMs ?? 15_000);
1018
+ if (issuerReady === "exited")
1019
+ return childFailed("issuer");
1020
+ if (issuerReady === "timeout") {
1021
+ writeErr("desktop-issuer-timeout\n");
1022
+ stopAll();
1023
+ return 1;
1024
+ }
1025
+ // The --out file is the agent credential. It does not carry verax:audit.
1026
+ // The panel reads the ledger with the passkey session, not this file.
1027
+ let tokenText = "";
1028
+ try {
1029
+ tokenText = readFileSync(tokenPath, "utf8").trim();
1030
+ }
1031
+ catch {
1032
+ tokenText = "";
1033
+ }
1034
+ if (tokenText === "") {
1035
+ writeErr("desktop-token-missing\n");
1036
+ stopAll();
1037
+ return 1;
1038
+ }
1039
+ token = tokenText;
1040
+ // The body verifies with the keys this issuer wrote at start. A fetch of
1041
+ // the port would pin whoever answered. VERAX_JWKS_FILE is not this pin:
1042
+ // that mode refuses operator scopes. The URL stays so config can load;
1043
+ // the pin is what verification uses, and it does not refetch.
1044
+ let pinnedJwks;
1045
+ try {
1046
+ pinnedJwks = readIssuerJwksPin(opts.stateDir);
1047
+ }
1048
+ catch {
1049
+ writeErr("desktop-jwks-pin-failed\n");
1050
+ stopAll();
1051
+ return 1;
1052
+ }
1053
+ if (await busy("body", opts.bodyPort))
1054
+ return 1;
1055
+ const body = launch("body", process.execPath, ["--experimental-strip-types", mainTs], {
1056
+ ...cleanEnv(),
1057
+ VERAX_STATE_DIR: opts.stateDir,
1058
+ VERAX_ISSUER: issuerUrl,
1059
+ VERAX_JWKS_URL: `${issuerLoopback}/.well-known/jwks.json`,
1060
+ VERAX_JWKS_PIN: pinnedJwks,
1061
+ VERAX_AUDIENCE: audience,
1062
+ VERAX_BIND: `127.0.0.1:${opts.bodyPort}`,
1063
+ VERAX_ALLOWED_ORIGINS: mergeAllowedOrigin(process.env.VERAX_ALLOWED_ORIGINS, panelOrigin),
1064
+ VERAX_POLICY_FILE: policy,
1065
+ [DESKTOP_PARENT_PID]: String(process.pid),
1066
+ ...(opts.inventoryFile ? { VERAX_INVENTORY_FILE: opts.inventoryFile } : {}),
1067
+ }, repoRoot);
1068
+ kids.push(body);
1069
+ arm("body", body);
1070
+ const bodyOut = { text: "" };
1071
+ pipe("body", body, bodyOut);
1072
+ const bodyReady = await waitReady(body, bodyOut, (text) => text.includes(bodyReadyLine(opts.bodyPort)), hooks?.readyMs ?? 15_000);
1073
+ if (bodyReady === "exited")
1074
+ return childFailed("body");
1075
+ if (bodyReady === "timeout") {
1076
+ writeErr("desktop-body-timeout\n");
1077
+ stopAll();
1078
+ return 1;
289
1079
  }
290
1080
  const panelSources = [
291
1081
  join(panelDir, "src"),
@@ -293,10 +1083,10 @@ export async function runDesktop(opts, writeErr = (s) => process.stderr.write(s)
293
1083
  join(panelDir, "vite.config.ts"),
294
1084
  join(repoRoot, "packages"),
295
1085
  ];
296
- if (panelBuildNeeded(join(panelDir, "dist", "index.html"), panelSources)) {
1086
+ if (!hooks?.spawn && panelBuildNeeded(join(panelDir, "dist", "index.html"), panelSources)) {
297
1087
  const built = spawnSync(process.execPath, [viteJs, "build"], {
298
1088
  cwd: panelDir,
299
- env: { ...cleanEnv(), VERAX_BODY_URL: audience },
1089
+ env: { ...cleanEnv(), VERAX_BODY_URL: bodyLoopback },
300
1090
  encoding: "utf8",
301
1091
  windowsHide: true,
302
1092
  });
@@ -307,18 +1097,28 @@ export async function runDesktop(opts, writeErr = (s) => process.stderr.write(s)
307
1097
  return 1;
308
1098
  }
309
1099
  }
310
- const panel = spawnLogged(process.execPath, [viteJs, "preview", "--host", "127.0.0.1", "--port", String(opts.panelPort), "--strictPort"], {
1100
+ if (await busy("panel", opts.panelPort))
1101
+ return 1;
1102
+ // vite preview has no parent-pid watch. stopAll kills it with the tree.
1103
+ // A launcher killed outright does not run stopAll; the issuer and the body
1104
+ // exit when VERAX_DESKTOP_PARENT_PID is gone, and without them this port is idle.
1105
+ const panel = launch("panel", process.execPath, [viteJs, "preview", "--host", "127.0.0.1", "--port", String(opts.panelPort), "--strictPort"], {
311
1106
  ...cleanEnv(),
312
- VERAX_BODY_URL: audience,
1107
+ VERAX_BODY_URL: bodyLoopback,
313
1108
  }, panelDir);
314
1109
  kids.push(panel);
315
- collectOutput(panel, log);
316
- if (!(await waitPort(opts.panelPort, 20_000))) {
1110
+ arm("panel", panel);
1111
+ const panelOut = { text: "" };
1112
+ pipe("panel", panel, panelOut);
1113
+ const panelReady = await waitReady(panel, panelOut, (text) => text.includes(panelReadyLine(opts.panelPort)), hooks?.readyMs ?? 20_000);
1114
+ if (panelReady === "exited")
1115
+ return childFailed("panel");
1116
+ if (panelReady === "timeout") {
317
1117
  writeErr("desktop-panel-timeout\n");
318
1118
  stopAll();
319
1119
  return 1;
320
1120
  }
321
- const url = `http://127.0.0.1:${opts.panelPort}`;
1121
+ const url = panelOrigin;
322
1122
  const scriptBrowser = Boolean(opts.browser && /\.(mjs|js|ts)$/.test(opts.browser));
323
1123
  const browserBin = scriptBrowser ? process.execPath : (opts.browser ?? defaultBrowser());
324
1124
  if (!browserBin) {
@@ -327,7 +1127,12 @@ export async function runDesktop(opts, writeErr = (s) => process.stderr.write(s)
327
1127
  return 1;
328
1128
  }
329
1129
  const profileDir = join(opts.stateDir, "browser-profile");
330
- mkdirSync(profileDir, { recursive: true });
1130
+ ensureDesktopDirectory(profileDir, restrictOwner, {
1131
+ platform: hooks?.platform,
1132
+ windowsDirectoryOwner: hooks?.windowsDirectoryOwner,
1133
+ windowsDirectoryDacl: hooks?.windowsDirectoryDacl,
1134
+ windowsAncestorDacl: hooks?.windowsAncestorDacl,
1135
+ });
331
1136
  const browserArgv = scriptBrowser
332
1137
  ? [opts.browser, url]
333
1138
  : [
@@ -337,42 +1142,46 @@ export async function runDesktop(opts, writeErr = (s) => process.stderr.write(s)
337
1142
  `--app=${url}`,
338
1143
  "--window-size=1360,880",
339
1144
  ];
340
- const browser = spawnLogged(browserBin, browserArgv, cleanEnv(), repoRoot, false);
1145
+ const browser = launch("browser", browserBin, browserArgv, cleanEnv(), repoRoot, false);
341
1146
  kids.push(browser);
342
- collectOutput(browser, log);
1147
+ pipe("browser", browser, { text: "" });
343
1148
  if (token !== null && log.text.includes(token)) {
344
1149
  writeErr("desktop-token-leaked\n");
345
1150
  stopAll();
346
1151
  return 1;
347
1152
  }
348
- process.stdout.write(decided.mode === "attach"
349
- ? `desktop-ready body=${opts.bodyPort} panel=${opts.panelPort} attached=1 pid=${decided.pid}\n`
350
- : `desktop-ready issuer=${opts.issuerPort} body=${opts.bodyPort} panel=${opts.panelPort}\n`);
351
- const onSignal = () => {
352
- stopAll();
353
- process.exit(1);
354
- };
355
- process.once("SIGINT", onSignal);
356
- process.once("SIGTERM", onSignal);
1153
+ process.stdout.write(`desktop-ready issuer=${opts.issuerPort} body=${opts.bodyPort} panel=${opts.panelPort}\n`);
357
1154
  const closed = new Promise((resolve) => {
358
- browser.once("close", () => resolve());
1155
+ if (browser.exitCode !== null || browser.signalCode !== null)
1156
+ resolve();
1157
+ else
1158
+ browser.once("close", () => resolve());
359
1159
  });
1160
+ const childExit = async (name) => {
1161
+ writeErr(desktopChildExitedLine(name));
1162
+ stopAll();
1163
+ return 1;
1164
+ };
360
1165
  const exitedEarly = await Promise.race([
361
- closed.then(() => true),
1166
+ closed.then(() => ({ kind: "browser" })),
1167
+ childGone.then((name) => ({ kind: "child", name })),
362
1168
  new Promise((resolve) => {
363
- setTimeout(() => resolve(false), 3_000);
1169
+ setTimeout(() => resolve({ kind: "stay" }), 3_000);
364
1170
  }),
365
1171
  ]);
366
- if (exitedEarly) {
1172
+ if (exitedEarly.kind === "child")
1173
+ return childExit(exitedEarly.name);
1174
+ if (exitedEarly.kind === "browser") {
367
1175
  writeErr("desktop-browser-exited-early\n");
368
- process.removeListener("SIGINT", onSignal);
369
- process.removeListener("SIGTERM", onSignal);
370
1176
  stopAll();
371
1177
  return 1;
372
1178
  }
373
- await closed;
374
- process.removeListener("SIGINT", onSignal);
375
- process.removeListener("SIGTERM", onSignal);
1179
+ const ended = await Promise.race([
1180
+ closed.then(() => ({ kind: "browser" })),
1181
+ childGone.then((name) => ({ kind: "child", name })),
1182
+ ]);
1183
+ if (ended.kind === "child")
1184
+ return childExit(ended.name);
376
1185
  stopAll();
377
1186
  return 0;
378
1187
  }
@@ -382,6 +1191,40 @@ export async function runDesktop(opts, writeErr = (s) => process.stderr.write(s)
382
1191
  writeErr(`desktop-failed:${msg}\n`);
383
1192
  return 1;
384
1193
  }
1194
+ finally {
1195
+ disarmSignals();
1196
+ }
1197
+ }
1198
+ /** SIGHUP on POSIX, SIGBREAK on Windows, plus crash and process exit. `stopAll` is synchronous. */
1199
+ function armDesktopStop(host, platform, stopAll) {
1200
+ const onSignal = () => {
1201
+ stopAll();
1202
+ host.exit(1);
1203
+ };
1204
+ const onCrash = () => {
1205
+ stopAll();
1206
+ host.exit(1);
1207
+ };
1208
+ const onExit = () => {
1209
+ stopAll();
1210
+ };
1211
+ const paired = [
1212
+ ["SIGINT", onSignal],
1213
+ ["SIGTERM", onSignal],
1214
+ // Node raises SIGHUP on Windows too, when the console window is closed.
1215
+ ["SIGHUP", onSignal],
1216
+ ...(platform === "win32" ? [["SIGBREAK", onSignal]] : []),
1217
+ ["uncaughtException", onCrash],
1218
+ ["unhandledRejection", onCrash],
1219
+ ];
1220
+ for (const [event, fn] of paired)
1221
+ host.once(event, fn);
1222
+ host.on("exit", onExit);
1223
+ return () => {
1224
+ for (const [event, fn] of paired)
1225
+ host.removeListener(event, fn);
1226
+ host.removeListener("exit", onExit);
1227
+ };
385
1228
  }
386
1229
  export async function desktopMain(argv) {
387
1230
  const parsed = parseDesktopArgs(argv);