@edgehero/pi-dispatch 5.0.0 → 5.1.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.
@@ -0,0 +1,372 @@
1
+ /**
2
+ * The console's remembered devices (issue #849): what lets a browser stay signed in across idle periods and console restarts.
3
+ *
4
+ * ONE FILE PER DEVICE, in `console-devices/` beside the tokens file (0700), named by the sha256 of the device's secret and holding that
5
+ * device's record (0600). Not one file with a list: the console writes during requests while the CLI writes too, and a list's
6
+ * compare-then-rename can lose a revocation between the two, which is the one write this store may never lose. It also means one bad record
7
+ * stops one device, not all of them.
8
+ *
9
+ * ONLY THE HASH IS ON DISK, so a copy of this directory signs nobody in. The secret exists in the browser's cookie and, once, in the
10
+ * process that made it.
11
+ *
12
+ * A RECORD IS NOT DELETED WHEN A DEVICE ENDS. It keeps `ended: {reason, at}` for `ENDED_RETENTION_MS`, because a browser presenting the
13
+ * cookie later has to be told the true reason (issue acceptance 6), including after a restart. A name keeps only its newest dead records (see
14
+ * `MAX_DEVICES_PER_NAME`), so a very busy name can lose an older reason sooner than that. A dead secret's hash is not sensitive, and the
15
+ * reason is shown only to whoever presents that cookie. A tombstone stops nothing for whoever can write this folder: they could remove `ended`,
16
+ * or plant a record outright. That boundary is the folder being owner-only and guarded, not the record's shape. REJECTED: a keyed MAC over each
17
+ * record, which would need a key kept beside what it protects.
18
+ *
19
+ * LAST USE IS THE FILE'S MTIME, set at most daily. A touch changes no content, so it can never overwrite the `ended` a revocation just wrote.
20
+ * REJECTED: a `lastUsedAt` field, which needs a read-modify-write on the hottest path, exactly the race the per-device file exists to remove.
21
+ *
22
+ * THREE ANSWERS, NOT TWO. `readDevice` says a record is here, is absent, or could not be judged. The third matters: a bad store must never
23
+ * end a session the way a revocation does, and must never pass one as live.
24
+ *
25
+ * WHAT MAY BE COPIED FROM `login-codes.mjs` AND WHAT MUST NOT. Its `dirProblem` idea is here, with an owner check it lacks. Its `take` unlinks
26
+ * on read and its `reap` deletes anything without a future `expiresAt`, which is every record in this directory. The fix sentence for an
27
+ * unsafe store is "remove", not `chmod`: tightening a directory another account could write would make whatever it planted good.
28
+ */
29
+ import * as nodeFs from "node:fs";
30
+ import { createHash, randomBytes } from "node:crypto";
31
+ import { basename, dirname, join } from "node:path";
32
+ import { TOKEN_NAME_RE } from "./console-tokens.mjs";
33
+
34
+ /** The directory's name, beside the tokens file. */
35
+ export const CONSOLE_DEVICES_DIRNAME = "console-devices";
36
+
37
+ /** The roles a device may be remembered for. An allowlist, so a role added later is not remembered until someone decides it should be. */
38
+ export const REMEMBER_ROLES = Object.freeze(["operator", "viewer"]);
39
+
40
+ /** How long a dead record is kept to say why. */
41
+ export const ENDED_RETENTION_MS = 90 * 24 * 3600 * 1000;
42
+
43
+ /**
44
+ * The most devices one token may have remembered, and the most dead records kept for it: a remember-then-end loop would otherwise grow this folder
45
+ * without bound, and every remember scans it. Losing an old dead record costs only the sentence saying why.
46
+ */
47
+ export const MAX_DEVICES_PER_NAME = 32;
48
+
49
+ /** How often last-use is written. A day is enough for "last used" to mean something, and keeps a request's work to a read. */
50
+ export const TOUCH_INTERVAL_MS = 24 * 3600 * 1000;
51
+
52
+ /** The most a record can be. Far above a real one, and it stops a planted file being read whole on every listing. */
53
+ const RECORD_MAX_BYTES = 64 * 1024;
54
+
55
+ /** How old a leftover temporary file must be before it is removed: older than any write in progress. */
56
+ const STALE_TEMP_MS = 3600 * 1000;
57
+
58
+ /** Why a device stopped being trusted. Each one is a reason the console can word for the person holding the cookie. */
59
+ export const DEVICE_END_REASONS = Object.freeze(["signed-out", "removed", "revoked", "role", "all", "replaced"]);
60
+
61
+ /** The closed word list a label is built from: never User-Agent bytes, which are attacker-chosen and would end up on a page. */
62
+ export const DEVICE_BROWSERS = Object.freeze(["Chrome", "Edge", "Firefox", "Safari", "Browser"]);
63
+ export const DEVICE_SYSTEMS = Object.freeze(["macOS", "Windows", "Linux", "iPhone", "iPad", "Android", "this system"]);
64
+
65
+ const HASH_RE = /^[0-9a-f]{64}$/;
66
+ // Either case: on a case-insensitive volume an uppercase name is the same file a lowercase hash opens, so a listing must see it (and report it).
67
+ const RECORD_FILE_RE = /^[0-9a-fA-F]{64}\.json$/;
68
+ const TEMP_FILE_RE = /^\.[0-9a-f]{64}\.[0-9a-f]{16}\.tmp$/;
69
+ const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
70
+ export const DEVICE_SECRET_RE = /^[A-Za-z0-9_-]{43}$/;
71
+
72
+ const errCode = (e) => (typeof e?.code === "string" ? e.code : "error");
73
+
74
+ /** A shape alone admits `2026-99-99T99:99:99.999Z`, which reads as NaN and would look very old to a sweep. */
75
+ const isoIsReal = (text) => typeof text === "string" && ISO_RE.test(text) && Number.isFinite(Date.parse(text)) && new Date(text).toISOString() === text;
76
+
77
+ export function consoleDevicesDir(tokensPath) {
78
+ return join(dirname(tokensPath), CONSOLE_DEVICES_DIRNAME);
79
+ }
80
+
81
+ /**
82
+ * The cookie's name for this deployment. Cookies ignore ports, so two deployments on one loopback address would otherwise overwrite each
83
+ * other's remembered device, and the person would simply find they had to sign in again with nothing to say why.
84
+ */
85
+ export function deviceCookieName(tokensPath) {
86
+ return `pdc_device_${createHash("sha256").update(String(tokensPath)).digest("hex").slice(0, 10)}`;
87
+ }
88
+
89
+ export function newDeviceSecret(random = randomBytes) {
90
+ return random(32).toString("base64url");
91
+ }
92
+
93
+ export function hashDeviceSecret(secret) {
94
+ return createHash("sha256").update(String(secret)).digest("hex");
95
+ }
96
+
97
+ /** What a list shows and a command takes: enough to tell devices apart, and useless for finding a record's secret. */
98
+ export function deviceIdOf(hash) {
99
+ return String(hash).slice(0, 12);
100
+ }
101
+
102
+ /** A label from closed word lists, for telling devices apart on a page. Anything unrecognised is the plain fallback, never the header's bytes. */
103
+ export function deviceLabelOf(userAgent) {
104
+ const ua = typeof userAgent === "string" ? userAgent : "";
105
+ const browser = /Edg\//.test(ua) ? "Edge" : /Firefox\//.test(ua) ? "Firefox" : /Chrome\//.test(ua) ? "Chrome" : /Safari\//.test(ua) ? "Safari" : "Browser";
106
+ const system = /iPhone/.test(ua) ? "iPhone" : /iPad/.test(ua) ? "iPad" : /Android/.test(ua) ? "Android" : /Mac OS X/.test(ua) ? "macOS" : /Windows/.test(ua) ? "Windows" : /Linux/.test(ua) ? "Linux" : "this system";
107
+ return `${browser} on ${system}`;
108
+ }
109
+
110
+ const labelIsClosed = (label) => typeof label === "string" && DEVICE_BROWSERS.some((b) => DEVICE_SYSTEMS.some((s) => label === `${b} on ${s}`));
111
+
112
+ function validRecord(r) {
113
+ if (r === null || typeof r !== "object" || Array.isArray(r)) return false;
114
+ if (r.version !== 1 || typeof r.name !== "string" || !TOKEN_NAME_RE.test(r.name) || !REMEMBER_ROLES.includes(r.role)) return false;
115
+ if (typeof r.tokenHash !== "string" || !HASH_RE.test(r.tokenHash) || !Number.isSafeInteger(r.generation) || r.generation < 0) return false;
116
+ if (!isoIsReal(r.createdAt) || !labelIsClosed(r.label)) return false;
117
+ if (r.ended !== undefined) {
118
+ if (r.ended === null || typeof r.ended !== "object" || !DEVICE_END_REASONS.includes(r.ended.reason) || !isoIsReal(r.ended.at)) return false;
119
+ }
120
+ return true;
121
+ }
122
+
123
+ /**
124
+ * What is wrong with the directory, or null: a symlink, anything but a directory, any group or world bit, or another account's. With `create`,
125
+ * a missing one is made 0700, chmod'd exactly since mkdir's mode is masked by the umask. `{absent: true}` for a missing one without `create`.
126
+ */
127
+ export function devicesDirProblem(dir, { fs = nodeFs, uid = process.geteuid?.(), create = false, platform = process.platform } = {}) {
128
+ // Node reports group and other bits on every Windows folder, so the mode check below could only ever refuse, and a folder made first would
129
+ // then be one doctor fails on from that day on. Said once, here, and nothing is made.
130
+ if (platform === "win32" && create) return { problem: "remembering a device does not work on a Windows host, because Windows reports every folder as open to other accounts; paste a token instead" };
131
+ let st;
132
+ try {
133
+ st = fs.lstatSync(dir);
134
+ } catch (e) {
135
+ if (e?.code !== "ENOENT") return { problem: `the remembered-device folder ${dir} could not be inspected (${errCode(e)}); check its parent's permissions` };
136
+ if (!create) return { absent: true };
137
+ try {
138
+ // Tolerated: two first remembers racing is no fault, and the checks below judge whichever folder won.
139
+ try { fs.mkdirSync(dir, { mode: 0o700 }); fs.chmodSync(dir, 0o700); }
140
+ catch (e2) { if (e2?.code !== "EEXIST") throw e2; }
141
+ st = fs.lstatSync(dir);
142
+ } catch (e2) {
143
+ return { problem: `the remembered-device folder ${dir} could not be created (${errCode(e2)}); check that the tokens file's folder exists and belongs to this account` };
144
+ }
145
+ }
146
+ const remove = `remove the folder (\`rm -r ${JSON.stringify(dir)}\`); every browser then signs in again`;
147
+ if (st.isSymbolicLink()) return { problem: `the remembered-device folder ${dir} is a symbolic link, and the console uses only a real folder; ${remove}` };
148
+ if (!st.isDirectory()) return { problem: `${dir} is not a folder; ${remove}` };
149
+ if (uid !== undefined && st.uid !== uid) return { problem: `the remembered-device folder ${dir} belongs to another account, so what it holds cannot be trusted; ${remove}` };
150
+ if ((st.mode & 0o077) !== 0) return { problem: `the remembered-device folder ${dir} can be reached by other accounts (mode ${(st.mode & 0o777).toString(8).padStart(3, "0")}), so what it holds cannot be trusted; ${remove}` };
151
+ // Readable but not writable would pass every read and then fail the one write that must work, a revocation.
152
+ if ((st.mode & 0o700) !== 0o700) return { problem: `the remembered-device folder ${dir} cannot be written by its owner (mode ${(st.mode & 0o777).toString(8).padStart(3, "0")}), so a device could not be removed; restore it with \`chmod u+rwx ${JSON.stringify(dir)}\`, which is safe here because it gives nobody else anything` };
153
+ return null;
154
+ }
155
+
156
+ /** The record at `path`, read through one no-follow descriptor: `{record, mtimeMs}`, `{absent}` or `{unjudged}`. */
157
+ function readRecordFile(path, { fs, uid }) {
158
+ let fd;
159
+ try {
160
+ fd = fs.openSync(path, nodeFs.constants.O_RDONLY | nodeFs.constants.O_NOFOLLOW | nodeFs.constants.O_NONBLOCK);
161
+ } catch (e) {
162
+ if (e?.code === "ENOENT") return { absent: true };
163
+ return { unjudged: `the record could not be opened (${errCode(e)})` };
164
+ }
165
+ try {
166
+ const st = fs.fstatSync(fd);
167
+ if (!st.isFile()) return { unjudged: "the record is not a regular file" };
168
+ if (uid !== undefined && st.uid !== uid) return { unjudged: "the record belongs to another account" };
169
+ if ((st.mode & 0o077) !== 0) return { unjudged: "the record can be reached by other accounts" };
170
+ // A second name could sit anywhere and be edited there. Backups that hard-link fail closed, which is the right way to fail.
171
+ // `> 1`, not `!== 1`: a file just renamed over reads 0 to whoever still holds it, and those bytes are whole.
172
+ if (st.nlink > 1) return { unjudged: "the record has more than one name" };
173
+ if (st.size > RECORD_MAX_BYTES) return { unjudged: "the record is too large to be one of this console's" };
174
+ let record;
175
+ try { record = JSON.parse(fs.readFileSync(fd, "utf8")); } catch { return { unjudged: "the record is not valid JSON" }; }
176
+ if (!validRecord(record)) return { unjudged: "the record is not one this console can read" };
177
+ return { record, mtimeMs: st.mtimeMs };
178
+ } catch (e) {
179
+ return { unjudged: `the record could not be read (${errCode(e)})` };
180
+ } finally {
181
+ try { fs.closeSync(fd); } catch { /* the answer above stands */ }
182
+ }
183
+ }
184
+
185
+ /**
186
+ * One device, by the hash of its secret: `{record, mtimeMs}`, `{absent: true}` when the store holds no such device, or `{unjudged}` when
187
+ * nothing can be said (the folder gone or unsafe, the record corrupt or unsafe). A missing folder is unjudged rather than absent on purpose:
188
+ * a repointed `PI_CONSOLE_TOKENS_FILE` must not read as every device having been revoked.
189
+ */
190
+ export function readDevice({ dir, hash, fs = nodeFs, uid = process.geteuid?.() }) {
191
+ if (typeof hash !== "string" || !HASH_RE.test(hash)) return { absent: true };
192
+ const problem = devicesDirProblem(dir, { fs, uid });
193
+ if (problem?.absent) return { unjudged: "the remembered-device folder does not exist" };
194
+ if (problem) return { unjudged: problem.problem };
195
+ const path = join(dir, `${hash}.json`);
196
+ /** On a case-insensitive volume an uppercase name opens as this one, so the name on disk has to be looked at too, whichever read found it. */
197
+ const named = (found) => {
198
+ if (found.record === undefined) return found;
199
+ try {
200
+ const onDisk = (fs.realpathSync?.native ?? fs.realpathSync)?.(path);
201
+ return typeof onDisk !== "string" || basename(onDisk) === `${hash}.json` ? found : { unjudged: "the record's name is not one this console writes" };
202
+ } catch (e) {
203
+ return { unjudged: `the record could not be looked at (${errCode(e)})` };
204
+ }
205
+ };
206
+ const read = readRecordFile(path, { fs, uid });
207
+ if (!read.absent) return named(read);
208
+ // A folder that went away between the check and the open says nothing about this device, and must not read as a revocation.
209
+ const after = devicesDirProblem(dir, { fs, uid });
210
+ if (after !== null) return { unjudged: after.problem ?? "the remembered-device folder does not exist" };
211
+ // Looked at once more: a folder that left and came back around the open would otherwise read as this device being gone.
212
+ return named(readRecordFile(path, { fs, uid }));
213
+ }
214
+
215
+ /** Write `record` for `hash` so a crash leaves the old bytes or the new ones, never half of either: a private temporary, fsync, rename, fsync of the folder. */
216
+ function writeRecord({ dir, hash, record, fs, random }) {
217
+ const temp = join(dir, `.${hash}.${random(8).toString("hex")}.tmp`);
218
+ const fd = fs.openSync(temp, "wx", 0o600);
219
+ try {
220
+ try {
221
+ fs.writeFileSync(fd, `${JSON.stringify(record)}\n`);
222
+ fs.fsyncSync(fd);
223
+ } finally {
224
+ fs.closeSync(fd);
225
+ }
226
+ } catch (e) {
227
+ try { fs.unlinkSync(temp); } catch { /* it may be gone already */ }
228
+ throw e;
229
+ }
230
+ try {
231
+ // The create's mode is masked by the umask; set it exactly.
232
+ fs.chmodSync(temp, 0o600);
233
+ fs.renameSync(temp, join(dir, `${hash}.json`));
234
+ } catch (e) {
235
+ try { fs.unlinkSync(temp); } catch { /* it may be gone already */ }
236
+ throw e;
237
+ }
238
+ const dirFd = fs.openSync(dir, "r");
239
+ try { fs.fsyncSync(dirFd); } finally { fs.closeSync(dirFd); }
240
+ }
241
+
242
+ /** Every record file here: `{records: [{hash, record?, mtimeMs?, unjudged?}]}`, read without stopping at a bad one. */
243
+ function allRecords(dir, { fs, uid }) {
244
+ return fs.readdirSync(dir).filter((name) => RECORD_FILE_RE.test(name)).map((name) => {
245
+ const hash = name.slice(0, -".json".length);
246
+ if (hash !== hash.toLowerCase()) return { hash, unjudged: "the record's name is not one this console writes" };
247
+ return { hash, ...readRecordFile(join(dir, name), { fs, uid }) };
248
+ });
249
+ }
250
+
251
+ /**
252
+ * Remember a device: `{secret, hash, record}`, or `{refusal}` saying what stands in the way. The binding (`name`, `role`, `tokenHash`, `generation`) is
253
+ * what lets every existing way of ending a sign-in also end this: revoke and rotate change the hash, a role change the role, `sessions end-all` the
254
+ * generation. The caller judges a record against the tokens file at each use. `tokenCreatedAt` is the entry's own `createdAt`: a device older than the
255
+ * entry cannot belong to it, which is the only way to order a hash, so a caller holding a stale entry cannot end a newer device.
256
+ */
257
+ export function rememberDevice({ dir, name, role, tokenHash, generation, tokenCreatedAt = null, label = "Browser on this system", fs = nodeFs, uid = process.geteuid?.(), now = Date.now, random = randomBytes }) {
258
+ if (!REMEMBER_ROLES.includes(role)) return { refusal: `a ${String(role).slice(0, 32)} token cannot be remembered on a device` };
259
+ if (typeof name !== "string" || !TOKEN_NAME_RE.test(name) || typeof tokenHash !== "string" || !HASH_RE.test(tokenHash) || !Number.isSafeInteger(generation) || generation < 0) return { refusal: "this sign-in cannot be remembered: its token entry is not one the store accepts" };
260
+ const problem = devicesDirProblem(dir, { fs, uid, create: true });
261
+ if (problem) return { refusal: problem.problem };
262
+ try {
263
+ const mine = allRecords(dir, { fs, uid }).filter((row) => row.record?.name === name);
264
+ // A device bound to a token entry that has since been rotated or ended can never sign in again, so it holds no place under the cap and ages out.
265
+ for (const row of mine) {
266
+ if (row.record.ended !== undefined) continue;
267
+ // The true reason, since this is what the browser holding that cookie will be told. Only ever downward: an entry that may be stale must not end a newer device.
268
+ const reason = row.record.generation < generation ? "all" : row.record.role !== role && row.record.generation === generation && row.record.tokenHash === tokenHash ? "role" : row.record.tokenHash !== tokenHash && typeof tokenCreatedAt === "string" && row.record.createdAt < tokenCreatedAt ? "revoked" : null;
269
+ if (reason === null) continue;
270
+ if (endDevice({ dir, hash: row.hash, reason, fs, uid, now, random }).ok) row.record.ended = { reason, at: new Date(now()).toISOString() };
271
+ }
272
+ const live = mine.filter((row) => row.record.ended === undefined).length;
273
+ // `full` lets a page say what a person can do about it (remove one), where every other refusal is the operator's to fix.
274
+ if (live >= MAX_DEVICES_PER_NAME) return { refusal: `"${name}" already has ${live} remembered devices, the most one sign-in may have; remove one first`, full: true };
275
+ const dead = mine.filter((row) => row.record.ended !== undefined).sort((a, b) => Date.parse(b.record.ended.at) - Date.parse(a.record.ended.at));
276
+ for (const row of dead.slice(MAX_DEVICES_PER_NAME - 1)) {
277
+ try { fs.unlinkSync(join(dir, `${row.hash}.json`)); } catch { /* the next remember */ }
278
+ }
279
+ const secret = newDeviceSecret(random);
280
+ const hash = hashDeviceSecret(secret);
281
+ const record = { version: 1, name, role, tokenHash, generation, createdAt: new Date(now()).toISOString(), label: labelIsClosed(label) ? label : "Browser on this system" };
282
+ writeRecord({ dir, hash, record, fs, random });
283
+ return { secret, hash, record };
284
+ } catch (e) {
285
+ return { refusal: `this device could not be remembered (${errCode(e)}); check the folder ${dir}` };
286
+ }
287
+ }
288
+
289
+ /**
290
+ * End a device, durably: `{ok: true}`, `{ok: true, already: true}` for one already ended, `{absent: true}` for none, or `{problem}`. Rewrites the
291
+ * record rather than removing it (see the header). Sequential ends keep the first reason. Ends that truly race both answer `ok` and one reason stays:
292
+ * the device is ended either way, and only the sentence shown can differ. REJECTED: an exclusive claim to close that, since it would add a way for a
293
+ * revocation to fail in order to protect a sentence.
294
+ */
295
+ export function endDevice({ dir, hash, reason, fs = nodeFs, uid = process.geteuid?.(), now = Date.now, random = randomBytes }) {
296
+ if (!DEVICE_END_REASONS.includes(reason)) throw new RangeError(`endDevice: a reason is one of ${DEVICE_END_REASONS.join(", ")}, not ${JSON.stringify(reason)}`);
297
+ const read = readDevice({ dir, hash, fs, uid });
298
+ if (read.absent) return { absent: true };
299
+ if (read.unjudged) return { problem: read.unjudged };
300
+ if (read.record.ended !== undefined) return { ok: true, already: true };
301
+ try {
302
+ const { mtimeMs } = read;
303
+ writeRecord({ dir, hash, record: { ...read.record, ended: { reason, at: new Date(now()).toISOString() } }, fs, random });
304
+ // Last use is the file's mtime, and the rename above has just reset it to the moment of the end.
305
+ try { fs.utimesSync(join(dir, `${hash}.json`), mtimeMs / 1000, mtimeMs / 1000); } catch { /* the end is durable, and that was only its last-used time */ }
306
+ return { ok: true };
307
+ } catch (e) {
308
+ return { problem: `the device could not be ended (${errCode(e)})` };
309
+ }
310
+ }
311
+
312
+ /** Move a device's last-use forward, at most daily. Best effort and changes no content, so it can be lost freely and can never lose an end. */
313
+ export function touchDevice({ dir, hash, mtimeMs, fs = nodeFs, now = Date.now }) {
314
+ if (typeof hash !== "string" || !HASH_RE.test(hash)) return false;
315
+ if (typeof mtimeMs === "number" && now() - mtimeMs < TOUCH_INTERVAL_MS) return false;
316
+ try {
317
+ const seconds = now() / 1000;
318
+ // `lutimes`: a link planted at the name must not have some other file's time moved.
319
+ fs.lutimesSync(join(dir, `${hash}.json`), seconds, seconds);
320
+ return true;
321
+ } catch {
322
+ return false;
323
+ }
324
+ }
325
+
326
+ /** Every device: `{devices, unreadable}` (newest first), or `{problem}`. A missing folder is simply no devices here: this is a listing, not a judgement. */
327
+ export function listDevices({ dir, fs = nodeFs, uid = process.geteuid?.() }) {
328
+ const problem = devicesDirProblem(dir, { fs, uid });
329
+ if (problem?.absent) return { devices: [], unreadable: 0, unreadableIds: [] };
330
+ if (problem) return { problem: problem.problem };
331
+ try {
332
+ const rows = allRecords(dir, { fs, uid });
333
+ const devices = rows.filter((row) => row.record).map((row) => ({ id: deviceIdOf(row.hash), hash: row.hash, name: row.record.name, role: row.record.role, label: row.record.label, createdAt: row.record.createdAt, lastUsedAt: new Date(row.mtimeMs).toISOString(), ...(row.record.ended ? { ended: { reason: row.record.ended.reason, at: row.record.ended.at } } : {}) }));
334
+ devices.sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0));
335
+ const bad = rows.filter((row) => row.unjudged);
336
+ return { devices, unreadable: bad.length, unreadableIds: bad.map((row) => deviceIdOf(row.hash)) };
337
+ } catch (e) {
338
+ return { problem: `the remembered-device folder ${dir} could not be read (${errCode(e)})` };
339
+ }
340
+ }
341
+
342
+ /** Remove dead records past retention and temporaries a crash left. Nothing it cannot read and nothing live: only the operator decides those. Returns how many went. */
343
+ export function reapDevices({ dir, fs = nodeFs, uid = process.geteuid?.(), now = Date.now }) {
344
+ const problem = devicesDirProblem(dir, { fs, uid });
345
+ if (problem) return 0;
346
+ let removed = 0;
347
+ const t = now();
348
+ try {
349
+ for (const row of allRecords(dir, { fs, uid })) {
350
+ if (row.record?.ended === undefined || !(t - Date.parse(row.record.ended.at) > ENDED_RETENTION_MS)) continue;
351
+ try { fs.unlinkSync(join(dir, `${row.hash}.json`)); removed += 1; } catch { /* the next sweep */ }
352
+ }
353
+ for (const name of fs.readdirSync(dir)) {
354
+ if (!TEMP_FILE_RE.test(name)) continue;
355
+ try {
356
+ const path = join(dir, name);
357
+ if (t - fs.lstatSync(path).mtimeMs > STALE_TEMP_MS) { fs.unlinkSync(path); removed += 1; }
358
+ } catch { /* the next sweep */ }
359
+ }
360
+ } catch { /* a folder that went away has nothing left to sweep */ }
361
+ return removed;
362
+ }
363
+
364
+ /** What doctor says of the store: `{absent}`, `{problem}`, or `{live, unreadable}`. */
365
+ export function devicesState({ dir, fs = nodeFs, uid = process.geteuid?.(), platform = process.platform }) {
366
+ const problem = devicesDirProblem(dir, { fs, uid, platform });
367
+ if (problem?.absent) return { absent: true };
368
+ if (problem) return { problem: problem.problem };
369
+ const listed = listDevices({ dir, fs, uid });
370
+ if (listed.problem) return { problem: listed.problem };
371
+ return { live: listed.devices.filter((d) => d.ended === undefined).length, unreadable: listed.unreadable, unreadableIds: listed.unreadableIds };
372
+ }
@@ -0,0 +1,239 @@
1
+ /**
2
+ * `npx @edgehero/pi-dispatch up --console` in an empty folder (issue #849): turn three install lines into one.
3
+ *
4
+ * A worker running from npm's execution cache can never become a service: the cache is disposable, so a unit pointing into it would
5
+ * break whenever npm prunes it (`setup-host-entry.mjs`). That refusal stays. What changes is the cache path's answer: rather than tell
6
+ * the person to run three more commands, it runs them, with the same exact pins the README and the wizard use, and hands over to the
7
+ * binary it just installed in the folder. Everything after the handover is the ordinary durable path, so nothing the cache run did can
8
+ * become a service.
9
+ *
10
+ * ORDER IS THE CONTRACT. Every refusal that needs nothing installed runs first, then the one question, and only then does anything
11
+ * touch the folder, so a refusal leaves no `node_modules` (`REQ-BROWSER-FIRST-RUN-SETUP`).
12
+ *
13
+ * THE INSTALL HAPPENS IN A STAGING FOLDER and is moved into place only after npm exits 0. npx looks in the current folder first: if a
14
+ * stopped install left the worker package here without its bin, npx would try to run a bin that does not exist, this code would never
15
+ * start, and the same command could never recover. REJECTED: cleaning up after a non-zero exit, which a Ctrl-C or the timeout's SIGKILL
16
+ * never reaches.
17
+ *
18
+ * THE ENVIRONMENT IS CLEANED, not inherited. npx exports `npm_config_*` values of its own run (the package it fetched, its prefix) and
19
+ * puts its cache's `.bin`, every ancestor folder's `node_modules/.bin` and npm's `node-gyp-bin` first on PATH. An install or a service
20
+ * that inherited those would be describing the cache, and a folder's `.bin` on PATH would let a planted `launchctl` run before the person
21
+ * has answered anything. A registry or proxy the person set themselves is not on the list and survives.
22
+ */
23
+ import * as nodeFs from "node:fs";
24
+ import { dirname, join } from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+ import { installedUnitPaths } from "./service.mjs";
27
+ import { setupCachePath, trustedSetupFile } from "./setup-host-entry.mjs";
28
+ import { askFirstRun, attachedCommand, commandsHere, deploymentPreflight, ensureConsoleShortcut, flagValue, newOperatorNameProblem, operatorChoiceProblem, serviceManagerAnswers, tightenedUmask } from "./console-bootstrap.mjs";
29
+ import { VALKEY_PASSWORD_KEY } from "./valkey-auth.mjs";
30
+ import { EXACT_VERSION_RE } from "./package-names.mjs";
31
+ import { readUnitSeam } from "./service.mjs";
32
+
33
+ /** How long the install may take. A bound, so a stalled registry ends in a refusal rather than a terminal that hangs forever. */
34
+ export const INSTALL_TIMEOUT_MS = 10 * 60_000;
35
+
36
+ /** The name of the private `package.json`, which also marks it as ours. */
37
+ const DEPLOYMENT_PACKAGE_NAME = "pi-dispatch-deployment";
38
+
39
+ /** Where the install is built before it is moved into place. What a stopped install left here is removed before the next one. */
40
+ const STAGING_DIR = ".pi-dispatch-install";
41
+
42
+ /** Names a folder may hold and still count as empty: files that say nothing about what the folder is for. */
43
+ const IGNORABLE = new Set([".DS_Store", ".git", ".gitignore", STAGING_DIR]);
44
+
45
+ /**
46
+ * What a folder may hold and still be a deployment: what `init` scaffolds (a test holds this to a real run of `init`) and what the
47
+ * console and this tooling add there. Anything else is someone's project, whatever else sits beside it.
48
+ */
49
+ export const DEPLOYMENT_FILES = new Set([".env", "triggers.json", "pause-windows.json", "pi-packages.json", "subscriptions.json", "scoped-limits.json", "projects.json", "egress-allowlist.conf", "model-endpoints.conf", "model-endpoints.json", "deploy", "console-tokens.json", "console-login-codes", "console-devices", "pi-dispatch", ".pi-dispatch-setup.lock"]);
50
+
51
+ /** What only npx puts in the environment. An allowlist of npm's own exports, so a person's `npm_config_registry` is left alone. */
52
+ const NPX_ENV_RE = /^(?:INIT_CWD|NODE|npm_(?:command|execpath|node_execpath|lifecycle_\w+|package_\w+|config_(?:call|cache|global_prefix|globalconfig|ignore_scripts|init_module|local_prefix|node_gyp|noproxy|npm_version|package|prefix|user_agent|userconfig|yes)))$/;
53
+
54
+ /** This package's own version and the exact pi it was built against, read from its own `package.json`, never from a flag. */
55
+ export function ownPackageVersions({ path = fileURLToPath(new URL("../package.json", import.meta.url)), fs = nodeFs } = {}) {
56
+ const pkg = JSON.parse(fs.readFileSync(path, "utf8"));
57
+ return { version: pkg.version, piVersion: pkg.dependencies?.["@earendil-works/pi-ai"] };
58
+ }
59
+
60
+ /**
61
+ * The npm argv for the durable install. The same packages and flags as the wizard's `browserInstallArgs` (the admin workspace's test
62
+ * holds the two equal), written out here because the worker cannot import from the admin package it is about to install.
63
+ */
64
+ export function selfInstallArgs({ version, piVersion }) {
65
+ return ["install", `@edgehero/pi-dispatch-admin@${version}`, `@earendil-works/pi-coding-agent@${piVersion}`, "--save-exact", `@edgehero/pi-dispatch@${version}`, "--omit=dev", "--omit=optional", "--ignore-scripts", "--no-audit", "--no-fund", "--loglevel=error"];
66
+ }
67
+
68
+ /** Is this PATH entry something npx put there? Narrower than `setupCachePath`, so a person's own `~/.cache/foo/bin` stays. */
69
+ const npxPathEntry = (part) => /(?:^|[/\\])_npx(?:[/\\]|$)/.test(part) || /(?:^|[/\\])node_modules[/\\]\.bin[/\\]?$/.test(part) || /[/\\]node-gyp-bin[/\\]?$/.test(part);
70
+
71
+ /** `env` without what npx added: its exports, and what it put on PATH. */
72
+ export function cleanedNpxEnv(env) {
73
+ const out = {};
74
+ for (const [key, value] of Object.entries(env)) if (!NPX_ENV_RE.test(key)) out[key] = value;
75
+ // env-internal PATH: an environment passed through, with only what npx added taken out.
76
+ if (typeof env.PATH === "string") out.PATH = env.PATH.split(":").filter((part) => !npxPathEntry(part)).join(":");
77
+ return out;
78
+ }
79
+
80
+ /**
81
+ * A private root `package.json`, so npm cannot settle on some project higher up the tree (the wizard's own guard). `wx`: a name that is
82
+ * a dangling link was missed by the folder check's `exists`, and must not be written through.
83
+ */
84
+ function ensureDeploymentPackageJson(fs, dir) {
85
+ const path = join(dir, "package.json");
86
+ if (!fs.existsSync(path)) fs.writeFileSync(path, `${JSON.stringify({ name: DEPLOYMENT_PACKAGE_NAME, private: true }, null, 2)}\n`, { flag: "wx" });
87
+ }
88
+
89
+ /** Is there a symlink here, even one that points nowhere? `existsSync` follows the link and says no to a dangling one. */
90
+ function isLink(fs, path) {
91
+ try { return fs.lstatSync(path).isSymbolicLink(); } catch { return false; }
92
+ }
93
+
94
+ /** Do the folder and every parent belong to this account or root, and are they closed to others? `trustedSetupFile` asks this after the install; asking it now means a refusal leaves nothing behind. */
95
+ function folderChainTrusted(fs, dir, uid) {
96
+ for (let parent = dirname(dir); ; parent = dirname(parent)) {
97
+ const st = fs.statSync(parent);
98
+ if (!st.isDirectory() || (st.uid !== uid && st.uid !== 0) || ((st.mode & 0o022) && !(st.mode & 0o1000))) return false;
99
+ if (parent === dirname(parent)) return true;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Is the `package.json` here exactly what this tooling writes? A failed install leaves it behind, and a re-run must not be refused for it.
105
+ * The exact keys matter: `overrides` or `dependencies` under our name would redirect what the pinned install resolves.
106
+ */
107
+ function ourPackageJson(fs, dir) {
108
+ try {
109
+ const pkg = JSON.parse(fs.readFileSync(join(dir, "package.json"), "utf8"));
110
+ return Object.keys(pkg).sort().join(",") === "name,private" && pkg.name === DEPLOYMENT_PACKAGE_NAME && pkg.private === true;
111
+ } catch { return false; }
112
+ }
113
+
114
+ /** Does `.env` hold a key `init` writes? `.env` alone is in every other web project, and is no evidence of a deployment. */
115
+ function initWroteEnv(fs, dir) {
116
+ try { return new RegExp(`^${VALKEY_PASSWORD_KEY}=`, "m").test(fs.readFileSync(join(dir, ".env"), "utf8")); } catch { return false; }
117
+ }
118
+
119
+ /** Is this folder empty, or already a deployment? Anything else is someone's project, and installing into it would be a surprise. */
120
+ function folderProblem(fs, dir, home, uid) {
121
+ let isHome = false;
122
+ try { isHome = dir === fs.realpathSync(home); } catch { /* an unreadable home is simply not this folder */ }
123
+ if (isHome) return "this is your home folder. Make a new folder for the deployment and run this there.";
124
+ if (setupCachePath(dir)) return "this folder is inside a cache, which can be emptied at any time. Make a folder somewhere that will stay and run this there.";
125
+ if (!folderChainTrusted(fs, dir, uid)) return "a folder above this one can be written by another account, so what is installed here could not be trusted. Make a folder somewhere only you can write and run this there.";
126
+ // The real path, so a `.bin` planted beside our own package.json counts as the leftover it is, not as an installation to send someone to.
127
+ let installed = false;
128
+ try { installed = fs.realpathSync(join(dir, "node_modules", ".bin", "pi-dispatch")) === join(dir, "node_modules", "@edgehero", "pi-dispatch", "src", "cli.mjs"); } catch { /* no bin, so no installation */ }
129
+ if (installed) return "this folder already has pi-dispatch installed. Run its own: ./node_modules/.bin/pi-dispatch up --console";
130
+ const ours = ourPackageJson(fs, dir);
131
+ const names = fs.readdirSync(dir).filter((name) => !IGNORABLE.has(name) && !(ours && (name === "package.json" || name === "node_modules")));
132
+ if (names.length === 0) return null;
133
+ const foreign = names.find((name) => !DEPLOYMENT_FILES.has(name));
134
+ const isDeployment = names.includes("triggers.json") || (names.includes(".env") && initWroteEnv(fs, dir));
135
+ if (foreign !== undefined || !isDeployment) return `this folder already holds other files (${foreign ?? names.join(", ")}) and is not a deployment. Make a new empty folder and run this there.`;
136
+ return null;
137
+ }
138
+
139
+ export async function runSelfInstall(argv, deps) {
140
+ const { fs, env, cwd, platform, euid: uid, home, out, prompt, consoleCommand: run, refuse } = deps;
141
+ let own;
142
+ try { own = (deps.ownPackage ?? ownPackageVersions)(); } catch { return refuse("this package's own version could not be read, so nothing can be pinned; nothing was changed."); }
143
+ if (!EXACT_VERSION_RE.test(String(own.version)) || !EXACT_VERSION_RE.test(String(own.piVersion))) return refuse("this package does not name exact versions to install, so nothing can be pinned; nothing was changed.");
144
+
145
+ // The folder before the preflight's mode check, so a folder that holds someone's project under a loose umask is told that, not about its mode.
146
+ // Only on a platform that can run this: the platform refusal below is the better sentence on the others.
147
+ if (["linux", "darwin"].includes(platform)) {
148
+ let problem;
149
+ try { problem = folderProblem(fs, fs.realpathSync(cwd), home, uid); } catch { return refuse("the deployment folder could not be read; nothing was changed."); }
150
+ if (problem) return refuse(problem);
151
+ }
152
+ const pre = deploymentPreflight({ fs, env, cwd, platform, uid, refuse, consoleTokensPath: deps.consoleTokensPath, readTokens: deps.readTokens, looseEmptyIgnoring: IGNORABLE });
153
+ if (pre.refused !== undefined) return pre.refused;
154
+ const { dir, existing } = pre;
155
+ // Free, and the installed binary would refuse it after the install otherwise.
156
+ const operators = existing?.tokens.filter((entry) => entry.role === "operator") ?? [];
157
+ const choice = operatorChoiceProblem(flagValue(argv, "--name"), operators);
158
+ if (choice) return refuse(choice);
159
+ if (operators.length > 1 && flagValue(argv, "--name") === null && !(deps.isTTY ?? Boolean(process.stdin.isTTY))) return refuse("there are several operators and no terminal to ask on, so name one with --name; nothing was changed.");
160
+ if (operators.length === 0) {
161
+ const nameProblem = newOperatorNameProblem(flagValue(argv, "--name") ?? "operator", existing);
162
+ if (nameProblem) return refuse(nameProblem);
163
+ }
164
+ // A console unit is account-wide. The installed binary would refuse a foreign one after the install; refusing here leaves no node_modules.
165
+ const unit = installedUnitPaths(platform, home).find((row) => row.which === "console" && fs.existsSync(row.path));
166
+ if (unit) {
167
+ let owner = null;
168
+ try { owner = readUnitSeam(fs.readFileSync(unit.path, "utf8"), platform).deployDir; } catch { /* the path alone still says which service */ }
169
+ const there = owner ? commandsHere(owner, fs, { from: dir }).worker : "pi-dispatch";
170
+ return refuse(`this account already has a console service (${unit.path})${owner ? `, for ${owner}` : ""}. Run ${there} up --console for that deployment, or ${there} service uninstall --console there first; nothing was changed.`);
171
+ }
172
+ // The cleaned PATH: a planted `launchctl` in some folder's `node_modules/.bin` must not run, and this probe comes before any answer.
173
+ const clean = cleanedNpxEnv(env);
174
+ if (!(await serviceManagerAnswers({ run, platform, uid, dir, env: clean }))) return refuse("the account's user service manager did not answer. Start it before console bootstrap.");
175
+
176
+ const installLine = `npm ${selfInstallArgs(own).join(" ")}`;
177
+ // One question only for a new deployment, as in `runConsoleUp`: on one in use, `--yes` can restart a live queue.
178
+ const isNew = !fs.existsSync(join(dir, ".env"));
179
+ const decision = argv.includes("--yes") ? "yes" : argv.includes("--ask-each") || !isNew ? "each" : await askFirstRun({ dir, installing: true, prompt, out });
180
+ if (decision === "no") { out("Console bootstrap declined; nothing was changed.\n"); return 0; }
181
+ out(` ${installLine}\n`);
182
+ if (decision === "each" && !/^y(es)?$/i.test(String(await prompt("Run that in this folder? [y/N] ")).trim())) { out("Console bootstrap declined; nothing was changed.\n"); return 0; }
183
+
184
+ // Under a umask like Ubuntu's 002 npm writes group-writable files, which the durable checks then rightly refuse, after all the work.
185
+ // Set here, so the install and the handed-over child both inherit it.
186
+ (deps.tightenUmask ?? (() => process.umask(tightenedUmask(process.umask()))))();
187
+ const staging = join(dir, STAGING_DIR);
188
+ try {
189
+ const st = fs.statSync(dir);
190
+ if (st.mode & 0o022) {
191
+ fs.chmodSync(dir, st.mode & ~0o022 & 0o7777);
192
+ // Looked at again now that nobody else can add to it: the check before the question was only true then, and it may have waited a long time.
193
+ if (fs.readdirSync(dir).some((name) => !IGNORABLE.has(name))) return refuse("files appeared in this folder while it was open to other accounts, so nothing was installed. Make a new folder and run this there.");
194
+ }
195
+ // The retries are for an interrupted npm, which goes on writing here for about a second after Ctrl-C has returned the prompt.
196
+ // NOT under the deployment's setup lock, though two runs at once in one folder would share this name: a Ctrl-C would leave that lock
197
+ // behind, and a lock nobody may remove would bring back the dead end this staging folder exists to prevent.
198
+ fs.rmSync(staging, { recursive: true, force: true, maxRetries: 8, retryDelay: 250 });
199
+ fs.mkdirSync(staging, { mode: 0o700 });
200
+ ensureDeploymentPackageJson(fs, staging);
201
+ } catch { return refuse("the deployment folder could not be prepared; an earlier install may still be stopping, so wait a moment and run this again. Nothing else was changed."); }
202
+ const install = deps.npmInstall ?? ((args, options) => attachedCommand("npm", args, { ...options, stdio: ["ignore", "inherit", "inherit"], timeout: INSTALL_TIMEOUT_MS }));
203
+ const code = await install(selfInstallArgs(own), { cwd: staging, env: clean });
204
+ if (code !== 0) return refuse("npm could not install the packages. Fix what it printed above and run this again.");
205
+ try {
206
+ // Whatever appeared while npm ran is someone's, and the renames below would replace it without a word.
207
+ if ((fs.existsSync(join(dir, "package.json")) && !ourPackageJson(fs, dir)) || fs.existsSync(join(dir, "package-lock.json"))) return refuse("this folder changed while it was being set up, so nothing was installed. Run this again in an empty folder.");
208
+ // What an earlier install left. Only reached beside our own `package.json`, read again here because the question may have waited a long
209
+ // time. A symlink is removed, not followed.
210
+ const leftover = join(dir, "node_modules");
211
+ if (fs.existsSync(leftover) || isLink(fs, leftover)) {
212
+ if (!ourPackageJson(fs, dir)) return refuse("this folder changed while it was being set up, so nothing was installed. Run this again in an empty folder.");
213
+ out(" removing what an earlier install left in node_modules\n");
214
+ fs.rmSync(leftover, { recursive: true, force: true });
215
+ }
216
+ // node_modules first. If anything stops between these, what is here is complete and the next run uses it.
217
+ fs.renameSync(join(staging, "node_modules"), leftover);
218
+ for (const name of ["package.json", "package-lock.json"]) if (fs.existsSync(join(staging, name))) fs.renameSync(join(staging, name), join(dir, name));
219
+ fs.rmSync(staging, { recursive: true, force: true });
220
+ } catch { return refuse("the installed packages could not be moved into this folder; run this again."); }
221
+
222
+ const entry = join(dir, "node_modules", "@edgehero", "pi-dispatch", "src", "cli.mjs");
223
+ const metadata = join(dirname(dirname(entry)), "package.json");
224
+ try {
225
+ const trust = deps.trustedFile ?? ((path) => trustedSetupFile(path, { uid, stat: fs.statSync, realpath: fs.realpathSync }));
226
+ const pkg = JSON.parse(fs.readFileSync(metadata, "utf8"));
227
+ if (!trust(entry) || !trust(metadata) || pkg.name !== "@edgehero/pi-dispatch" || pkg.version !== own.version) return refuse("the installed package could not be trusted or is not the expected version; nothing was started.");
228
+ } catch { return refuse("the installed package could not be validated; nothing was started."); }
229
+ // Straight away, so that if the steps after this fail (Docker not running is the likely one) the way to retry exists and can be named.
230
+ const shortcut = ensureConsoleShortcut(dir, fs);
231
+
232
+ // The answer crosses the handover as a flag, so the person is never asked the same thing twice.
233
+ const rest = argv.filter((arg) => arg !== "--yes" && arg !== "--ask-each" && arg !== "--console");
234
+ const handed = await (deps.handOver ?? attachedCommand)(process.execPath, [entry, "up", "--console", decision === "yes" ? "--yes" : "--ask-each", ...rest], { cwd: dir, env: clean });
235
+ const retry = `${shortcut ? "./pi-dispatch" : "./node_modules/.bin/pi-dispatch"} up --console`;
236
+ if (typeof handed !== "number") return refuse(`the installed pi-dispatch could not be started. Run this in this folder: ${retry}`);
237
+ if (handed !== 0) out(`Fix what is printed above, then run this in this folder: ${retry}\n`);
238
+ return handed;
239
+ }