agent-dag 1.43.0 → 1.44.1

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.
@@ -48,6 +48,85 @@ let _cacheAt = 0;
48
48
  // tracks what claude-swap is doing. No network cost to amortise.
49
49
  const CACHE_MS = 5_000;
50
50
 
51
+ // ── what a forced read may cost ──────────────────────────────────────────────
52
+ //
53
+ // Until #604 the cache above was the whole of the admission control here, and
54
+ // `force` walked straight past it. `handleClaudeAccounts` reads `refresh=1` off
55
+ // the query string and passes it through, and reads on this server are
56
+ // deliberately open — `isTrustedRead` does not apply the `Sec-Fetch-Site` test
57
+ // that `isTrustedMutation` does, because a cross-site read of
58
+ // `http://127.0.0.1:4317` is an ordinary top-level navigation — so any page the
59
+ // user had open could run a `?refresh=1` loop and get one roster read per
60
+ // request, concurrently.
61
+ //
62
+ // What that buys is the cheapest of the six forcible routes and it is worth
63
+ // saying so plainly rather than dressing it up: two small local JSON reads,
64
+ // sequence.json and cache/usage.json, plus a call into nudgeCollector that is
65
+ // throttled on its own terms and spawns nothing when nothing is due. There is no
66
+ // network here by design — see the note at the top of this file — and no
67
+ // subprocess per request. This is the SHAPE the four routes before it were fixed
68
+ // for, not a cost anyone would have noticed.
69
+ //
70
+ // The reason to fix it anyway is the second half. This is the only one of the
71
+ // six whose cache is invalidated from elsewhere — nine call sites in four
72
+ // modules: six mutations in cswap-admin.mjs, an auto-switch in cswap-auto.mjs, a
73
+ // manual one in index.mjs, and the first `cswap add` below — and #582 has
74
+ // already shown what a read that started before an invalidation does when it
75
+ // lands after one. So the in-flight slot this route was missing arrives with the
76
+ // generation guard that makes it safe, rather than after the next bug report.
77
+ const FORCE_POLL_MS = 60_000;
78
+
79
+ // A read in progress, offered to callers that arrive while it is running.
80
+ let _inflight = null;
81
+ // Stamped when a read STARTS rather than when it lands: what the floor rations
82
+ // is the trip to disk, and one that is still running has already been paid for.
83
+ let _lastReadAt = 0;
84
+ // Which roster the reading below is about — as a counter, because the answer is
85
+ // about whichever account claude-swap's store says is active, and that can move
86
+ // under a read that is already running. invalidateClaudeAccountsCache bumps it;
87
+ // every write is stamped with the value that was current when the read STARTED.
88
+ // quota.mjs's `_generation`, for quota.mjs's reason (#582).
89
+ let _generation = 0;
90
+
91
+ /**
92
+ * Whether we may go to disk for the roster again.
93
+ *
94
+ * The same shape and the same minute as quota.mjs's `maySelfPoll`, and exported
95
+ * for the same reason it is: this is the rule, it is pure, and it belongs
96
+ * somewhere a test can point at it.
97
+ *
98
+ * Two intervals, like `maySelfPoll`'s. A forced read may beat the cache, but not
99
+ * turn into a poll loop when the button is held down, so it takes the minute the
100
+ * other four forcible routes use. An unforced read takes the cache's own
101
+ * interval — it is the panel's ordinary poll, the cache above has already
102
+ * answered it, and measuring from the START of the last read rather than from
103
+ * its end is the only difference between the two rules.
104
+ */
105
+ export function mayReadAccounts({ now, force, lastReadAt }) {
106
+ return now - lastReadAt >= (force ? FORCE_POLL_MS : CACHE_MS);
107
+ }
108
+
109
+ /**
110
+ * The answer to a read the floor refused.
111
+ *
112
+ * A reading, not an error. AccountsPanel renders `data.accounts`, and an
113
+ * `{ ok: false }` refusal would empty the roster for a minute — the deck
114
+ * teaching itself a new failure mode in order to defend against a loop nobody
115
+ * ran. `stale` is the flag quota.mjs, codex-quota.mjs and codex-usage.mjs all
116
+ * use for exactly this, and every account row already carries its own
117
+ * `fetchedAt` from claude-swap's store, which is the age the panel draws and
118
+ * which nothing here touches.
119
+ */
120
+ function heldReading(now) {
121
+ if (_cache) return { ..._cache, stale: true };
122
+ // Unreachable in practice, and spelled the way codex-quota.mjs and
123
+ // codex-usage.mjs spell the same state. Every outcome below is cached, a read
124
+ // still running is served by `_inflight`, and the one moment `_cache` is empty
125
+ // with a recent stamp — just after an invalidation — is exactly the moment
126
+ // invalidateClaudeAccountsCache clears the stamp as well.
127
+ return { ok: false, reason: "waiting", fetchedAt: now };
128
+ }
129
+
51
130
  // Past this, claude-swap's own numbers are old enough that showing them
52
131
  // without a marker would misrepresent them (its own trust ceiling is 3600s).
53
132
  const STALE_AFTER_MS = 15 * 60_000;
@@ -255,8 +334,51 @@ function lane(id, label, win) {
255
334
  export async function fetchClaudeAccounts({ force = false } = {}) {
256
335
  const now = Date.now();
257
336
  if (!force && _cache && now - _cacheAt < CACHE_MS) return _cache;
337
+ // Offered before the floor: a read that has not finished yet is a reading
338
+ // newer than the cache, which is what refresh asked for, and joining it costs
339
+ // nothing.
340
+ if (_inflight) return _inflight;
341
+ if (!mayReadAccounts({ now, force, lastReadAt: _lastReadAt })) return heldReading(now);
342
+ _lastReadAt = now;
343
+
344
+ // `_inflight === mine` rather than a bare clear, which is quota.mjs's guard
345
+ // and is here for quota.mjs's reason: invalidateClaudeAccountsCache drops
346
+ // `_inflight` so the next caller starts a read that knows the roster moved,
347
+ // and that read installs its own promise here. A read from before the switch
348
+ // finishing afterwards would otherwise clear the NEW one on its way out.
349
+ const mine = readRoster(now, _generation)
350
+ .finally(() => { if (_inflight === mine) _inflight = null; });
351
+ _inflight = mine;
352
+ return mine;
353
+ }
258
354
 
259
- const finish = (r) => { _cache = r; _cacheAt = Date.now(); return r; };
355
+ /**
356
+ * The read itself, split out from the admission control above it so the guard is
357
+ * readable as the four lines it is.
358
+ *
359
+ * `gen` is the generation that was current when this read STARTED, and finish()
360
+ * refuses to write anything under it once that has moved.
361
+ *
362
+ * Every write here happens after at least one await, and
363
+ * invalidateClaudeAccountsCache clears variables — which does nothing to a
364
+ * function that is already running and still holds the old roster in a local. So
365
+ * a switch landing mid-read was followed, milliseconds later, by the pre-switch
366
+ * roster being written straight back over the cleared cache: the invalidation
367
+ * looked like it worked and was undone before the next poll could observe it,
368
+ * and the panel went on showing the account the user had just switched away
369
+ * from. That is #582's defect, in the module #582 did not touch.
370
+ *
371
+ * The read is deliberately not cancelled. Whoever asked for it is still owed an
372
+ * answer, and the answer is not wrong — it is about a roster that has since
373
+ * moved, which makes it a fine return value and a bad cached one.
374
+ */
375
+ async function readRoster(now, gen) {
376
+ const finish = (r) => {
377
+ if (gen !== _generation) return r;
378
+ _cache = r;
379
+ _cacheAt = Date.now();
380
+ return r;
381
+ };
260
382
 
261
383
  const root = backupRoot();
262
384
  const seq = await readJson(join(root, "sequence.json"));
@@ -395,9 +517,31 @@ export async function requestCollection() {
395
517
  return _lastNudge !== before;
396
518
  }
397
519
 
520
+ /**
521
+ * Forget the roster, because something just made it wrong.
522
+ *
523
+ * Called from nine places in four modules — every `cswap` mutation the deck
524
+ * performs — and every one of them is behind a POST that `isTrustedMutation`
525
+ * guards, so nothing a page can send in a loop reaches this.
526
+ *
527
+ * Three things go besides the reading itself:
528
+ *
529
+ * `_generation` so a read that STARTED before this call cannot write its
530
+ * answer into the cache afterwards. See finish() in readRoster.
531
+ * `_inflight` so a caller arriving after the switch is not handed the read
532
+ * that began before it — joining a run is only free when the
533
+ * run is still about the right thing.
534
+ * `_lastReadAt` so the very next read is real work rather than a refusal.
535
+ * Every one of these call sites is followed by the panel
536
+ * reloading with ?refresh=1, and a floor that answered THAT
537
+ * with the pre-switch roster would make the guard the bug.
538
+ */
398
539
  export function invalidateClaudeAccountsCache() {
399
540
  _cache = null;
400
541
  _cacheAt = 0;
542
+ _generation++;
543
+ _inflight = null;
544
+ _lastReadAt = 0;
401
545
  }
402
546
 
403
547
  /**
@@ -243,9 +243,84 @@ async function requestUsage(base, auth) {
243
243
  // to race over the single-use refresh token.
244
244
  let _inflight = null;
245
245
 
246
+ // ── what a forced read may cost ────────────────────────────────────────────
247
+ // The floor between two reads WE pay for, and it is the same number and the
248
+ // same rule quota.mjs gives the Claude half — see FORCE_POLL_MS and maySelfPoll
249
+ // there. The two routes are four lines apart in the router and had no business
250
+ // disagreeing about what `?refresh=1` costs.
251
+ //
252
+ // `force` used to mean "skip the cache", and the cache was the ONLY thing
253
+ // between a caller and chatgpt.com. `_inflight` deduplicates callers that
254
+ // overlap and nothing else, so a caller that waits for one fetch to settle and
255
+ // then asks again got a fresh round trip every time — two authenticated HTTPS
256
+ // GETs carrying the user's live ChatGPT session, as fast as the round trip
257
+ // allows, from any page the user happens to have open (#580). Reads on this
258
+ // server are deliberately open (isTrustedRead), so "any page" is the real
259
+ // threat model rather than a hypothetical one.
260
+ //
261
+ // The sharper half is the credential rather than the traffic. On a 401
262
+ // doFetchCodexQuota spends the SINGLE-USE refresh token via forceCodexRefresh,
263
+ // and `staleAccessToken` only stops that happening twice for the same rejected
264
+ // token — every turn re-reads auth.json and sees the token the previous turn
265
+ // rotated to, so a backend that keeps answering 401 rotated a fresh credential
266
+ // once per request, racing the Codex CLI for each one. codex-auth.mjs's own
267
+ // EXPIRY_SKEW_MS comment says what losing that race costs the user: a
268
+ // `refresh_token_reused` that reads as "your login is broken", recoverable only
269
+ // with `codex login`.
270
+ const FORCE_POLL_MS = 60_000;
271
+
272
+ // Set from a 429 or a rejected refresh: a backend that is refusing us must not
273
+ // be asked once per request, whoever is asking. Same shape as quota.mjs's
274
+ // _rateLimitedUntil, which is likewise never beaten by force.
275
+ let _rateLimitedUntil = 0;
276
+ const COOLDOWN_MS = 5 * 60_000;
277
+
278
+ // Stamped when a fetch STARTS rather than when it lands: what the floor is
279
+ // rationing is the round trip, and one that is still in flight has already been
280
+ // paid for.
281
+ let _lastFetchAt = 0;
282
+
283
+ /**
284
+ * Whether we may spend a request of the user's ChatGPT session right now.
285
+ *
286
+ * Exported for tests, for the same reason quota.mjs exports maySelfPoll: this
287
+ * is the rule, it is pure, and it is worth pinning down away from the fetch it
288
+ * guards.
289
+ */
290
+ export function mayFetchQuota({ now, lastFetchAt, rateLimitedUntil }) {
291
+ if (now < rateLimitedUntil) return false;
292
+ return now - lastFetchAt >= FORCE_POLL_MS;
293
+ }
294
+
295
+ /**
296
+ * The answer to a read the floor refused.
297
+ *
298
+ * A reading, not an error — a user who clicks ↻ twice in a second must get the
299
+ * numbers they already have rather than a red hint, which is exactly what
300
+ * quota.mjs does with `{ ...held, stale: true }`. The timestamp stays the one
301
+ * the data was fetched at, so the panel's age label keeps telling the truth
302
+ * instead of vouching for a reading it did not take.
303
+ */
304
+ function heldReading(now) {
305
+ if (_cache) return { ..._cache, stale: true };
306
+ // Only reachable before the first fetch has ever landed — `finish` caches
307
+ // every outcome, failures included — and spelled the way the Claude side
308
+ // spells the same two states.
309
+ return { ok: false, reason: now < _rateLimitedUntil ? "rate_limited" : "waiting", fetchedAt: now };
310
+ }
311
+
246
312
  export function fetchCodexQuota({ force = false } = {}) {
247
- if (!force && _cache && Date.now() - _cacheAt < CACHE_MS) return Promise.resolve(_cache);
248
- _inflight ??= doFetchCodexQuota().finally(() => { _inflight = null; });
313
+ const now = Date.now();
314
+ if (!force && _cache && now - _cacheAt < CACHE_MS) return Promise.resolve(_cache);
315
+ // Joining a run already in flight costs nothing, so it is offered before the
316
+ // floor: what refresh asks for is a reading newer than the cache, and a fetch
317
+ // that has not landed yet is one.
318
+ if (_inflight) return _inflight;
319
+ if (!mayFetchQuota({ now, lastFetchAt: _lastFetchAt, rateLimitedUntil: _rateLimitedUntil })) {
320
+ return Promise.resolve(heldReading(now));
321
+ }
322
+ _lastFetchAt = now;
323
+ _inflight = doFetchCodexQuota().finally(() => { _inflight = null; });
249
324
  return _inflight;
250
325
  }
251
326
 
@@ -256,6 +331,13 @@ async function doFetchCodexQuota() {
256
331
  // shortens the effective TTL for no reason.
257
332
  const finish = (r) => { _cache = r; _cacheAt = Date.now(); return r; };
258
333
  const fail = (reason) => finish({ ok: false, reason, fetchedAt: started });
334
+ // A refusal we were told about, rather than one we inferred: back off further
335
+ // than the ordinary floor before asking again. `retry-after` is honoured when
336
+ // the backend sends one, because it knows better than the constant does.
337
+ const cooldown = (res) => {
338
+ const after = parseInt(res?.headers?.get?.("retry-after") ?? "", 10);
339
+ _rateLimitedUntil = Date.now() + (Number.isFinite(after) ? after * 1000 : COOLDOWN_MS);
340
+ };
259
341
 
260
342
  let auth, base, res;
261
343
  try {
@@ -294,12 +376,22 @@ async function doFetchCodexQuota() {
294
376
  // destroyed.
295
377
  if (res.status === 401) {
296
378
  const refreshed = await forceCodexRefresh(auth.accessToken);
297
- if (!refreshed.ok) return fail(refreshed.reason);
379
+ if (!refreshed.ok) {
380
+ // The credential is gone and only `codex login` brings it back, so
381
+ // rotating another single-use token at the next request would burn the
382
+ // one the CLI is still holding. Wait.
383
+ if (refreshed.reason === "refresh_rejected") cooldown(null);
384
+ return fail(refreshed.reason);
385
+ }
298
386
  auth = refreshed;
299
387
  res = await requestUsage(base, auth);
300
388
  }
301
389
 
302
390
  if (!res.ok) {
391
+ // A second 401 means the token we just rotated to was rejected as well —
392
+ // the case that turned into one rotation per request. 429 is the backend
393
+ // saying the same thing in the ordinary way.
394
+ if (res.status === 401 || res.status === 429) cooldown(res);
303
395
  return fail(res.status === 401 ? "refresh_rejected" : `http_${res.status}`);
304
396
  }
305
397
  } catch (err) {
@@ -12,6 +12,88 @@ let _cache = null;
12
12
  let _cacheAt = 0;
13
13
  const CACHE_MS = 60_000;
14
14
 
15
+ // ── what a forced read may cost ─────────────────────────────────────────────
16
+ // Until #600 the cache above was the whole of the admission control here, and
17
+ // `force` walked straight past it. That made one GET worth a full week of the
18
+ // rollout tree: listRolloutFiles(WINDOW_7D_MS) and then a read of every file it
19
+ // returns. Measured on this repo's machine against 280 rollouts of ~90KB — a
20
+ // week of ordinary use — one forced call is 685ms, 280 file opens and a peak of
21
+ // four descriptors. Nothing bounded how many of those calls ran at once, and
22
+ // the cost scaled exactly linearly: 16 concurrent forced reads were 4,480 opens
23
+ // and 64 descriptors, 128 were 35,840 opens, 512 descriptors and 54.7s.
24
+ //
25
+ // Reads on this server are deliberately open — `isTrustedRead` does not apply
26
+ // the `Sec-Fetch-Site` test that `isTrustedMutation` does, because a cross-site
27
+ // read of `http://127.0.0.1:4317` is an ordinary top-level navigation — so any
28
+ // page the user has open could run
29
+ //
30
+ // for (;;) fetch("http://127.0.0.1:4317/api/codex-usage?refresh=1",
31
+ // { mode: "no-cors" });
32
+ //
33
+ // and get one of those scans per request. MAX_PARALLEL_READS below bounds the
34
+ // fan-out WITHIN one call, and it exists because opening a week of rollouts at
35
+ // once risked EMFILE — which readTokenSeries swallows into `return null`, a
36
+ // silent undercount rather than an error. Unbounded calls let that EMFILE back
37
+ // in through the door the pool does not cover.
38
+ //
39
+ // The two things between a caller and a scan are the ones quota.mjs established
40
+ // and codex-quota.mjs adopted in #597, spelled the same way in all three:
41
+ //
42
+ // _inflight — callers that overlap wait on the one scan already running,
43
+ // `force` included. What ?refresh=1 asks for is a reading
44
+ // newer than the cache, and a scan in progress is one, so
45
+ // joining it costs nothing and is offered before the floor.
46
+ // FORCE_POLL_MS — the minimum interval between two scans WE pay for.
47
+ // `_inflight` deduplicates callers that overlap and nothing
48
+ // else, so a caller that waits for one scan to settle and then
49
+ // asks again was a fresh week of the disk every time.
50
+ //
51
+ // This module has neither of the extra parts its two siblings carry, and
52
+ // deliberately: there is no upstream backend to rate-limit us, so no cooldown,
53
+ // and nothing outside this file invalidates the cache, so no generation guard.
54
+ let _inflight = null;
55
+
56
+ // Stamped when a scan STARTS rather than when it lands: what the floor rations
57
+ // is the walk of the disk, and one that is still running has already been paid
58
+ // for.
59
+ let _lastScanAt = 0;
60
+
61
+ // quota.mjs's number, and codex-quota.mjs's, for the reason those two give it:
62
+ // "The refresh button may beat that floor, but not turn into a poll loop when
63
+ // held down." Three routes within a few lines of each other in the router have
64
+ // no business disagreeing about what `?refresh=1` costs.
65
+ const FORCE_POLL_MS = 60_000;
66
+
67
+ /**
68
+ * Whether we may walk a week of the rollout tree right now.
69
+ *
70
+ * Exported for tests, for the same reason quota.mjs exports `maySelfPoll` and
71
+ * codex-quota.mjs exports `mayFetchQuota`: this is the rule, it is pure, and it
72
+ * is worth pinning down away from the scan it guards.
73
+ */
74
+ export function mayScanUsage({ now, lastScanAt }) {
75
+ return now - lastScanAt >= FORCE_POLL_MS;
76
+ }
77
+
78
+ /**
79
+ * The answer to a read the floor refused.
80
+ *
81
+ * A reading, not an error. The panel draws this number from `codexUsage?.ok &&
82
+ * window7d.sessionCount > 0`, so an `{ ok: false }` refusal would make the token
83
+ * line vanish for a minute — the deck teaching itself a new failure mode in
84
+ * order to defend against a loop nobody ran. `stale` is the flag quota.mjs and
85
+ * codex-quota.mjs both use for exactly this, and `fetchedAt` keeps the moment
86
+ * the DATA was read rather than the moment of the read that was refused, so an
87
+ * age label drawn from it never vouches for a scan that did not happen.
88
+ */
89
+ function heldReading(now) {
90
+ if (_cache) return { ..._cache, stale: true };
91
+ // Only reachable before the first scan has ever landed — every outcome below
92
+ // is cached, failures included, and a scan that is still running is served by
93
+ // `_inflight` — and spelled the way codex-quota.mjs spells the same state.
94
+ return { ok: false, reason: "waiting", fetchedAt: now };
95
+ }
96
+
15
97
  const WINDOW_5H_MS = 5 * 60 * 60 * 1000;
16
98
  const WINDOW_7D_MS = 7 * 24 * 60 * 60 * 1000;
17
99
 
@@ -206,10 +288,24 @@ function emptyWindow() {
206
288
  return { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, totalTokens: 0, sessionCount: 0 };
207
289
  }
208
290
 
209
- export async function fetchCodexUsage({ force = false } = {}) {
291
+ export function fetchCodexUsage({ force = false } = {}) {
210
292
  const now = Date.now();
211
- if (!force && _cache && now - _cacheAt < CACHE_MS) return _cache;
293
+ if (!force && _cache && now - _cacheAt < CACHE_MS) return Promise.resolve(_cache);
294
+ // Offered before the floor: a scan that has not finished yet is a reading
295
+ // newer than the cache, which is what refresh asked for, and joining it costs
296
+ // nothing.
297
+ if (_inflight) return _inflight;
298
+ if (!mayScanUsage({ now, lastScanAt: _lastScanAt })) return Promise.resolve(heldReading(now));
299
+ _lastScanAt = now;
300
+ // A bare clear rather than quota.mjs's `_inflight === mine` check: that guard
301
+ // is there because invalidateQuotaCache drops the slot mid-flight, and this
302
+ // module has no invalidator to race with. If one is ever added, it needs the
303
+ // same check adding with it.
304
+ _inflight = scanCodexUsage(now).finally(() => { _inflight = null; });
305
+ return _inflight;
306
+ }
212
307
 
308
+ async function scanCodexUsage(now) {
213
309
  const w5h = emptyWindow();
214
310
  const w7d = emptyWindow();
215
311
  const start5h = now - WINDOW_5H_MS;
@@ -17,10 +17,13 @@
17
17
  // first account's record. Nothing upstream prevents it, so every mutation here
18
18
  // goes through one mutex.
19
19
  import { AsyncLocalStorage } from "node:async_hooks";
20
+ import { existsSync } from "node:fs";
20
21
  import { readFile } from "node:fs/promises";
22
+ import { homedir } from "node:os";
21
23
  import { join } from "node:path";
22
- import { looksMissing, run, runDetached, runInteractive } from "./exec.mjs";
24
+ import { looksMissing, pathLookup, run, runDetached, runInteractive } from "./exec.mjs";
23
25
  import { backupRoot, invalidateClaudeAccountsCache } from "./claude-accounts.mjs";
26
+ import { claudeCliCandidates } from "./claude-dir.mjs";
24
27
  import { cswapBin } from "./cswap-install.mjs";
25
28
  import { PRODUCT } from "./brand.mjs";
26
29
 
@@ -77,8 +80,79 @@ export function withStoreLock(fn) {
77
80
  // import, remove, rename, reorder — failed with cmd.exe's "is not recognized",
78
81
  // while the read-only half of the panel worked, because it was already using
79
82
  // the resolver. Reported from Windows on 2026-08-14.
83
+
84
+ /**
85
+ * Which `claude` the account surface runs: the configured one, else the first
86
+ * candidate this machine actually has, else the bare name.
87
+ *
88
+ * WHY THIS IS NOT `AGENTS_DECK_CLAUDE ?? "claude"` ANY MORE (#570). That was
89
+ * the whole of this module's resolution, and it feeds every child the accounts
90
+ * panel starts — `claude auth status --json` for `currentIdentity`, and the
91
+ * `claude auth login` whose output the sign-in dialog reads a link out of. On a
92
+ * machine whose `claude` is at `~/.local/bin/claude` but whose deck was started
93
+ * from something that never sourced a shell rc — a LaunchAgent, a systemd user
94
+ * unit, pm2, a desktop shortcut — the bare name is an ENOENT, so the login
95
+ * child is dead within milliseconds, the flow reports `no_url`, and the dialog
96
+ * shows "the claude CLI could not be run: not on PATH. Set AGENTS_DECK_CLAUDE
97
+ * to its full path." That sentence is a real remedy and it is why this was a
98
+ * smaller bug than #553; it is still a request to spell out a path the deck had
99
+ * already found for itself, because `hasClaudeInstalled()` stat'ed that exact
100
+ * file at boot to decide this was a Claude machine, and since #553 the quota
101
+ * panel beside this one runs the same binary without being told anything.
102
+ *
103
+ * SO IT READS THE SAME LIST, ON THE SAME TERMS #553 SETTLED ON. The list is
104
+ * `claudeCliCandidates` in claude-dir.mjs, whose other two readers are
105
+ * `hasClaudeInstalled()` — the boot question this module's whole surface hangs
106
+ * off — and `quotaClaudeBin` in quota.mjs. This is the same question at a third
107
+ * site, so nothing here is decided again:
108
+ *
109
+ * - AGENTS_DECK_CLAUDE first, and it is the one thing that skips the list
110
+ * entirely. It is documented in the README as "full path to the `claude`
111
+ * CLI", it is what the failure message above tells people to set, and
112
+ * someone who set it has already been through this once — second-guessing
113
+ * them with a stat would be answering a question they have closed. An empty
114
+ * value reads as unset, the way `AGENTS_DECK_CSWAP` does in cswapBin.
115
+ * - Then the candidate list's own order, unchanged: PATH first on POSIX, the
116
+ * two known install directories first on Windows. Preferring a different
117
+ * copy would silently change which binary signs somebody in on every
118
+ * machine that has two, and a `claude auth login` that suddenly runs a
119
+ * different binary is a credential path, not a detail.
120
+ * - The bare name is only answered with when PATH actually holds it, and
121
+ * `pathLookup` is a yes/no gate rather than the path it found, so spawn's
122
+ * own resolution — and, on Windows, exec.mjs's PATHEXT walk, since `claude`
123
+ * there is `claude.exe` or `claude.cmd` and never the bare word — stays in
124
+ * charge of the PATH case exactly as before.
125
+ * - The absolute candidates are stat'ed only once PATH has come up empty, so
126
+ * the common case costs one stat rather than a directory walk. Against what
127
+ * follows it — a whole Claude Code process, and a browser sign-in a human
128
+ * is walking through — that is not a cost worth naming.
129
+ *
130
+ * Pure, with the platform, environment, home directory and existence check all
131
+ * parameters, so the Windows branch is checkable from the platforms this repo
132
+ * is actually developed on. Exported for that test rather than for a caller
133
+ * (#383): `claudeBin` below is the only one, and it hands back the real
134
+ * machine's answer.
135
+ */
136
+ export function adminClaudeBin(platform = process.platform, env = process.env,
137
+ home = homedir(), exists = existsSync) {
138
+ if (env.AGENTS_DECK_CLAUDE) return env.AGENTS_DECK_CLAUDE;
139
+ const sep = platform === "win32" ? "\\" : "/";
140
+ // process.env is case-insensitive on Windows; an injected plain object in a
141
+ // test is not, and %Path% is how the variable is actually spelled there.
142
+ const pathEnv = env.PATH ?? env.Path ?? env.path ?? "";
143
+ for (const c of claudeCliCandidates(platform, env, home)) {
144
+ if (c.includes(sep)) { if (exists(c)) return c; }
145
+ else if (pathLookup(c, platform, { pathEnv, exists })) return c;
146
+ }
147
+ // Nothing on PATH and nothing at any known install directory. The bare name
148
+ // is still the right last resort — POSIX `execvp` and cmd.exe's own search
149
+ // both deserve their turn at a layout no list here knows — and the ENOENT it
150
+ // produces is what failureText turns into the AGENTS_DECK_CLAUDE sentence.
151
+ return "claude";
152
+ }
153
+
80
154
  async function claudeBin() {
81
- return process.env.AGENTS_DECK_CLAUDE ?? "claude";
155
+ return adminClaudeBin();
82
156
  }
83
157
 
84
158
  /** Slot → email for everything currently in the store, plus the active slot. */
@@ -212,7 +286,71 @@ export function loginState() {
212
286
  return { state, url: url ?? null, error: error ?? null, account: account ?? null, expiresAt: expiresAt ?? null };
213
287
  }
214
288
 
289
+ /**
290
+ * What an address may be made of before it becomes an argv element.
291
+ *
292
+ * NOT AN RFC 5322 PARSER, and it should not be read as one. RFC 5322 permits
293
+ * quoted local parts, spaces inside them, comments in parentheses and bracketed
294
+ * address literals; a regex that accepted all of that would accept precisely the
295
+ * shapes this exists to keep out. The job here is narrower and worth stating
296
+ * plainly: keep a FLAG-SHAPED or WHITESPACE-BEARING string out of a spawn's
297
+ * argument vector. Anything it wrongly refuses is an address nobody has ever
298
+ * typed into this dialog; anything it wrongly accepts is inert as an argument,
299
+ * which is the only property being defended. Whether the address exists is
300
+ * Anthropic's question, asked a moment later by the CLI itself.
301
+ *
302
+ * `--email` was the field the alias allowlist below missed. `email.includes("@")`
303
+ * was the whole of its validation and the value came straight off the request
304
+ * body, so the two residuals exec.mjs documents were both reachable through it
305
+ * on Windows, where `claude` is a `.cmd` shim and the vector goes through
306
+ * `cmd.exe /d /s /c`: an interior newline is a command separator inside that one
307
+ * quoted line, and `%USERPROFILE%` expands inside quotes with no escape
308
+ * available. `"a@b\ncalc.exe"` and `"%USERPROFILE%@x"` both satisfy
309
+ * `includes("@")`, and both are payloads alias-charset.test.ts already pins as
310
+ * refused for the other field.
311
+ *
312
+ * The leading-character rule is the same argv-position rule ALIAS_OK now carries.
313
+ * `-x@y.z` starts with a dash, so a child parser reads it as an option rather
314
+ * than as the value of `--email`, and what happens next depends entirely on
315
+ * which options that CLI happens to define.
316
+ */
317
+ const EMAIL_OK =
318
+ /^[A-Za-z0-9][A-Za-z0-9._%+-]{0,63}@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$/;
319
+
320
+ // The SMTP forward-path limit. The pattern above bounds each PIECE — 64 for the
321
+ // local part, 63 per label — and a domain may carry any number of labels, so
322
+ // without this the whole is unbounded. A bound belongs here for the reason
323
+ // ALIAS_OK has one: on Windows the value ends up inside a single cmd.exe command
324
+ // line, which has a hard length limit of its own.
325
+ const EMAIL_MAX_LENGTH = 254;
326
+
327
+ /**
328
+ * The `--email` value `claude auth login` should be given, or a refusal.
329
+ *
330
+ * Three answers rather than two, and the third is the one that matters: `null`
331
+ * means NO ADDRESS WAS OFFERED, which is the only shape the deck's own dialog
332
+ * sends (`AddAccountDialog` posts a bare `{action:"login"}`) and which must stay
333
+ * an ordinary sign-in with no flag appended. A value that is present and
334
+ * unusable is refused outright instead of being quietly dropped: dropping it
335
+ * would run a DIFFERENT sign-in from the one that was asked for and call it a
336
+ * success, and this route is reachable by anything holding the deck token.
337
+ */
338
+ function loginEmailArg(email) {
339
+ if (email == null) return { ok: true, email: null };
340
+ if (typeof email !== "string") return { ok: false };
341
+ const clean = email.trim();
342
+ if (!clean) return { ok: true, email: null };
343
+ if (clean.length > EMAIL_MAX_LENGTH || !EMAIL_OK.test(clean)) return { ok: false };
344
+ return { ok: true, email: clean };
345
+ }
346
+
215
347
  export async function startLogin({ email } = {}) {
348
+ // Argv position is settled first, before any state moves. A refusal here must
349
+ // not cancel a sign-in that is already running — the caller asked for
350
+ // something the deck will not do, and the flow already in flight is not part
351
+ // of that bargain.
352
+ const wanted = loginEmailArg(email);
353
+ if (!wanted.ok) return { ok: false, reason: "bad_email", ...loginState() };
216
354
  // Registering is the half that writes to the store; interrupting it would
217
355
  // leave an account half-recorded, so that one is refused. A flow merely
218
356
  // waiting for a code is not precious — it is most often the one abandoned by
@@ -225,7 +363,7 @@ export async function startLogin({ email } = {}) {
225
363
  await cancelLogin();
226
364
  }
227
365
  if (!_starting) {
228
- _starting = spawnLogin(email).finally(() => { _starting = null; });
366
+ _starting = spawnLogin(wanted.email).finally(() => { _starting = null; });
229
367
  }
230
368
  const flow = await _starting;
231
369
 
@@ -277,7 +415,10 @@ async function spawnLogin(email) {
277
415
  const identity = await currentIdentity();
278
416
 
279
417
  const args = ["auth", "login"];
280
- if (typeof email === "string" && email.includes("@")) args.push("--email", email);
418
+ // Already through loginEmailArg, which is the only caller's boundary: this is
419
+ // either an address that cannot be read as a flag or null, and null is the
420
+ // ordinary case.
421
+ if (email) args.push("--email", email);
281
422
 
282
423
  const child = runInteractive(await claudeBin(), args, { timeout: LOGIN_TIMEOUT_MS });
283
424
  // A sign-in outlives the request that started it, so it can also outlive the
@@ -600,8 +741,34 @@ export async function removeAccount(num) {
600
741
  * list, and it closes the unbounded-length half too: an alias is a short name
601
742
  * shown instead of an email, so 64 characters is not a constraint anyone meets
602
743
  * by accident.
744
+ *
745
+ * The leading `(?!-)` is the half that allowlist missed, and it is not about
746
+ * quoting at all — it is about ARGV POSITION, which no amount of quoting fixes
747
+ * because the value arrives intact and is then read as syntax by the CHILD.
748
+ * `-` is in the character class, so `--unset` matched, and
749
+ * `setAlias(3, "--unset")` built ["alias", "3", "--unset"] — character for
750
+ * character claude-swap's own command for CLEARING an alias. Its `_alias_command`
751
+ * hands that vector to argparse, which sets `unset=True` and leaves `alias_name`
752
+ * as None; the store dropped the name, cswap printed "Removed alias for
753
+ * Account 3", exited 0, and the deck reported the rename as a success. Any other
754
+ * `-x` spelling is consumed the same way — `-h` prints help and exits 0, which
755
+ * also arrives here as a rename that worked.
756
+ *
757
+ * argparse does honour `--` as an end-of-options separator, so
758
+ * ["alias", "3", "--", "--unset"] would reach `set_alias` as data. It is
759
+ * deliberately not used: the separator only helps for the one child whose parser
760
+ * we can read, `claude auth login` is the other spawn on this route and its
761
+ * parser is not ours to verify, and a value the deck refuses outright cannot be
762
+ * mangled by a CLI that changes its mind later. The validator is the guard.
763
+ *
764
+ * Refusing a leading dash rather than requiring a leading alphanumeric is the
765
+ * narrower rule, and it is the one the hazard actually describes: `.env` and
766
+ * `_work` are ordinary positional arguments to every parser involved, while
767
+ * `acme-corp` — the one dash-bearing name in alias-charset.test.ts's list of
768
+ * names people use — keeps working because only the FIRST character is
769
+ * constrained.
603
770
  */
604
- const ALIAS_OK = /^[A-Za-z0-9 ._-]{1,64}$/;
771
+ const ALIAS_OK = /^(?!-)[A-Za-z0-9 ._-]{1,64}$/;
605
772
 
606
773
  export async function setAlias(num, alias) {
607
774
  const n = Number(num);
@@ -675,11 +842,18 @@ export async function moveAccount(num, slot) {
675
842
  * Windows it is cmd.exe's two-line "is not recognized …/operable program or
676
843
  * batch file.", and `firstUseful` — which takes the LAST line, correctly for
677
844
  * every other CLI — leaves the second half on screen by itself.
845
+ *
846
+ * The exit status goes to looksMissing beside the text (#552). This is the one
847
+ * caller with no candidate spelling to compare against, so the shape rules alone
848
+ * are all the TEXT can offer it — and on a non-English Windows the text says
849
+ * nothing this recognises. The status does: 9009 is cmd.exe's "no such command"
850
+ * in every language. Without it, a German user pressing "share…" got the last
851
+ * line of a translated sentence instead of the sentence about PATH.
678
852
  */
679
853
  export function failureText(r, what = "cswap") {
680
854
  const out = `${r?.stderr ?? ""}\n${r?.stdout ?? ""}`;
681
855
  const tool = String(what).split(" ")[0];
682
- if (r?.code === "ENOENT" || looksMissing(out)) {
856
+ if (r?.code === "ENOENT" || looksMissing(out, "", r?.code)) {
683
857
  return tool === "claude"
684
858
  ? "the claude CLI could not be run: not on PATH. Set AGENTS_DECK_CLAUDE to its full path."
685
859
  : "cswap could not be run: not on PATH, and not in the places uv and pipx install to. Set AGENTS_DECK_CSWAP to its full path.";