agent-dag 3.2.1 → 3.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.
@@ -201,11 +201,33 @@ async function readCompressedTokenSeries(filePath) {
201
201
  }
202
202
  return null;
203
203
  }
204
+ // THE SOURCE IS HELD, and both halves of that matter.
205
+ //
206
+ // `Readable.pipe` attaches its error handling to the DESTINATION. The source
207
+ // got neither an 'error' listener nor a destroy, so a read that failed — the
208
+ // file removed between the listing and this call, EACCES, EMFILE — emitted
209
+ // an unhandled 'error' and took the process down, in a reader whose
210
+ // uncompressed twin deliberately survives those same errnos. A torn archive
211
+ // errors on the destination instead, which the catch below swallows while the
212
+ // source stays open: twenty reads of one corrupt file left twenty handles,
213
+ // against a sixty-second poll. On Windows a held handle also blocks the
214
+ // unlink, which is the case the plain path's `finally { await fd?.close() }`
215
+ // names.
216
+ let source = null;
204
217
  try {
205
218
  const series = [];
206
219
  const decoder = new StringDecoder("utf8");
207
220
  let pending = "";
208
- const stream = createReadStream(filePath).pipe(createZstdDecompress());
221
+ source = createReadStream(filePath);
222
+ const stream = source.pipe(createZstdDecompress());
223
+ // THE ERROR HAS TO REACH THE LOOP, not merely be caught. A bare
224
+ // `on("error", () => {})` here stops the process dying and hangs the read
225
+ // instead: `pipe` does not forward a source failure to the destination, so
226
+ // the `for await` below waits forever for an 'end' that cannot come — which
227
+ // is what a missing file did on the first Windows run of this code, at 30
228
+ // seconds per poll. Destroying the destination WITH the error makes the
229
+ // iteration reject, which is what the catch is for.
230
+ source.on("error", err => { stream.destroy(err); });
209
231
  for await (const chunk of stream) {
210
232
  pending += decoder.write(chunk);
211
233
  let from = 0;
@@ -220,6 +242,7 @@ async function readCompressedTokenSeries(filePath) {
220
242
  if (pending) foldTokenLine(series, pending);
221
243
  return series.length ? series : null;
222
244
  } catch { return null; }
245
+ finally { source?.destroy(); }
223
246
  }
224
247
 
225
248
  /** The reader, exported under a test-only name. The compressed path cannot be
@@ -986,20 +986,6 @@ export async function shareAccounts(nums) {
986
986
  };
987
987
  }
988
988
 
989
- /**
990
- * The one-account case, which is a bundle of one and takes the same path.
991
- *
992
- * That path PARSES what cswap wrote, where this used to hand its stdout on
993
- * opaquely — the cost of `shared` being able to promise that the count on the
994
- * copy button is the count in the blob. So an export shape the fold cannot read
995
- * now fails the single share too, not just the bundle. Deliberate: one path
996
- * means the two cannot drift into two envelope shapes, and a share that
997
- * silently carried something this deck could not account for is the failure
998
- * the count exists to prevent.
999
- */
1000
- export async function shareAccount(num) {
1001
- return shareAccounts([num]);
1002
- }
1003
989
 
1004
990
  /**
1005
991
  * The identities a bundle carries, or `[]` when it cannot be read.
@@ -229,7 +229,13 @@ const MODEL_LIST_OK = /^(?!-)[A-Za-z0-9 ,._-]{1,120}$/;
229
229
 
230
230
  /** Validate against SETTINGS, then hand to `cswap config set`. */
231
231
  export async function setCswapConfig(key, value) {
232
- const spec = SETTINGS[key];
232
+ // `Object.hasOwn`, not a bare read. `SETTINGS["constructor"]` is truthy and
233
+ // its `.type` is undefined, so a prototype member passed the allowlist and
234
+ // fell through to the free-text branch — reaching `cswap config set
235
+ // constructor <value>` and skipping the type-specific range check on the way.
236
+ // Nothing reachable that way was dangerous; an allowlist that does not hold
237
+ // is.
238
+ const spec = Object.hasOwn(SETTINGS, key) ? SETTINGS[key] : null;
233
239
  if (!spec) return { ok: false, reason: "unknown_setting" };
234
240
 
235
241
  let str;
@@ -253,6 +259,17 @@ export async function setCswapConfig(key, value) {
253
259
  // nothing. The panel reloads this route immediately afterwards and gets a real
254
260
  // read; see invalidateCswapAutoCache.
255
261
  invalidateCswapAutoCache();
262
+ // A NEW INTERVAL HAS TO REACH THE TIMER. `tickInterval()` is read once, at
263
+ // startLoop, so changing this setting used to update what the panel reports
264
+ // and nothing else: set 3600 with auto-switch on and the panel read back an
265
+ // hour while the loop kept firing every sixty seconds for the life of the
266
+ // process — sixty `cswap auto --once` spawns an hour instead of one, against
267
+ // the shared per-account request budget this subsystem exists to protect.
268
+ // Lowering it was equally inert.
269
+ if (r.ok && key === "autoswitch.intervalSeconds" && _enabled) {
270
+ stopLoop();
271
+ await startLoop();
272
+ }
256
273
  return r.ok ? { ok: true } : { ok: false, reason: "set_failed", detail: (r.stderr || r.stdout).trim().slice(0, 300) };
257
274
  }
258
275
 
@@ -291,6 +308,15 @@ function summarise(stdout) {
291
308
  /** Evaluate a tick for real. May switch the active account. */
292
309
  async function runAutoTick() {
293
310
  const r = await run(await cswapBin(), ["auto", "--once", "--json"], { timeout: TICK_TIMEOUT_MS });
311
+ // A KILLED RUN IS NOT A QUIET ONE. `run`'s timeout path deliberately keeps an
312
+ // 8 KB tail of whatever the child managed to print, so `!r.ok && !r.stdout`
313
+ // is false for a tick that emitted its `{"event":"poll"}` line and then
314
+ // stalled — and the killed run fell through to `ok: true`. The panel then
315
+ // showed a healthy `no-switch` every two minutes, forever, while the engine
316
+ // did nothing at all: exactly the trap exec.mjs's own header names.
317
+ if (r.timedOut) {
318
+ return { ok: false, reason: "tick_timeout", detail: `cswap auto --once did not finish within ${TICK_TIMEOUT_MS / 1000}s` };
319
+ }
294
320
  if (!r.ok && !r.stdout) {
295
321
  return { ok: false, reason: "tick_failed", detail: (r.stderr || "").trim().slice(0, 300) };
296
322
  }
@@ -477,6 +503,18 @@ async function runTick() {
477
503
  _lastTick = { at: Date.now(), event: "skipped", reason: "external-engine" };
478
504
  return;
479
505
  }
506
+ // AND RE-CHECK THE SWITCH ITSELF, after that await. `stopLoop` clears the
507
+ // interval and nothing else, so a tick already running went on to move the
508
+ // user's live account seconds after the panel had drawn itself as off. The
509
+ // await above is not short: ticks are at least fifteen seconds apart against
510
+ // a ten-second floor, so every one pays a real process-table read — on
511
+ // Windows a PowerShell Get-CimInstance with an eight-second deadline. The
512
+ // panel then showed `enabled: false` beside a `lastTick` of
513
+ // `{event: "switch", from, to}` stamped after the user turned it off.
514
+ if (!_enabled) {
515
+ _lastTick = { at: Date.now(), event: "skipped", reason: "disabled" };
516
+ return;
517
+ }
480
518
  const result = await runAutoTick();
481
519
  // Before `_lastTick`, not after. This is the only path in the deck that moves
482
520
  // the live account without a click behind it, so nothing else is in a position
@@ -597,8 +597,13 @@ export async function ensureCswap({ onInstalling = null } = {}) {
597
597
  // Installed — the only question left is whether it's stale. One PyPI
598
598
  // request a day, and the upgrade itself never blocks startup.
599
599
  if (!updateCheckDue()) return { state: "present", version: existing };
600
- touchMarker();
600
+ // The marker is stamped AFTER the request, not before it. Stamped first, a
601
+ // boot with no network yet — a laptop opened on a train, the ten seconds
602
+ // before Wi-Fi associates — burned the whole shared 24-hour window on a
603
+ // check that never reached PyPI, and the next real chance was the day
604
+ // after. self-update.mjs states this rule for itself in as many words.
601
605
  const latest = await latestOnPypi();
606
+ touchMarker();
602
607
  if (latest && existing !== "installed" && isOlder(existing, latest)) {
603
608
  // Who owns it, not what is installed on the machine: an upgrade aimed at
604
609
  // a tool that never installed this package is refused where runDetached
@@ -28,6 +28,30 @@
28
28
  import { execFile, spawn } from "node:child_process";
29
29
  import { existsSync } from "node:fs";
30
30
 
31
+ // WINDOWS SEARCHES THE WORKING DIRECTORY FIRST, and this turns that off.
32
+ //
33
+ // libuv's PATH search calls `NeedCurrentDirectoryForExePathW("")`, which is
34
+ // true unless this variable is set — so `spawn("cswap", …)` on Windows tries
35
+ // `.\cswap.exe` before anything on PATH, and the deck's working directory is
36
+ // wherever `npx ccdeck` was run: normally the user's project, often a repo they
37
+ // just cloned. `cswapBin()` probes the bare name before any known install path
38
+ // and memoises whatever answered, so a planted binary would then receive every
39
+ // later `cswap switch` and `cswap export -` — the commands that carry account
40
+ // credentials. The same search reaches py.exe, where.exe, claude.exe and
41
+ // powershell.exe.
42
+ //
43
+ // Set on this process rather than per spawn, because the search is done by the
44
+ // PARENT: one assignment covers every child this deck ever starts, including
45
+ // the ones spawned outside this module. An explicit value in the environment is
46
+ // left alone — someone who set it meant it.
47
+ //
48
+ // POSIX never had this behaviour: execvp does not search `.` unless PATH says
49
+ // so, so this is a no-op there and is skipped rather than written.
50
+ if (process.platform === "win32" && !("NoDefaultCurrentDirectoryInExePath" in process.env)) {
51
+ process.env.NoDefaultCurrentDirectoryInExePath = "1";
52
+ }
53
+
54
+
31
55
  // Extensions Windows will execute, most specific first. `.com` is omitted —
32
56
  // nothing ships one, and every extra candidate costs a failed spawn.
33
57
  const WIN_EXTS = [".exe", ".cmd", ".bat", ""];
@@ -682,6 +706,12 @@ export function run(cmd, args, { timeout = 20_000, maxBuffer = 4 << 20, env } =
682
706
  // the shell command it replaced had to end in `< /dev/null`. Closing
683
707
  // the pipe is that redirection without a shell to parse it.
684
708
  try { cp.stdin?.on("error", () => {}); cp.stdin?.end(); } catch { /* no stdin to close */ }
709
+ // Decoded as a stream rather than per chunk: a chunk boundary falls
710
+ // wherever the pipe broke, and a multi-byte character split across two
711
+ // of them becomes two replacement characters in the tail this keeps for
712
+ // the timeout message.
713
+ cp.stdout?.setEncoding?.("utf8");
714
+ cp.stderr?.setEncoding?.("utf8");
685
715
  cp.stdout?.on("data", (d) => { sawOut = (sawOut + d).slice(-TIMEOUT_TAIL); });
686
716
  cp.stderr?.on("data", (d) => { sawErr = (sawErr + d).slice(-TIMEOUT_TAIL); });
687
717
  // The deadline states the outcome itself and only then kills, which is
@@ -814,6 +844,15 @@ export function runInteractive(cmd, args, { timeout = 300_000, maxOutput = 256 <
814
844
  return tryNext(err) ? attempt(i + 1) : finish(-1, err);
815
845
  }
816
846
  child = proc;
847
+ // EPIPE ON STDIN IS NOT A CRASH. `write` below is wrapped in a try/catch,
848
+ // and that catch can never fire for the case that matters: a broken pipe
849
+ // arrives asynchronously, as an 'error' event on the Writable, and an
850
+ // unhandled 'error' on a stream is an uncaught exception with no
851
+ // process-level net anywhere in this deck. `cswap import -` reading a
852
+ // prefix of a bad bundle and exiting before it drains is exactly that
853
+ // shape, so a rejected paste in the import dialog took the dashboard down
854
+ // with it. `run` has carried this same line since it was written.
855
+ proc.stdin?.on("error", () => {});
817
856
  // A spelling that fails to spawn emits 'error' AND THEN 'close' — with code
818
857
  // -2 after an ENOENT. Once the error handler has moved on to the next
819
858
  // candidate, that trailing 'close' is news about a child nobody is waiting
@@ -845,6 +884,11 @@ export function runInteractive(cmd, args, { timeout = 300_000, maxOutput = 256 <
845
884
  // Capped so a runaway child cannot grow the heap without bound; the tail is
846
885
  // what carries the error, so the head is what gets dropped.
847
886
  const keep = (buf, text) => (buf + text).slice(-maxOutput);
887
+ // Same reason as `run`'s tails: the login prompt this reader is waiting for
888
+ // arrives mid-chunk, and a UTF-8 sequence cut by a pipe boundary must not
889
+ // become two replacement characters in the line it emits.
890
+ proc.stdout?.setEncoding?.("utf8");
891
+ proc.stderr?.setEncoding?.("utf8");
848
892
  proc.stdout?.on("data", (d) => { if (stale()) return; const t = String(d); stdout = keep(stdout, t); emitLines(t); });
849
893
  proc.stderr?.on("data", (d) => { if (stale()) return; const t = String(d); stderr = keep(stderr, t); emitLines(t); });
850
894
  proc.on("close", (code) => {
@@ -662,7 +662,7 @@ export const MAX_SCAN_CHUNK = 8 * 1024 * 1024;
662
662
  // pointing one POST at something arbitrarily large: the read still terminates,
663
663
  // the cursor keeps whatever it reached, and the next throttled pass continues
664
664
  // from there.
665
- export const MAX_SCAN_BYTES_PER_PASS = 256 * 1024 * 1024;
665
+ const MAX_SCAN_BYTES_PER_PASS = 256 * 1024 * 1024;
666
666
 
667
667
  const NEWLINE = 0x0a;
668
668
 
@@ -1458,7 +1458,7 @@ const pendingNameReads = new Set(); // sid currently being read
1458
1458
  /** The naming the cursor has folded so far, or null when the scan has nothing.
1459
1459
  * Exported beside readContextFromTranscript for the same reason: the rule is
1460
1460
  * worth pinning directly rather than through a live server. */
1461
- export async function readSessionNamingFromTranscript(path) {
1461
+ async function readSessionNamingFromTranscript(path) {
1462
1462
  const state = await scanTranscript(path);
1463
1463
  if (!state) return null;
1464
1464
  if (!state.agentName && !state.aiTitle) return null;
@@ -3470,7 +3470,13 @@ export function replayScope(workspace, platform = process.platform) {
3470
3470
  * that case cheap needs an index of where a workspace's lines are, which is a
3471
3471
  * different change from this one.
3472
3472
  */
3473
- async function replayLog(filePath, workspace = "") {
3473
+ /* Exported for the suite, with both ceilings as parameters. The byte budget is
3474
+ * 128 MiB, so a test that wanted to reach it honestly would have to write 128
3475
+ * MiB — which is why the count bound was the only one anything pinned, and why
3476
+ * the byte bound was the one that broke. Production passes neither argument. */
3477
+ export async function replayLog(filePath, workspace = "", {
3478
+ maxEvents = MAX_BUFFER, maxChars = MAX_BUFFER_CHARS,
3479
+ } = {}) {
3474
3480
  if (!existsSync(filePath)) return 0;
3475
3481
  let skipped = 0;
3476
3482
  let skippedBytes = 0;
@@ -3499,17 +3505,32 @@ async function replayLog(filePath, workspace = "") {
3499
3505
  }
3500
3506
  } else {
3501
3507
  // Newest first, so this is filled back to front and then walked in reverse
3502
- // to push. Bounded by MAX_BUFFER, which is what makes the memory here a
3503
- // property of the ring rather than of the file.
3508
+ // to push. Bounded by BOTH of the ring's limits, which is what makes the
3509
+ // memory here a property of the ring rather than of the file.
3510
+ //
3511
+ // The count alone was not enough, and the comment that used to say it was
3512
+ // predates #625. Eviction is the only thing that applies MAX_BUFFER_CHARS,
3513
+ // and eviction happens inside pushEvent — which does not run until this
3514
+ // array is already full. Measured on a 187 MB log of 40 events of 4.9M
3515
+ // characters each, every one of them under the ingest cap: RSS went from
3516
+ // 215 MB to a peak of 505 MB, and the ring that survived held 27 events and
3517
+ // 126 MiB. About 290 MB staged for a ring capped at 128.
3518
+ //
3519
+ // Rotation at 50 MB normally keeps logs well under this, but rotation is
3520
+ // best-effort and its failure is only logged, and this file's own header
3521
+ // records logs reaching gigabytes.
3504
3522
  const newestFirst = [];
3523
+ let stagedChars = 0;
3505
3524
  for await (const line of linesFromEnd(filePath)) {
3506
3525
  if (!line) continue;
3507
3526
  const evt = parse(line);
3508
3527
  if (!usable(evt) || !admits(evt.payload)) continue;
3509
3528
  newestFirst.push(evt);
3529
+ stagedChars += ENVELOPE_CHARS + payloadChars(evt.payload);
3510
3530
  // Everything older than this would be evicted by the events already held,
3511
3531
  // so reading further is work whose only result is throwing it away.
3512
- if (newestFirst.length >= MAX_BUFFER) break;
3532
+ // Either limit reaching its ceiling means exactly that.
3533
+ if (newestFirst.length >= maxEvents || stagedChars >= maxChars) break;
3513
3534
  }
3514
3535
  for (let i = newestFirst.length - 1; i >= 0; i--) replay(newestFirst[i]);
3515
3536
  count = newestFirst.length;
@@ -4116,7 +4137,7 @@ async function handleBrowserWatchSettings(req, res) {
4116
4137
  try { body = JSON.parse(raw ?? ""); } catch { /* handled below */ }
4117
4138
  if (!body || typeof body !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
4118
4139
 
4119
- const { readStore, writeStore, normalise } = await import(
4140
+ const { readStore, updateStore, normalise } = await import(
4120
4141
  pathToFileURL(join(PKG_ROOT, "src/server/browser-watch-store.mjs")).href
4121
4142
  );
4122
4143
  const { invalidateBrowserWatchCache, noteWatchSetting } = await import(
@@ -4132,7 +4153,11 @@ async function handleBrowserWatchSettings(req, res) {
4132
4153
  // this field ERASES it. Measured — changing the reaction wiped every
4133
4154
  // dismissal, so every episode the reader had reviewed came straight back on
4134
4155
  // the next poll, from a settings change that had nothing to do with them.
4135
- await writeStore({ settings, episodes: store.episodes, dismissed: store.dismissed });
4156
+ // Only the settings are this route's to change. `updateStore` re-reads inside
4157
+ // the write queue, so a poll that landed between the read above and this line
4158
+ // cannot have its archive thrown away by a settings change — which is what
4159
+ // writing a whole state read seconds earlier used to do.
4160
+ await updateStore(cur => ({ ...cur, settings }));
4136
4161
  // The one line in the log that is somebody acting rather than the deck
4137
4162
  // reading, which is exactly why it is worth its own entry.
4138
4163
  if (settings.enabled !== store.settings.enabled) {
@@ -4168,7 +4193,7 @@ async function handleBrowserWatchDismiss(req, res) {
4168
4193
  const startMs = typeof body?.startMs === "number" && Number.isFinite(body.startMs) ? body.startMs : null;
4169
4194
  if (host === null || startMs === null) return send(res, 400, { ok: false, reason: "bad_request" });
4170
4195
 
4171
- const { readStore, writeStore, episodeKey } = await import(
4196
+ const { readStore, updateStore, episodeKey } = await import(
4172
4197
  pathToFileURL(join(PKG_ROOT, "src/server/browser-watch-store.mjs")).href
4173
4198
  );
4174
4199
  const { invalidateBrowserWatchCache, noteWatchSetting } = await import(
@@ -4177,7 +4202,7 @@ async function handleBrowserWatchDismiss(req, res) {
4177
4202
  const store = await readStore();
4178
4203
  const key = episodeKey(host, startMs);
4179
4204
  const dismissed = [...new Set([...(store.dismissed ?? []), key])];
4180
- await writeStore({ settings: store.settings, episodes: store.episodes, dismissed });
4205
+ await updateStore(cur => ({ ...cur, dismissed }));
4181
4206
  // The reader acting on their own list, which is exactly the kind of line the
4182
4207
  // `act` level exists for.
4183
4208
  noteWatchSetting(`dismissed ${host}`);
@@ -4188,6 +4213,12 @@ async function handleBrowserWatchDismiss(req, res) {
4188
4213
  async function handleBrowserWatch(req, res) {
4189
4214
  const url = new URL(req.url, "http://localhost");
4190
4215
  const force = url.searchParams.get("refresh") === "1";
4216
+ // `live=0` is the badge's five-minute poll saying "the archive is enough".
4217
+ // With the watch OFF that is honoured and no browser is read at all — see
4218
+ // browserWatchSnapshot: the switch used to gate only what was KEPT, so a deck
4219
+ // nobody had switched on still copied every History database every five
4220
+ // minutes. A forced read overrides it, because that is the user pressing ↻.
4221
+ const readBrowsers = force || url.searchParams.get("live") !== "0";
4191
4222
 
4192
4223
  // Numbers from a query string are refused rather than coerced: NaN would
4193
4224
  // silently widen the quiet gate to "everything counts", which is the failure
@@ -4217,6 +4248,7 @@ async function handleBrowserWatch(req, res) {
4217
4248
  const ports = await registeredDeckPorts();
4218
4249
  return send(res, 200, await fetchBrowserWatch({
4219
4250
  force,
4251
+ readBrowsers,
4220
4252
  deckOrigins: deckOwnOrigins(undefined, ports),
4221
4253
  quietMs: minutes("quiet"),
4222
4254
  gapMs: minutes("gap"),
@@ -4676,9 +4708,19 @@ function handleHookChallenge(_req, res, url) {
4676
4708
  send(res, 200, { proof: challengeProof(HOOK_TOKEN, nonce) });
4677
4709
  }
4678
4710
 
4711
+ // Signal 0 delivers nothing; it asks whether the pid could be signalled.
4712
+ //
4713
+ // BOTH ERRNOS, and the second one is the Windows spelling. POSIX `kill(2)`
4714
+ // answers EPERM for a process this account may not signal. On Windows
4715
+ // `uv_kill` calls `OpenProcess`, a denial is ERROR_ACCESS_DENIED, and libuv
4716
+ // maps that to EACCES — so a deck started from an elevated terminal, or under
4717
+ // another account, read as DEAD to every probe in this repo. What followed was
4718
+ // silent: the live deck's discovery file was unlinked on the next hook fire,
4719
+ // rewritten five seconds later by keepDiscovery, and its banner went on
4720
+ // claiming it was receiving events it had stopped receiving.
4679
4721
  function isProcessAlive(pid) {
4680
4722
  try { process.kill(pid, 0); return true; }
4681
- catch (e) { return e && e.code === "EPERM"; }
4723
+ catch (e) { return !!e && (e.code === "EPERM" || e.code === "EACCES"); }
4682
4724
  }
4683
4725
 
4684
4726
  async function sweepStaleDiscovery() {
@@ -4990,6 +5032,72 @@ function isDeckUiRequest({ origin, host, secFetchSite } = {}) {
4990
5032
  // presents nothing at all no longer changes anything — and what is opened is
4991
5033
  // the honest door, so a script of the user's own authenticates by reading the
4992
5034
  // token instead of impersonating a page.
5035
+ /**
5036
+ * May this request read the deck's OWN data — the events, the accounts, the
5037
+ * browsing episodes?
5038
+ *
5039
+ * The mutation gate exists because `curl -XPOST localhost:4317/…/admin` handed
5040
+ * a live OAuth refresh token to a sandboxed subprocess with loopback egress.
5041
+ * The same caller could still `curl localhost:4317/api/events` and read the
5042
+ * whole ring — prompt text, the Bash command lines the agent ran, the paths and
5043
+ * contents it wrote, the contents of every file it read back — plus the account
5044
+ * roster and the browsing episodes. The threat model had been applied to half
5045
+ * the surface.
5046
+ *
5047
+ * Same shape as isAuthorizedMutation, with one difference forced by the
5048
+ * browser: a same-origin GET carries no `Origin` header at all, so the UI
5049
+ * cannot be recognised the way a POST is. `Sec-Fetch-Site: same-origin` is what
5050
+ * a page's own fetch and its EventSource both send, on every browser new enough
5051
+ * to run this bundle, and it is a header no non-browser client sends by
5052
+ * accident. A caller that sends neither it nor the token is not a page.
5053
+ *
5054
+ * WHAT STAYS OPEN, deliberately: /api/health (the hook's readiness probe),
5055
+ * /api/hook-challenge (the handshake itself), the static files, and every
5056
+ * measurement route — the machine panel's numbers are about the machine, not
5057
+ * about what the user is doing on it.
5058
+ */
5059
+ function isAuthorizedDataRead(req) {
5060
+ const headers = req?.headers ?? {};
5061
+ if (presentsDeckToken(headers)) return true;
5062
+ // Addressed to this machine by a name that can only be this machine — so a
5063
+ // rebound page, which also reports same-origin, does not qualify.
5064
+ if (!isLoopbackHost(headers.host)) return false;
5065
+
5066
+ const site = typeof headers["sec-fetch-site"] === "string"
5067
+ ? headers["sec-fetch-site"].trim().toLowerCase() : "";
5068
+ if (site === "same-origin") return true;
5069
+ // ANY FETCH METADATA AT ALL, AND IT SAID SOMETHING ELSE. `cross-site`,
5070
+ // `same-site` and `none` are all a page that is not this one — or a top-level
5071
+ // navigation typed into the address bar, which has no business reading the
5072
+ // ring.
5073
+ if (site !== "") return false;
5074
+
5075
+ // THE BROWSER THAT SENDS NO FETCH METADATA, and this is the whole reason this
5076
+ // branch exists. Sec-Fetch-Site is Safari 16.4 and newer; Safari 16.0-16.3
5077
+ // runs this bundle perfectly well (Vite's default target is Safari 16) and
5078
+ // sends none of it. Without a fallback those users get an empty canvas and a
5079
+ // 401 they cannot act on — an impediment for a browser that is otherwise
5080
+ // fine.
5081
+ //
5082
+ // Referer is what they do send, on a page's own fetches and on its
5083
+ // EventSource, and it must name THIS origin. A cross-site page's Referer
5084
+ // names its own; a rebound page's names the attacker's host, which is not a
5085
+ // loopback identity. It is forgeable by a non-browser client — and so is
5086
+ // Sec-Fetch-Site, which curl sets as easily; neither is the control that
5087
+ // stops a deliberate local caller. That control is the token, and this only
5088
+ // decides which BROWSERS are recognised as the deck's own page.
5089
+ return originMatchesHost(headers.referer, headers.host);
5090
+ }
5091
+
5092
+ /** The reads that carry the user's own work, rather than the machine's. */
5093
+ const GUARDED_READS = new Set([
5094
+ "/events",
5095
+ "/api/events",
5096
+ "/api/claude-accounts",
5097
+ "/api/claude-accounts/login",
5098
+ "/api/browser-watch",
5099
+ ]);
5100
+
4993
5101
  function isAuthorizedMutation(req) {
4994
5102
  const headers = req?.headers ?? {};
4995
5103
  if (presentsDeckToken(headers)) return true;
@@ -5200,6 +5308,14 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
5200
5308
  return send(res, 401, { error: "unauthenticated" });
5201
5309
  }
5202
5310
 
5311
+ // And the reads that carry the same secrets. See isAuthorizedDataRead: the
5312
+ // gate above was written for a sandboxed subprocess with loopback egress,
5313
+ // and that caller was reading the ring through a GET the whole time.
5314
+ if ((req.method === "GET" || req.method === "HEAD")
5315
+ && GUARDED_READS.has(url.pathname) && !isAuthorizedDataRead(req)) {
5316
+ return send(res, 401, { error: "unauthenticated" });
5317
+ }
5318
+
5203
5319
  // `?persist=0` — another deck was elected to write this event to the log
5204
5320
  // the two of them share. Absent, this deck writes it.
5205
5321
  if (req.method === "POST" && url.pathname === "/api/event") return guard(handleEventIngest(req, res, url.searchParams.get("persist") !== "0"), res);
@@ -353,7 +353,31 @@ async function writeFileAtomic(rawTarget, text) {
353
353
  // to stay 600. No-op on Windows, where chmod only toggles the read-only bit.
354
354
  const mode = await stat(target).then(s => s.mode, () => null);
355
355
  if (mode !== null) await chmod(tmp, mode).catch(() => {});
356
+ // A READ-ONLY TARGET IS A DEAD END ON WINDOWS, and only there. libuv's
357
+ // rename is one MoveFileExW(MOVEFILE_REPLACE_EXISTING), which refuses to
358
+ // replace a destination carrying FILE_ATTRIBUTE_READONLY; POSIX rename(2)
359
+ // over a 0444 file succeeds, because only the parent directory's write bit
360
+ // decides. A settings.json picks that attribute up from a OneDrive restore,
361
+ // a copy off a network share, or read-only media — and EACCES is in the
362
+ // retry ladder, so the whole ~1.4s was spent before throwing, on every
363
+ // boot, forever. Every settings writer goes through here, so hooks never
364
+ // installed and the sound-hook retirement could never repair a stale entry
365
+ // either.
366
+ //
367
+ // chmod on Windows toggles exactly that attribute and nothing else, which
368
+ // is why this is safe to do unconditionally there: the mode carried above
369
+ // is re-applied to the new file after the rename, so a file the user marked
370
+ // read-only stays read-only.
371
+ // ONLY WHEN THE TARGET IS ACTUALLY READ-ONLY. Two extra syscalls on the
372
+ // path between the temp write and the rename are not free on Windows:
373
+ // discovery-live.test.ts hammers writeDiscovery while a reader holds the
374
+ // destination open, and the wider window turned a rename the retry ladder
375
+ // used to win into an EPERM it gave up on. The attribute is what this
376
+ // clears, so a file that does not carry it has nothing to clear.
377
+ const readOnly = process.platform === "win32" && mode !== null && (mode & 0o200) === 0;
378
+ if (readOnly) await chmod(target, 0o666).catch(() => {});
356
379
  await renameWithRetry(tmp, target);
380
+ if (readOnly) await chmod(target, mode).catch(() => {});
357
381
  } catch (err) {
358
382
  // Cleanup covers the write and the fsync as well as the rename: a full disk
359
383
  // used to leave the half-written temp file sitting beside the target.
@@ -405,7 +429,7 @@ function dedupeOurEntries(group) {
405
429
  }
406
430
 
407
431
  /** Install hooks for a single provider. Returns {settingsPath, hookPath, events, changed}. */
408
- export async function installHooks({ provider = "claude" } = {}) {
432
+ export async function installHooks({ provider = "claude", beforeWrite = null } = {}) {
409
433
  const cfg = PROVIDERS[provider];
410
434
  if (!cfg) throw new Error(`unknown provider: ${provider}`);
411
435
  // An uninstall-only provider has no event list. Saying so beats the
@@ -481,7 +505,38 @@ export async function installHooks({ provider = "claude" } = {}) {
481
505
  // compare against the exact bytes we read and, when they match, do nothing.
482
506
  const next = JSON.stringify(current, null, 2) + "\n";
483
507
  const changed = next !== before;
484
- if (changed) await writeFileAtomic(cfg.settingsPath, next);
508
+ if (changed) {
509
+ // COMPARE AGAINST THE FILE, NOT AGAINST THE SNAPSHOT, at the last moment.
510
+ //
511
+ // Everything above was computed from bytes read at the top of this
512
+ // function, and two decks booting together — the ordinary case on a machine
513
+ // where one was already running — interleave inside that window. The one
514
+ // that loses is unrecoverable rather than merely stale: deck A restores the
515
+ // user's own sound hooks from the parked file and deletes the park, and
516
+ // deck B then writes a settings object computed before that restore, with
517
+ // an empty park behind it. The user's hook is gone from settings.json and
518
+ // from the only other copy of it.
519
+ //
520
+ // So the file is re-read immediately before the write and, if another
521
+ // writer has touched it, this pass declines. Declining is safe by
522
+ // construction: every boot reinstalls, so the next one recomputes against
523
+ // the new bytes and converges — and the entries this function adds are
524
+ // identical on both decks, which is why the loser has nothing of its own to
525
+ // lose.
526
+ // The seam the suite needs, and the only way to test this deterministically:
527
+ // the window between the read at the top and the write below is filled with
528
+ // real fs work, so a test that raced it by wall clock would pass or fail by
529
+ // how fast the machine is. Production passes nothing.
530
+ if (beforeWrite) await beforeWrite();
531
+ const { raw: onDisk } = await readSettingsForWrite(cfg.settingsPath).catch(() => ({ raw: before }));
532
+ if (onDisk !== before) {
533
+ return {
534
+ settingsPath: cfg.settingsPath, hookPath, events: cfg.events, provider,
535
+ changed: false, raced: true, retire: { ...retire, pending: false },
536
+ };
537
+ }
538
+ await writeFileAtomic(cfg.settingsPath, next);
539
+ }
485
540
  // After the write, never before it: the notify script an older deck installed
486
541
  // is what a live session's cached command still names until the new entry is
487
542
  // on disk, and deleting it early turns a stale sound into a missing module.
@@ -773,4 +828,4 @@ export { AGENT_DAG_DIR, CLAUDE_DIR, CODEX_DIR };
773
828
  // repo for the same reasons settings.json is, and "never rename onto a link" is
774
829
  // one rule: codex-auth.mjs called a realpath of its own before this existed, and
775
830
  // two spellings of a rule are two things that can drift.
776
- export { readSettingsForWrite, writeFileAtomic, installScript, renameWithRetry, createTemp, resolveWriteTarget };
831
+ export { readSettingsForWrite, writeFileAtomic, renameWithRetry, createTemp, resolveWriteTarget };
@@ -193,11 +193,6 @@ export function macmonAsset(release) {
193
193
  return { version: typeof tag === "string" ? tag : "unknown", url: a.browser_download_url, sha256: m[1] };
194
194
  }
195
195
 
196
- /** A macmon this function installed earlier, or null. */
197
- export function existingBootstrappedMacmon() {
198
- const bin = join(MACMON_DIR, "macmon");
199
- return existsSync(bin) ? bin : null;
200
- }
201
196
 
202
197
  /**
203
198
  * Download macmon into ~/.agents-deck/tools/macmon.
@@ -210,9 +205,25 @@ export function existingBootstrappedMacmon() {
210
205
  * ioreg and never reaches this file at all.
211
206
  */
212
207
  export async function bootstrapMacmon({
213
- platform = process.platform, env = process.env, fetchFn = fetch, dir = MACMON_DIR,
208
+ platform = process.platform, arch = process.arch, env = process.env,
209
+ fetchFn = fetch, dir = MACMON_DIR, findBin = macmonBin,
214
210
  } = {}) {
215
211
  if (platform !== "darwin") return { ok: false, reason: "unsupported_platform" };
212
+ // ARM64 ONLY, checked rather than merely documented. The release publishes
213
+ // one asset and it is an arm64 Mach-O, so an Intel Mac downloaded 746 KB it
214
+ // could not execute and failed its own --version check afterwards. That is
215
+ // the small half. The larger half is the promise: the README says an Intel
216
+ // Mac never downloads anything, and this reached api.github.com on any Mac
217
+ // whose sensors happened to stay silent — which an Intel Mac's do whenever
218
+ // ioreg publishes no Temperature(C).
219
+ if (arch !== "arm64") return { ok: false, reason: "unsupported_arch" };
220
+ // A macmon the user already has is the other thing the README promises to
221
+ // notice, and until now the skip was emergent rather than checked: a working
222
+ // copy produces a reading, the reading sets thermalEverAnswered, and the
223
+ // give-up branch never fires. That chain breaks on a macmon which runs but
224
+ // reports values this deck rejects as implausible — and then a machine with
225
+ // macmon on PATH downloaded a second one.
226
+ if (await findBin()) return { ok: false, reason: "already_installed" };
216
227
  // Both switches, for the reason uv-bootstrap has both: downloading an
217
228
  // executable is a bigger step than installing a package with a tool the user
218
229
  // already chose, so somebody may want the managed installs and not this.
@@ -194,7 +194,12 @@ export function npxPrefetch(spec, {
194
194
  timer.unref?.();
195
195
 
196
196
  child.on("error", err => settle({ ok: false, error: `could not run npx: ${err?.message ?? err}`, hint: null }));
197
- child.on("exit", (code, signal) => {
197
+ // 'close' rather than 'exit': 'exit' fires when the process ends and says
198
+ // nothing about its pipes, so past one pipe buffer the tail this kept —
199
+ // deliberately the TAIL, because npm's reason is on its last lines — was
200
+ // still only the first 8 KiB when npxFailureSummary read it. 'close' is
201
+ // emitted after both streams have been drained.
202
+ child.on("close", (code, signal) => {
198
203
  if (code === 0) { settle({ ok: true, error: null, hint: null }); return; }
199
204
  settle({
200
205
  ok: false,
@@ -166,11 +166,31 @@ export function startCommand(url, env = process.env, comspec) {
166
166
  * it and `start` would run it.
167
167
  */
168
168
  export function isOpenable(url) {
169
+ return normalizeOpenable(url) !== null;
170
+ }
171
+
172
+ /**
173
+ * The address to actually hand the desktop, or null when it is not one.
174
+ *
175
+ * The guard used to parse into a local `u`, check its protocol and throw the
176
+ * parse away — so what reached `launchers` was the RAW string. `new URL()`
177
+ * accepts characters it normalizes only in `href`, a `"` among them, and on
178
+ * Windows `startCommand` builds `start "" "<url>"` with
179
+ * `windowsVerbatimArguments: true`, where a quote is syntax. Today the only
180
+ * caller passes a loopback URL this deck built itself, so nothing is reachable;
181
+ * the function's own doc says it exists "for the day a second caller passes a
182
+ * path", and as written it would not have stopped that caller.
183
+ *
184
+ * Returning the normalized `href` means the string that was checked is the
185
+ * string that is launched.
186
+ */
187
+ export function normalizeOpenable(url) {
169
188
  try {
170
189
  const u = new URL(String(url));
171
- return u.protocol === "http:" || u.protocol === "https:";
190
+ if (u.protocol !== "http:" && u.protocol !== "https:") return null;
191
+ return u.href;
172
192
  } catch {
173
- return false;
193
+ return null;
174
194
  }
175
195
  }
176
196
 
@@ -183,8 +203,9 @@ export function isOpenable(url) {
183
203
  * the same recovery an error row would have offered.
184
204
  */
185
205
  export function openUrl(url, { platform = process.platform, env = process.env, spawnFn = spawn } = {}) {
186
- if (!isOpenable(url)) return;
187
- const tries = launchers(url, { platform, env });
206
+ const href = normalizeOpenable(url);
207
+ if (href === null) return;
208
+ const tries = launchers(href, { platform, env });
188
209
 
189
210
  const attempt = (i) => {
190
211
  if (i >= tries.length) return;