@verax-ai/body 0.3.0 → 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/README.md +7 -2
- package/dist/approve-cli.d.ts +22 -1
- package/dist/approve-cli.js +209 -22
- package/dist/auth.d.ts +9 -1
- package/dist/auth.js +12 -2
- package/dist/body-log.d.ts +14 -0
- package/dist/body-log.js +61 -0
- package/dist/cli.d.ts +23 -1
- package/dist/cli.js +227 -94
- package/dist/config.d.ts +26 -0
- package/dist/config.js +47 -0
- package/dist/demo.d.ts +2 -0
- package/dist/demo.js +8 -2
- package/dist/desktop-parent.d.ts +19 -0
- package/dist/desktop-parent.js +44 -0
- package/dist/desktop.d.ts +140 -11
- package/dist/desktop.js +931 -103
- package/dist/doctor.d.ts +2 -0
- package/dist/doctor.js +94 -11
- package/dist/health-extras.d.ts +4 -0
- package/dist/health-extras.js +14 -0
- package/dist/init-local.d.ts +25 -1
- package/dist/init-local.js +151 -54
- package/dist/install.d.ts +745 -0
- package/dist/install.js +5738 -0
- package/dist/keys.d.ts +6 -0
- package/dist/keys.js +43 -20
- package/dist/main.js +11 -1
- package/dist/no-bypass-scan.js +3 -2
- package/dist/reconcile-cli.js +3 -1
- package/dist/server.d.ts +14 -1
- package/dist/server.js +125 -3
- package/dist/tools/audit.d.ts +5 -2
- package/dist/tools/audit.js +12 -2
- package/dist/tools/memory.js +41 -6
- package/dist/tools/message.js +38 -9
- package/dist/unlock.d.ts +5 -2
- package/dist/unlock.js +6 -1
- package/dist/verify-cli.js +135 -15
- package/dist/wiring.js +1 -1
- package/dist/witness.js +154 -24
- package/package.json +7 -3
package/dist/desktop.js
CHANGED
|
@@ -1,10 +1,23 @@
|
|
|
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
22
|
export function resolveRepoRoot() {
|
|
10
23
|
return join(here, "..", "..", "..");
|
|
@@ -58,7 +71,11 @@ export function panelBuildNeeded(distIndex, sourceRoots) {
|
|
|
58
71
|
}
|
|
59
72
|
return sourceRoots.some((root) => newestUnder(root, builtAt));
|
|
60
73
|
}
|
|
61
|
-
|
|
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) {
|
|
62
79
|
const rest = argv.slice(1);
|
|
63
80
|
let stateDir = "";
|
|
64
81
|
let panelPort = 5173;
|
|
@@ -97,6 +114,8 @@ export function parseDesktopArgs(argv) {
|
|
|
97
114
|
}
|
|
98
115
|
if (!stateDir)
|
|
99
116
|
return { error: "usage" };
|
|
117
|
+
if (platform === "win32" && desktopStateUncOrDevice(stateDir))
|
|
118
|
+
return { error: "desktop-state-unc" };
|
|
100
119
|
if (![panelPort, issuerPort, bodyPort].every((n) => Number.isInteger(n) && n > 0 && n < 65536)) {
|
|
101
120
|
return { error: "port-invalid" };
|
|
102
121
|
}
|
|
@@ -112,14 +131,131 @@ export function portOpen(port, host = "127.0.0.1") {
|
|
|
112
131
|
sock.once("error", () => resolve(false));
|
|
113
132
|
});
|
|
114
133
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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;
|
|
121
221
|
}
|
|
122
|
-
|
|
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");
|
|
246
|
+
}
|
|
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;
|
|
123
259
|
}
|
|
124
260
|
/** GET /healthz on the loopback port; true only for a 200. */
|
|
125
261
|
export function healthzUp(port) {
|
|
@@ -135,33 +271,493 @@ export function healthzUp(port) {
|
|
|
135
271
|
req.once("error", () => resolve(false));
|
|
136
272
|
});
|
|
137
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
|
+
}
|
|
138
346
|
/**
|
|
139
347
|
* One ledger, one body. The desktop used to build its own issuer and body on
|
|
140
348
|
* every open, which on a machine whose body starts at logon put the window on
|
|
141
349
|
* a second, empty ledger: the panel looked broken while the real decisions sat
|
|
142
|
-
* in the directory the lock names. So the lock is read first.
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
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.
|
|
148
358
|
*/
|
|
149
|
-
export async function desktopMode(stateDir, bodyPort,
|
|
359
|
+
export async function desktopMode(stateDir, bodyPort, listenerPid = loopbackListenPid) {
|
|
150
360
|
const lockPath = join(stateDir, "ledger.lock");
|
|
151
361
|
if (!existsSync(lockPath))
|
|
152
362
|
return { mode: "spawn" };
|
|
153
363
|
const lock = readLockFile(lockPath);
|
|
154
364
|
if (!lock || !pidAlive(lock.pid))
|
|
155
365
|
return { mode: "spawn" };
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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;
|
|
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;
|
|
159
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. */
|
|
160
752
|
export function killTree(pid) {
|
|
161
|
-
|
|
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)
|
|
162
755
|
return;
|
|
163
756
|
if (process.platform === "win32") {
|
|
164
|
-
spawnSync("taskkill", ["/T", "/PID", String(pid), "/F"], {
|
|
757
|
+
spawnSync(systemToolPath("taskkill", "win32"), ["/T", "/PID", String(pid), "/F"], {
|
|
758
|
+
windowsHide: true,
|
|
759
|
+
stdio: "ignore",
|
|
760
|
+
});
|
|
165
761
|
return;
|
|
166
762
|
}
|
|
167
763
|
try {
|
|
@@ -176,8 +772,24 @@ export function killTree(pid) {
|
|
|
176
772
|
}
|
|
177
773
|
}
|
|
178
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
|
+
}
|
|
179
789
|
/** The panel's redirect is its own origin, so the issuer has to be told which port
|
|
180
|
-
* 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. */
|
|
181
793
|
export function issuerEnv(base, opts, audience, issuerUrl) {
|
|
182
794
|
return {
|
|
183
795
|
...base,
|
|
@@ -186,7 +798,9 @@ export function issuerEnv(base, opts, audience, issuerUrl) {
|
|
|
186
798
|
VERAX_DEV_ISSUER_PORT: String(opts.issuerPort),
|
|
187
799
|
VERAX_AUDIENCE: audience,
|
|
188
800
|
VERAX_ISSUER: issuerUrl,
|
|
189
|
-
VERAX_DEV_REDIRECT_URIS: `http://
|
|
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),
|
|
190
804
|
};
|
|
191
805
|
}
|
|
192
806
|
function cleanEnv() {
|
|
@@ -229,78 +843,239 @@ function defaultBrowser() {
|
|
|
229
843
|
return p;
|
|
230
844
|
return null;
|
|
231
845
|
}
|
|
232
|
-
function
|
|
233
|
-
const feed = (c) => {
|
|
234
|
-
sink.text += String(c);
|
|
235
|
-
};
|
|
236
|
-
child.stdout?.on("data", feed);
|
|
237
|
-
child.stderr?.on("data", feed);
|
|
238
|
-
}
|
|
239
|
-
export async function runDesktop(opts, writeErr = (s) => process.stderr.write(s)) {
|
|
846
|
+
export async function runDesktop(opts, writeErr = (s) => process.stderr.write(s), hooks) {
|
|
240
847
|
const repoRoot = resolveRepoRoot();
|
|
241
848
|
const cloneOnly = desktopCloneError(repoRoot);
|
|
242
849
|
if (cloneOnly) {
|
|
243
850
|
writeErr(cloneOnly);
|
|
244
851
|
return 78;
|
|
245
852
|
}
|
|
246
|
-
|
|
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));
|
|
247
860
|
const tokenPath = join(opts.stateDir, "dev-token");
|
|
248
861
|
const issuerScript = join(repoRoot, "scripts", "dev-issuer.mjs");
|
|
249
862
|
const mainTs = join(here, "main.ts");
|
|
250
863
|
const viteJs = join(repoRoot, "node_modules", "vite", "bin", "vite.js");
|
|
251
864
|
const panelDir = join(repoRoot, "apps", "panel");
|
|
252
865
|
const policy = join(repoRoot, "packages", "proxy", "policy", "default.json");
|
|
253
|
-
|
|
254
|
-
|
|
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}`;
|
|
255
879
|
const kids = [];
|
|
256
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;
|
|
257
905
|
const stopAll = () => {
|
|
906
|
+
gate.watch = false;
|
|
907
|
+
stopForwarders();
|
|
258
908
|
for (const c of kids)
|
|
259
|
-
|
|
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;
|
|
260
970
|
};
|
|
261
971
|
try {
|
|
262
|
-
|
|
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);
|
|
263
980
|
if ("error" in decided) {
|
|
264
|
-
|
|
981
|
+
const named = decided.lockPort === null ? "unknown" : String(decided.lockPort);
|
|
982
|
+
writeErr(`${decided.error}:${decided.pid}:${named}\n`);
|
|
265
983
|
return 1;
|
|
266
984
|
}
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
stopAll();
|
|
285
|
-
return 1;
|
|
286
|
-
}
|
|
287
|
-
const body = spawnLogged(process.execPath, ["--experimental-strip-types", mainTs], {
|
|
288
|
-
...cleanEnv(),
|
|
289
|
-
VERAX_STATE_DIR: opts.stateDir,
|
|
290
|
-
VERAX_ISSUER: issuerUrl,
|
|
291
|
-
VERAX_JWKS_URL: `${issuerUrl}/.well-known/jwks.json`,
|
|
292
|
-
VERAX_AUDIENCE: audience,
|
|
293
|
-
VERAX_BIND: `127.0.0.1:${opts.bodyPort}`,
|
|
294
|
-
VERAX_POLICY_FILE: policy,
|
|
295
|
-
...(opts.inventoryFile ? { VERAX_INVENTORY_FILE: opts.inventoryFile } : {}),
|
|
296
|
-
}, repoRoot);
|
|
297
|
-
kids.push(body);
|
|
298
|
-
collectOutput(body, log);
|
|
299
|
-
if (!(await waitPort(opts.bodyPort, 15_000))) {
|
|
300
|
-
writeErr("desktop-body-timeout\n");
|
|
301
|
-
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))
|
|
302
1002
|
return 1;
|
|
303
|
-
|
|
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;
|
|
304
1079
|
}
|
|
305
1080
|
const panelSources = [
|
|
306
1081
|
join(panelDir, "src"),
|
|
@@ -308,10 +1083,10 @@ export async function runDesktop(opts, writeErr = (s) => process.stderr.write(s)
|
|
|
308
1083
|
join(panelDir, "vite.config.ts"),
|
|
309
1084
|
join(repoRoot, "packages"),
|
|
310
1085
|
];
|
|
311
|
-
if (panelBuildNeeded(join(panelDir, "dist", "index.html"), panelSources)) {
|
|
1086
|
+
if (!hooks?.spawn && panelBuildNeeded(join(panelDir, "dist", "index.html"), panelSources)) {
|
|
312
1087
|
const built = spawnSync(process.execPath, [viteJs, "build"], {
|
|
313
1088
|
cwd: panelDir,
|
|
314
|
-
env: { ...cleanEnv(), VERAX_BODY_URL:
|
|
1089
|
+
env: { ...cleanEnv(), VERAX_BODY_URL: bodyLoopback },
|
|
315
1090
|
encoding: "utf8",
|
|
316
1091
|
windowsHide: true,
|
|
317
1092
|
});
|
|
@@ -322,18 +1097,28 @@ export async function runDesktop(opts, writeErr = (s) => process.stderr.write(s)
|
|
|
322
1097
|
return 1;
|
|
323
1098
|
}
|
|
324
1099
|
}
|
|
325
|
-
|
|
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"], {
|
|
326
1106
|
...cleanEnv(),
|
|
327
|
-
VERAX_BODY_URL:
|
|
1107
|
+
VERAX_BODY_URL: bodyLoopback,
|
|
328
1108
|
}, panelDir);
|
|
329
1109
|
kids.push(panel);
|
|
330
|
-
|
|
331
|
-
|
|
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") {
|
|
332
1117
|
writeErr("desktop-panel-timeout\n");
|
|
333
1118
|
stopAll();
|
|
334
1119
|
return 1;
|
|
335
1120
|
}
|
|
336
|
-
const url =
|
|
1121
|
+
const url = panelOrigin;
|
|
337
1122
|
const scriptBrowser = Boolean(opts.browser && /\.(mjs|js|ts)$/.test(opts.browser));
|
|
338
1123
|
const browserBin = scriptBrowser ? process.execPath : (opts.browser ?? defaultBrowser());
|
|
339
1124
|
if (!browserBin) {
|
|
@@ -342,7 +1127,12 @@ export async function runDesktop(opts, writeErr = (s) => process.stderr.write(s)
|
|
|
342
1127
|
return 1;
|
|
343
1128
|
}
|
|
344
1129
|
const profileDir = join(opts.stateDir, "browser-profile");
|
|
345
|
-
|
|
1130
|
+
ensureDesktopDirectory(profileDir, restrictOwner, {
|
|
1131
|
+
platform: hooks?.platform,
|
|
1132
|
+
windowsDirectoryOwner: hooks?.windowsDirectoryOwner,
|
|
1133
|
+
windowsDirectoryDacl: hooks?.windowsDirectoryDacl,
|
|
1134
|
+
windowsAncestorDacl: hooks?.windowsAncestorDacl,
|
|
1135
|
+
});
|
|
346
1136
|
const browserArgv = scriptBrowser
|
|
347
1137
|
? [opts.browser, url]
|
|
348
1138
|
: [
|
|
@@ -352,42 +1142,46 @@ export async function runDesktop(opts, writeErr = (s) => process.stderr.write(s)
|
|
|
352
1142
|
`--app=${url}`,
|
|
353
1143
|
"--window-size=1360,880",
|
|
354
1144
|
];
|
|
355
|
-
const browser =
|
|
1145
|
+
const browser = launch("browser", browserBin, browserArgv, cleanEnv(), repoRoot, false);
|
|
356
1146
|
kids.push(browser);
|
|
357
|
-
|
|
1147
|
+
pipe("browser", browser, { text: "" });
|
|
358
1148
|
if (token !== null && log.text.includes(token)) {
|
|
359
1149
|
writeErr("desktop-token-leaked\n");
|
|
360
1150
|
stopAll();
|
|
361
1151
|
return 1;
|
|
362
1152
|
}
|
|
363
|
-
process.stdout.write(
|
|
364
|
-
? `desktop-ready body=${opts.bodyPort} panel=${opts.panelPort} attached=1 pid=${decided.pid}\n`
|
|
365
|
-
: `desktop-ready issuer=${opts.issuerPort} body=${opts.bodyPort} panel=${opts.panelPort}\n`);
|
|
366
|
-
const onSignal = () => {
|
|
367
|
-
stopAll();
|
|
368
|
-
process.exit(1);
|
|
369
|
-
};
|
|
370
|
-
process.once("SIGINT", onSignal);
|
|
371
|
-
process.once("SIGTERM", onSignal);
|
|
1153
|
+
process.stdout.write(`desktop-ready issuer=${opts.issuerPort} body=${opts.bodyPort} panel=${opts.panelPort}\n`);
|
|
372
1154
|
const closed = new Promise((resolve) => {
|
|
373
|
-
browser.
|
|
1155
|
+
if (browser.exitCode !== null || browser.signalCode !== null)
|
|
1156
|
+
resolve();
|
|
1157
|
+
else
|
|
1158
|
+
browser.once("close", () => resolve());
|
|
374
1159
|
});
|
|
1160
|
+
const childExit = async (name) => {
|
|
1161
|
+
writeErr(desktopChildExitedLine(name));
|
|
1162
|
+
stopAll();
|
|
1163
|
+
return 1;
|
|
1164
|
+
};
|
|
375
1165
|
const exitedEarly = await Promise.race([
|
|
376
|
-
closed.then(() =>
|
|
1166
|
+
closed.then(() => ({ kind: "browser" })),
|
|
1167
|
+
childGone.then((name) => ({ kind: "child", name })),
|
|
377
1168
|
new Promise((resolve) => {
|
|
378
|
-
setTimeout(() => resolve(
|
|
1169
|
+
setTimeout(() => resolve({ kind: "stay" }), 3_000);
|
|
379
1170
|
}),
|
|
380
1171
|
]);
|
|
381
|
-
if (exitedEarly)
|
|
1172
|
+
if (exitedEarly.kind === "child")
|
|
1173
|
+
return childExit(exitedEarly.name);
|
|
1174
|
+
if (exitedEarly.kind === "browser") {
|
|
382
1175
|
writeErr("desktop-browser-exited-early\n");
|
|
383
|
-
process.removeListener("SIGINT", onSignal);
|
|
384
|
-
process.removeListener("SIGTERM", onSignal);
|
|
385
1176
|
stopAll();
|
|
386
1177
|
return 1;
|
|
387
1178
|
}
|
|
388
|
-
await
|
|
389
|
-
|
|
390
|
-
|
|
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);
|
|
391
1185
|
stopAll();
|
|
392
1186
|
return 0;
|
|
393
1187
|
}
|
|
@@ -397,6 +1191,40 @@ export async function runDesktop(opts, writeErr = (s) => process.stderr.write(s)
|
|
|
397
1191
|
writeErr(`desktop-failed:${msg}\n`);
|
|
398
1192
|
return 1;
|
|
399
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
|
+
};
|
|
400
1228
|
}
|
|
401
1229
|
export async function desktopMain(argv) {
|
|
402
1230
|
const parsed = parseDesktopArgs(argv);
|