agent-dag 3.19.0 → 3.21.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,243 @@
1
+ // Where the deck keeps its OWN things, as opposed to where Claude Code keeps
2
+ // its things.
3
+ //
4
+ // For a year those were one directory. `~/.claude/agent-dag/` held the hook
5
+ // Claude Code reads, the deck's private key, its pairings, its port registry
6
+ // and a hundred megabytes of event log — and the only reason was that the deck
7
+ // already knew that path, because it has to read `~/.claude` to do its job at
8
+ // all.
9
+ //
10
+ // THREE THINGS THAT COST, and none of them is tidiness:
11
+ //
12
+ // A person who deletes ~/.claude to fix Claude Code deletes this deck's
13
+ // identity. The key is what every paired machine has pinned, so it is not a
14
+ // setting that comes back — every colleague has to accept this deck again.
15
+ //
16
+ // CLAUDE_CONFIG_DIR is Claude Code's knob for keeping a second profile, and
17
+ // pointing it somewhere gave the deck a brand new identity and no pairings,
18
+ // silently. That is a footgun with a fifty-machine blast radius and no error
19
+ // message anywhere.
20
+ //
21
+ // The event log is a hundred megabytes of the deck's data inside another
22
+ // application's configuration directory. Nothing about a rotating log is
23
+ // configuration.
24
+ //
25
+ // So the deck's own state moves to where this platform keeps application data,
26
+ // and the two things that CANNOT move stay where they are: `hook.js`, because
27
+ // Claude Code reads it from there, and the per-pid port registry, because a
28
+ // deck of any version has to be able to list the others — see
29
+ // registeredDeckPorts, which reads that directory by name.
30
+ //
31
+ // The layout is env-paths', which is what Node command-line tools converge on,
32
+ // and its rule is the one worth repeating: XDG is a Linux specification, so it
33
+ // applies on Linux and nowhere else. macOS has Application Support and Logs;
34
+ // Windows has LocalAppData. A tool that puts ~/.config on a Mac is a tool that
35
+ // read half the page.
36
+ //
37
+ // NOTHING IS DELETED BY THE MOVE. Every file is COPIED to its new home and the
38
+ // original left where it was, so a deck downgraded to the version before this
39
+ // one finds exactly what it had. See migrateDeckFiles.
40
+ import { homedir } from "node:os";
41
+ import { join, posix as posixPath, win32 as winPath } from "node:path";
42
+ import { claudeConfigDir } from "./claude-dir.mjs";
43
+ // The Windows ladder, not `rename` (#786). MoveFileExW refuses while any other
44
+ // process holds the destination open — a scanner, the indexer, a backup agent —
45
+ // and the file this one renames into place is a private key. Losing that rename
46
+ // to a virus scanner would be a deck with no identity, which is the one outcome
47
+ // this whole module is written to prevent.
48
+ import { renameWithRetry } from "./installer.mjs";
49
+
50
+ /** The name this deck files itself under. No `-nodejs` suffix, which env-paths
51
+ * adds to keep a script from colliding with a native app of the same name —
52
+ * there is no other ccdeck on any of these platforms. */
53
+ const APP = "ccdeck";
54
+
55
+ /** Point the deck's whole home somewhere else in one variable. The escape
56
+ * hatch for a portable install, a test, or anybody who simply wants it
57
+ * elsewhere; it wins over every rule below. */
58
+ export const HOME_ENV = "CCDECK_HOME";
59
+
60
+ /** Where the deck kept everything before this, and where two of its files still
61
+ * live. Also the place a migration reads from. */
62
+ export function legacyDeckDir(env = process.env, home = homedir(), platform = process.platform) {
63
+ const { join } = platform === "win32" ? winPath : posixPath;
64
+ return join(claudeConfigDir(env, home), "agent-dag");
65
+ }
66
+
67
+ /**
68
+ * The deck's own durable state: its key, its pairings, the accounts it offers.
69
+ *
70
+ * CLAUDE_CONFIG_DIR IS HONOURED RATHER THAN OVERRIDDEN, and that is deliberate.
71
+ * Somebody who set it has asked for a separate Claude profile and has been
72
+ * getting a separate deck along with it for a year — separate identity,
73
+ * separate pairings, separate share list. Moving those people to one
74
+ * machine-wide deck would collapse two profiles into one on upgrade, which is
75
+ * exactly the kind of silent change this file exists to stop. They keep what
76
+ * they have; everybody else moves.
77
+ */
78
+ export function deckDataDir(platform = process.platform, env = process.env, home = homedir()) {
79
+ // THE TARGET PLATFORM'S SEPARATOR, NOT THE HOST'S — the same reason
80
+ // claudeCliCandidates does it, which is that the only way a Windows answer
81
+ // stays right is if it can be checked from a Mac. `node:path` joins with
82
+ // whatever the machine running the code uses, so a bare `join` here answers
83
+ // `\home\u\Library\...` on Windows for a question about macOS.
84
+ // `resolve` for the same reason as `join` just below it: it is host-bound
85
+ // too, so a posix path resolved on a Windows runner comes back with the
86
+ // runner's drive letter glued to the front of it.
87
+ const { join, resolve } = platform === "win32" ? winPath : posixPath;
88
+ const forced = env[HOME_ENV]?.trim();
89
+ if (forced) return resolve(forced);
90
+ if (env.CLAUDE_CONFIG_DIR?.trim()) return legacyDeckDir(env, home, platform);
91
+ if (platform === "darwin") return join(home, "Library", "Application Support", APP);
92
+ if (platform === "win32") return join(localAppData(env, home, platform), APP, "Data");
93
+ return join(env.XDG_DATA_HOME?.trim() || join(home, ".local", "share"), APP);
94
+ }
95
+
96
+ /**
97
+ * Where a log belongs, which is not where a preference belongs.
98
+ *
99
+ * On Linux that is the state directory rather than the data one: XDG separates
100
+ * "data the user would miss" from "state the program can rebuild", and an event
101
+ * log that rotates at 50 MB is the second. macOS and Windows have a named place
102
+ * for logs and this uses it.
103
+ */
104
+ export function deckLogDir(platform = process.platform, env = process.env, home = homedir()) {
105
+ const { join, resolve } = platform === "win32" ? winPath : posixPath;
106
+ const forced = env[HOME_ENV]?.trim();
107
+ if (forced) return resolve(forced);
108
+ if (env.CLAUDE_CONFIG_DIR?.trim()) return legacyDeckDir(env, home, platform);
109
+ if (platform === "darwin") return join(home, "Library", "Logs", APP);
110
+ if (platform === "win32") return join(localAppData(env, home, platform), APP, "Log");
111
+ return join(env.XDG_STATE_HOME?.trim() || join(home, ".local", "state"), APP);
112
+ }
113
+
114
+ /** %LOCALAPPDATA%, or the path it points at on every Windows since Vista. The
115
+ * fallback is not decoration: a service account or a stripped environment can
116
+ * reach this code with the variable unset, and a deck that throws there is a
117
+ * deck that will not start. */
118
+ function localAppData(env = process.env, home = homedir(), platform = process.platform) {
119
+ const { join } = platform === "win32" ? winPath : posixPath;
120
+ return env.LOCALAPPDATA?.trim() || join(home, "AppData", "Local");
121
+ }
122
+
123
+ /**
124
+ * WHAT MOVES, and the two things that do not.
125
+ *
126
+ * `hook.js` stays: Claude Code reads it out of ~/.claude/agent-dag and would
127
+ * find nothing if it moved. The per-pid `NNNNN.json` files stay: a deck lists
128
+ * that directory to find the other decks on this machine, and a deck of the
129
+ * previous version reads it by name — move them and two versions running side
130
+ * by side stop seeing each other.
131
+ *
132
+ * `mode` is the file's, not the directory's, and 0600 on the preferences is
133
+ * load-bearing: it is the one file with a private key in it.
134
+ */
135
+ export const MOVED = Object.freeze([
136
+ Object.freeze({ name: "prefs.json", where: "data", mode: 0o600 }),
137
+ Object.freeze({ name: "events.jsonl", where: "log", mode: 0o644 }),
138
+ Object.freeze({ name: "events.jsonl.1", where: "log", mode: 0o644 }),
139
+ ]);
140
+
141
+ /**
142
+ * Bring a deck's files to their new home, once, and never at the cost of one.
143
+ *
144
+ * FOUR RULES, and each one is a way this could have lost somebody's key:
145
+ *
146
+ * A destination that already exists is never touched. That is what makes
147
+ * this safe to run at every boot, and it is also what stops a stale copy in
148
+ * the old directory from overwriting live state later.
149
+ *
150
+ * The copy lands on a temp file in the destination directory and is renamed
151
+ * into place. A copy interrupted half-way is a truncated prefs.json, and a
152
+ * truncated prefs.json is a deck with no key — which is the whole thing this
153
+ * is trying not to do.
154
+ *
155
+ * The original is LEFT WHERE IT IS. Somebody who downgrades finds what they
156
+ * had. It costs four kilobytes and a hundred megabytes of log that was
157
+ * already there, and it buys a move nobody can be hurt by.
158
+ *
159
+ * A failure is reported and swallowed. A read-only home, a full disk, a
160
+ * permission the installer never had — none of those is a reason for the
161
+ * deck not to start, and the caller falls back to reading the old path.
162
+ *
163
+ * Returns what it did, so the caller can say so once rather than every boot.
164
+ */
165
+ export async function migrateDeckFiles({
166
+ from,
167
+ data,
168
+ log,
169
+ fs,
170
+ /** Separate from `fs` because it is not `fs.rename`: see the import above. */
171
+ rename = renameWithRetry,
172
+ onError,
173
+ } = {}) {
174
+ const moved = [];
175
+ const failed = [];
176
+ for (const file of MOVED) {
177
+ const dir = file.where === "log" ? log : data;
178
+ const src = join(from, file.name);
179
+ const dst = join(dir, file.name);
180
+ if (src === dst) continue;
181
+ try {
182
+ // Both questions asked before anything is written: there is something to
183
+ // move, and there is nothing already there.
184
+ if (!(await exists(fs, src))) continue;
185
+ if (await exists(fs, dst)) continue;
186
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
187
+ const tmp = `${dst}.${process.pid}.migrating`;
188
+ await fs.copyFile(src, tmp);
189
+ await fs.chmod(tmp, file.mode).catch(() => {});
190
+ await rename(tmp, dst);
191
+ moved.push(file.name);
192
+ } catch (err) {
193
+ failed.push(file.name);
194
+ onError?.(file.name, err);
195
+ }
196
+ }
197
+ return { moved, failed };
198
+ }
199
+
200
+ async function exists(fs, path) {
201
+ try { await fs.stat(path); return true; } catch { return false; }
202
+ }
203
+
204
+ /** An hour. A temp file younger than this may belong to a write happening in
205
+ * another deck right now, and deleting one of those turns an atomic write into
206
+ * a lost one. Nothing legitimate is an hour old: the rename that retires a
207
+ * temp file happens within milliseconds of its creation. */
208
+ export const TEMP_STALE_MS = 60 * 60 * 1000;
209
+
210
+ /**
211
+ * The litter an atomic write leaves when the process is killed between the
212
+ * write and the rename.
213
+ *
214
+ * Ninety-seven of them were found in one directory, the oldest six days old:
215
+ * two waves, one from decks that crashed and one from decks killed by hand
216
+ * mid-write. Nothing has ever swept them, because the code that makes them is
217
+ * the code that is not running any more when they are made.
218
+ *
219
+ * So the sweep belongs at START, where a process that is alive can clean up
220
+ * after the ones that are not — and it is deliberately blind to WHICH deck made
221
+ * a file, because it cannot know: it goes by age instead, which is a fact about
222
+ * the file rather than a guess about a pid.
223
+ */
224
+ export async function sweepTempFiles({ dirs, fs, now = Date.now(), onError } = {}) {
225
+ let removed = 0;
226
+ for (const dir of dirs ?? []) {
227
+ let names;
228
+ try { names = await fs.readdir(dir); } catch { continue; }
229
+ for (const name of names) {
230
+ if (!name.endsWith(".tmp") && !name.endsWith(".migrating")) continue;
231
+ const path = join(dir, name);
232
+ try {
233
+ const st = await fs.stat(path);
234
+ if (now - st.mtimeMs < TEMP_STALE_MS) continue;
235
+ await fs.unlink(path);
236
+ removed++;
237
+ } catch (err) {
238
+ onError?.(path, err);
239
+ }
240
+ }
241
+ }
242
+ return removed;
243
+ }
@@ -31,15 +31,21 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
31
31
  // this call. See the note over the write below (#786).
32
32
  import { renameWithRetry } from "./installer.mjs";
33
33
  import { join } from "node:path";
34
- import { claudeConfigDir } from "./claude-dir.mjs";
34
+ import { deckDataDir } from "./deck-home.mjs";
35
35
 
36
36
  /** Set to "1" to keep the deck off the desktop whatever the stored preference
37
37
  * says. Same sheet of switches as AGENTS_DECK_NO_DOWNLOAD and
38
38
  * AGENTS_DECK_NO_INSTALL, and unchanged in meaning by this file. */
39
39
  export const OFF_ENV = "AGENTS_DECK_NO_NOTIFY";
40
40
 
41
- const prefsDir = (home = claudeConfigDir()) => join(home, "agent-dag");
42
- export const prefsPath = (home = claudeConfigDir()) => join(prefsDir(home), "prefs.json");
41
+ /* WHERE THIS FILE LIVES, AND WHY IT MOVED. It sat in ~/.claude/agent-dag — the
42
+ directory Claude Code owns — which meant a person clearing Claude Code's
43
+ configuration cleared this deck's private key, and every machine that had
44
+ pinned it had to be told to trust this one again. deck-home.mjs owns the new
45
+ answer and the reasons; what matters here is that the parameter is still a
46
+ DIRECTORY, so every caller that passes one is unchanged. */
47
+ const prefsDir = (home = deckDataDir()) => home;
48
+ export const prefsPath = (home = deckDataDir()) => join(prefsDir(home), "prefs.json");
43
49
 
44
50
  /**
45
51
  * Every preference the deck keeps, with the answer it gives when there is no
@@ -52,9 +58,32 @@ export const prefsPath = (home = claudeConfigDir()) => join(prefsDir(home), "pre
52
58
  */
53
59
  export const DEFAULTS = Object.freeze({
54
60
  notifications: true,
61
+ // Whether the deck may update itself: restart into code already on disk once
62
+ // it is idle, and — while nobody is looking — install a newer release and
63
+ // restart into that (auto-update.mjs). ON, because this is the banner's
64
+ // `auto when idle`, which defaulted on as a localStorage key; it moved here so
65
+ // the server can read it with no page open.
66
+ autoUpdate: true,
55
67
  // LAN sync, off until somebody turns it on. `passphrase` is the only secret
56
68
  // this file has ever held, which is why the write below now names a mode.
57
- lan: Object.freeze({ enabled: false, name: "", secret: "", shared: [], manual: [], trusted: [], port: 0 }),
69
+ lan: Object.freeze({
70
+ enabled: false, name: "", secret: "", shared: [], manual: [], trusted: [], port: 0,
71
+ // WHO PAIRS WITH WHOM, WITHOUT ANYBODY PRESSING ANYTHING. Both on, so two
72
+ // decks on one network find each other and pair themselves — which is what
73
+ // a person with three of their own machines wants and had to do by hand
74
+ // six times.
75
+ //
76
+ // Read this next to the two switches that gate it. `enabled` above is off,
77
+ // so nothing here happens until somebody deliberately puts this deck on the
78
+ // network; `shared` is empty, so a deck that pairs is offered nothing until
79
+ // somebody ticks a login. These say what happens AFTER both of those, and
80
+ // the dialog that turns the feature on prints them.
81
+ autoAsk: true,
82
+ autoAccept: true,
83
+ // What somebody HERE calls another deck, keyed by its fingerprint. The name
84
+ // a deck gives itself is its owner's to choose; this is the other half.
85
+ aliases: Object.freeze({}),
86
+ }),
58
87
  });
59
88
 
60
89
  /** The mode prefs.json is created with.
@@ -70,6 +99,40 @@ export const DEFAULTS = Object.freeze({
70
99
  * window between the two, which is exactly when a secret is in it. */
71
100
  export const PREFS_MODE = 0o600;
72
101
 
102
+ /** The longest name somebody here may give another deck. The same order as a
103
+ * deck's own name, and short enough to stay one line in the panel's column. */
104
+ export const ALIAS_MAX = 48;
105
+
106
+ /** A name somebody typed for another deck, cleaned — or "" for one with
107
+ * nothing left in it, which is how an alias is taken away. Control characters
108
+ * go the way cleanName sends them: this string reaches a terminal as well as a
109
+ * page. */
110
+ export function cleanAlias(raw) {
111
+ if (typeof raw !== "string") return "";
112
+ const flat = raw.replace(/\p{Cc}/gu, " ").replace(/\s+/g, " ").trim();
113
+ return [...flat].slice(0, ALIAS_MAX).join("").trim();
114
+ }
115
+
116
+ /** A fingerprint an alias may be kept under. Real ones are `abc-def-012-345`;
117
+ * what matters here is only that a typed address's placeholder — which has a
118
+ * colon in it and names no deck — can never be one, and that nothing exotic
119
+ * becomes a key in an object this file writes back to disk. */
120
+ export function isAliasKey(fp) {
121
+ return typeof fp === "string" && /^[A-Za-z0-9-]{1,64}$/.test(fp);
122
+ }
123
+
124
+ /** The alias map, coerced. A page writes it; a hand-edited file can hold
125
+ * anything. Capped, because nothing legitimate is anywhere near it. */
126
+ function aliasesFrom(raw) {
127
+ const out = {};
128
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return out;
129
+ for (const [fp, name] of Object.entries(raw).slice(0, 256)) {
130
+ const clean = cleanAlias(name);
131
+ if (isAliasKey(fp) && clean) out[fp] = clean;
132
+ }
133
+ return out;
134
+ }
135
+
73
136
  /** One LAN section, coerced. Unknown keys dropped like everything else here,
74
137
  * and the two lists forced to arrays of strings — they arrive from a page and
75
138
  * are then compared against account keys and dialled as addresses. */
@@ -93,7 +156,13 @@ function normaliseLan(raw) {
93
156
  // cannot be checked is worse than no entry at all.
94
157
  trusted: (Array.isArray(src.trusted) ? src.trusted : [])
95
158
  .filter(t => t && typeof t.fp === "string" && typeof t.pub === "string")
96
- .map(t => ({ fp: t.fp, pub: t.pub, name: typeof t.name === "string" ? t.name : "" })),
159
+ .map(t => ({
160
+ fp: t.fp, pub: t.pub, name: typeof t.name === "string" ? t.name : "",
161
+ // When somebody here said yes. Absent on every pin made before this was
162
+ // kept, and absent is drawn as "before" rather than guessed.
163
+ ...(Number.isFinite(t.at) && t.at > 0 ? { at: t.at } : {}),
164
+ })),
165
+ aliases: aliasesFrom(src.aliases),
97
166
  // The port this deck listened on last time, so an address somebody typed on
98
167
  // the other machine still works after a restart. It asked the OS for a new
99
168
  // one every start, which is invisible while broadcast works and is exactly
@@ -102,6 +171,16 @@ function normaliseLan(raw) {
102
171
  // A PREFERENCE, never a requirement: a port already taken falls through to
103
172
  // an OS-chosen one and this is rewritten. 0 means "none yet".
104
173
  port: Number.isInteger(src.port) && src.port > 0 && src.port < 65_536 ? src.port : 0,
174
+ // ASK FIRST. A deck heard on the broadcast is sent a pairing request without
175
+ // anybody pressing `ask` — the outbound half, which gives nothing away: the
176
+ // machine on the other end still answers it, by hand or by the switch below.
177
+ autoAsk: typeof src.autoAsk === "boolean" ? src.autoAsk : true,
178
+ // AND SAY YES. Every deck that finishes a handshake and is not already
179
+ // trusted is pinned without anybody being asked — the accept button pressed
180
+ // in advance, and it hands whoever asks a copy of every login this deck
181
+ // shares. Absent means the default above; only a real boolean overrides it,
182
+ // because a truthy string from a hand-edited file is not an answer.
183
+ autoAccept: typeof src.autoAccept === "boolean" ? src.autoAccept : true,
105
184
  };
106
185
  }
107
186
 
@@ -115,13 +194,14 @@ export function normalise(raw) {
115
194
  const src = raw && typeof raw === "object" ? raw : {};
116
195
  return {
117
196
  notifications: typeof src.notifications === "boolean" ? src.notifications : DEFAULTS.notifications,
197
+ autoUpdate: typeof src.autoUpdate === "boolean" ? src.autoUpdate : DEFAULTS.autoUpdate,
118
198
  lan: normaliseLan(src.lan),
119
199
  };
120
200
  }
121
201
 
122
202
  /** What is on disk, or the defaults. A corrupt or absent file is not an error
123
203
  * the user can act on mid-session, so it reads as "nothing chosen yet". */
124
- export async function readPrefs(home = claudeConfigDir(), deps = {}) {
204
+ export async function readPrefs(home = deckDataDir(), deps = {}) {
125
205
  const read = deps.readFile ?? readFile;
126
206
  try { return normalise(JSON.parse(await read(prefsPath(home), "utf8"))); }
127
207
  catch { return { ...DEFAULTS }; }
@@ -142,7 +222,7 @@ let _chain = Promise.resolve();
142
222
  * Serialized for the same reason the other store is: two pages toggling two
143
223
  * different switches in the same second must not lose one of them.
144
224
  */
145
- export async function writePrefs(patch, home = claudeConfigDir(), deps = {}) {
225
+ export async function writePrefs(patch, home = deckDataDir(), deps = {}) {
146
226
  const job = async () => {
147
227
  const mk = deps.mkdir ?? mkdir;
148
228
  const write = deps.writeFile ?? writeFile;
@@ -159,7 +239,7 @@ export async function writePrefs(patch, home = claudeConfigDir(), deps = {}) {
159
239
  // nothing has to send a secret it was never given.
160
240
  const merged = { ...prev, ...patch, lan: { ...prev.lan, ...(patch?.lan ?? {}) } };
161
241
  const next = normalise(merged);
162
- await mk(prefsDir(home), { recursive: true });
242
+ await mk(prefsDir(home), { recursive: true, mode: 0o700 });
163
243
  const tmp = `${prefsPath(home)}.${process.pid}.tmp`;
164
244
  await write(tmp, JSON.stringify(next, null, 2) + "\n", { encoding: "utf8", mode: PREFS_MODE });
165
245
  await mv(tmp, prefsPath(home));
@@ -0,0 +1,111 @@
1
+ // Is that process a deck, and is it still there — asked from anywhere.
2
+ //
3
+ // These four lived in src/server/index.mjs, which is the whole server: 5,800
4
+ // lines, a dozen watchers, and timers that arm the moment the module is
5
+ // imported. Everything that had to ask one of these questions therefore had to
6
+ // import all of that, and three callers cannot:
7
+ //
8
+ // • running-deck.mjs, read on the boot path before anything else, which for
9
+ // one commit took both of these as PARAMETERS purely to avoid the import.
10
+ // • `ccdeck --stop` and `--status`, one-shot commands that talk to a deck and
11
+ // exit. Starting a server to ask a server to stop is absurd on its face,
12
+ // and on a cold start it is also slower than the thing it is asking for.
13
+ // • the tests, which pin these against hook/hook.js's copy and should cost a
14
+ // millisecond rather than a server boot.
15
+ //
16
+ // So they moved down here, to a leaf that imports two node builtins and nothing
17
+ // else. index.mjs imports them from here and RE-EXPORTS them under the names it
18
+ // always had, so every existing caller and every existing test is untouched —
19
+ // and there is still exactly one spelling of the handshake in the package.
20
+ //
21
+ // hook/hook.js keeps its own duplicate of challengeProof for the one reason
22
+ // that has always justified it: that script is copied out of the package and
23
+ // installed into ~/.claude, where it cannot import from anywhere. A test pins
24
+ // the two spellings against each other, and that test is the only thing
25
+ // standing between a changed hash and a deck that silently stops being told
26
+ // anything.
27
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
28
+ import { request as httpRequest } from "node:http";
29
+
30
+ // The same deadline hook.js gives a challenge, and for the same reason: a
31
+ // bodyless GET to a loopback port is sub-millisecond when a deck is there and an
32
+ // instant ECONNREFUSED when nothing is.
33
+ export const DECK_CHALLENGE_TIMEOUT_MS = 400;
34
+
35
+ // Signal 0 delivers nothing; it asks whether the pid could be signalled.
36
+ //
37
+ // BOTH ERRNOS, and the second one is the Windows spelling. POSIX `kill(2)`
38
+ // answers EPERM for a process this account may not signal. On Windows
39
+ // `uv_kill` calls `OpenProcess`, a denial is ERROR_ACCESS_DENIED, and libuv
40
+ // maps that to EACCES — so a deck started from an elevated terminal, or under
41
+ // another account, read as DEAD to every probe in this repo. What followed was
42
+ // silent: the live deck's discovery file was unlinked on the next hook fire,
43
+ // rewritten five seconds later by keepDiscovery, and its banner went on
44
+ // claiming it was receiving events it had stopped receiving.
45
+ export function isProcessAlive(pid) {
46
+ try { process.kill(pid, 0); return true; }
47
+ catch (e) { return !!e && (e.code === "EPERM" || e.code === "EACCES"); }
48
+ }
49
+
50
+ /**
51
+ * The proof of knowing `token`, for a nonce the challenger chose.
52
+ *
53
+ * hook/hook.js spells this out a second time — it is installed outside the
54
+ * package and cannot import from here — and a test pins the two against each
55
+ * other. Changing one without the other silently blinds the deck.
56
+ */
57
+ export function challengeProof(token, nonce) {
58
+ return createHash("sha256").update(`${token}:${nonce}`).digest("hex");
59
+ }
60
+
61
+ /**
62
+ * One challenge round trip, resolving true only on a correct proof.
63
+ *
64
+ * The compare is constant-time for the reason hook.js's sameProof is: whatever
65
+ * is on that port may not be a deck, and it must not be able to walk the
66
+ * expected proof out of us one byte at a time by timing how long we take to hang
67
+ * up. The nonce is fresh per call, so an answer overheard earlier is worth
68
+ * nothing, and the token itself never leaves this process.
69
+ */
70
+ export function challengeDeck(port, token) {
71
+ return new Promise(resolve => {
72
+ let settled = false;
73
+ const finish = ok => { if (settled) return; settled = true; resolve(ok); };
74
+ const nonce = randomBytes(16).toString("hex");
75
+ const want = challengeProof(token, nonce);
76
+ const req = httpRequest({
77
+ hostname: "127.0.0.1",
78
+ port,
79
+ path: `/api/hook-challenge?nonce=${nonce}`,
80
+ method: "GET",
81
+ timeout: DECK_CHALLENGE_TIMEOUT_MS,
82
+ }, res => {
83
+ if (res.statusCode !== 200) { res.resume(); return res.on("end", () => finish(false)); }
84
+ let answer = "";
85
+ res.setEncoding("utf8");
86
+ res.on("data", c => {
87
+ answer += c;
88
+ // A deck answers in ~100 bytes. Anything pouring data at us is not one,
89
+ // and must not be allowed to grow this buffer without bound.
90
+ if (answer.length > 4096) { req.destroy(); finish(false); }
91
+ });
92
+ res.on("end", () => {
93
+ if (settled) return;
94
+ let proof;
95
+ try { proof = JSON.parse(answer).proof; } catch { return finish(false); }
96
+ finish(sameProof(proof, want));
97
+ });
98
+ });
99
+ req.on("error", () => finish(false));
100
+ req.on("timeout", () => req.destroy());
101
+ req.end();
102
+ });
103
+ }
104
+
105
+ /** hook.js's sameProof, for the same reason it is constant-time there. */
106
+ export function sameProof(got, want) {
107
+ if (typeof got !== "string") return false;
108
+ const a = Buffer.from(got, "utf8");
109
+ const b = Buffer.from(want, "utf8");
110
+ return a.length === b.length && timingSafeEqual(a, b);
111
+ }