agent-dag 1.34.0 → 1.34.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.
@@ -40,8 +40,8 @@
40
40
  document.documentElement.setAttribute("data-theme", stored === "light" ? "light" : "dark");
41
41
  })();
42
42
  </script>
43
- <script type="module" crossorigin src="/assets/index-BpDHqfOL.js"></script>
44
- <link rel="stylesheet" crossorigin href="/assets/index-C9im4KPT.css">
43
+ <script type="module" crossorigin src="/assets/index-JwmQLh6n.js"></script>
44
+ <link rel="stylesheet" crossorigin href="/assets/index-CV3aF__6.css">
45
45
  </head>
46
46
  <body>
47
47
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-dag",
3
- "version": "1.34.0",
3
+ "version": "1.34.1",
4
4
  "description": "Live deck of Claude Code and Codex agents — watch parallel subagents fork, call tools, and return on one calm canvas. Also available as npx ccdeck and npx agent-dag.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -101,13 +101,35 @@ function tagged(reason, message) {
101
101
 
102
102
  // Absolute path to ccusage's CLI entry inside our managed install, or null if
103
103
  // not installed. Reads the package's `bin` field (currently "./src/cli.js").
104
- function resolveEntry() {
104
+ //
105
+ // `bin` is a string out of a package.json downloaded from the registry, and it
106
+ // is joined onto PKG_DIR and then handed to `spawn(node, [entry])` — so a `bin`
107
+ // of "../../../evil.js" names a file outside the managed install and gets run.
108
+ // existsSync alone does not notice: it is asked whether the escaped path is
109
+ // there, and for an attacker who put something there the answer is yes.
110
+ //
111
+ // The narrowness is worth stating rather than leaving implied: a package that
112
+ // can choose its own `bin` can also ship an install script, so this is not the
113
+ // weak link in a hostile-package scenario. What it does close is the case where
114
+ // only the file is influenced — a tampered or half-written package.json in the
115
+ // cache dir, or a `bin` that walks out of the install by accident — and it
116
+ // costs one comparison.
117
+ //
118
+ // Exported for tests: the containment rule is the whole point of the function
119
+ // and there is no other way to reach it without an install on disk.
120
+ export function resolveEntry() {
105
121
  try {
106
122
  const pkg = JSON.parse(readFileSync(path.join(PKG_DIR, "package.json"), "utf8"));
107
123
  let rel = pkg.bin;
108
124
  if (rel && typeof rel === "object") rel = rel.ccusage ?? Object.values(rel)[0];
109
125
  if (typeof rel !== "string") return null;
110
- const entry = path.join(PKG_DIR, rel);
126
+ const entry = path.resolve(PKG_DIR, rel);
127
+ // path.resolve, not path.join, so an ABSOLUTE `bin` is measured as the
128
+ // absolute path it is rather than being silently re-rooted under PKG_DIR
129
+ // and passing on a technicality. The trailing separator is what stops a
130
+ // sibling directory whose name merely starts with PKG_DIR's — and it also
131
+ // rejects PKG_DIR itself, which is a directory and no kind of entry point.
132
+ if (!entry.startsWith(path.resolve(PKG_DIR) + path.sep)) return null;
111
133
  return existsSync(entry) ? { entry, version: pkg.version } : null;
112
134
  } catch {
113
135
  return null;
@@ -23,7 +23,57 @@ const AUTH_PATH = join(CODEX_HOME, "auth.json");
23
23
 
24
24
  // Same client id + endpoint the Codex CLI uses (codex-rs/login/src/auth/manager.rs).
25
25
  const CLIENT_ID = process.env.CODEX_APP_SERVER_LOGIN_CLIENT_ID ?? "app_EMoamEEZ73f0CkXaXp7hrann";
26
- const REFRESH_URL = process.env.CODEX_REFRESH_TOKEN_URL_OVERRIDE ?? "https://auth.openai.com/oauth/token";
26
+ const DEFAULT_REFRESH_URL = "https://auth.openai.com/oauth/token";
27
+
28
+ // Registrable domains the deck is willing to hand an OpenAI credential to.
29
+ // Deliberately a suffix list rather than a set of exact hosts: OpenAI moves
30
+ // endpoints between subdomains, and FedRAMP tenants live on their own, so
31
+ // pinning the four hosts in use today would break a working login on a change
32
+ // that is none of our business. The suffix is the part that is.
33
+ const CREDENTIAL_HOSTS = ["openai.com", "chatgpt.com"];
34
+
35
+ /**
36
+ * May a live OpenAI credential be sent to this URL?
37
+ *
38
+ * Both destinations in the Codex half of the deck are configurable by something
39
+ * other than the deck — `chatgpt_base_url` in ~/.codex/config.toml, which the
40
+ * access token is sent to, and $CODEX_REFRESH_TOKEN_URL_OVERRIDE, which the
41
+ * SINGLE-USE refresh token is POSTed to — and neither was checked before the
42
+ * credential went out. The Codex CLI honours the same two knobs; the difference
43
+ * is that it is the program those credentials belong to, and the deck is a
44
+ * bystander that reads them.
45
+ *
46
+ * Two rules. `https:`, so a base URL of `http://…` cannot put a bearer token on
47
+ * the wire in cleartext. And a host at or under one of the domains above, so a
48
+ * config file the deck does not own cannot name the recipient.
49
+ *
50
+ * `URL` does the parsing rather than a regex, which is what makes
51
+ * `https://chatgpt.com@evil.example/` (userinfo, not a host) and
52
+ * `https://chatgpt.com.evil.example/` (a different registrable domain) come out
53
+ * as the hosts they really are.
54
+ */
55
+ export function isCredentialHost(raw) {
56
+ let u;
57
+ try { u = new URL(String(raw ?? "")); } catch { return false; }
58
+ if (u.protocol !== "https:") return false;
59
+ const host = u.hostname.toLowerCase();
60
+ return CREDENTIAL_HOSTS.some(d => host === d || host.endsWith(`.${d}`));
61
+ }
62
+
63
+ // The override is honoured only when it names somewhere the refresh token may
64
+ // go. Falling back rather than failing outright keeps a machine with a stale or
65
+ // mistyped override working, and the log line is there so the fallback is not
66
+ // the silent kind.
67
+ function refreshUrl() {
68
+ const override = process.env.CODEX_REFRESH_TOKEN_URL_OVERRIDE?.trim();
69
+ if (!override) return DEFAULT_REFRESH_URL;
70
+ if (isCredentialHost(override)) return override;
71
+ console.error(
72
+ `${PRODUCT} codex-auth: ignoring CODEX_REFRESH_TOKEN_URL_OVERRIDE — ` +
73
+ `a refresh token is only sent to https OpenAI hosts, not ${override}`,
74
+ );
75
+ return DEFAULT_REFRESH_URL;
76
+ }
27
77
 
28
78
  // Refresh once the access token is within this much of expiring. Deliberately
29
79
  // tighter than the CLI's 5 minutes: matching it would wake both processes into
@@ -162,7 +212,10 @@ function refreshErrorCode(body) {
162
212
  async function doRefresh(auth) {
163
213
  let res, body;
164
214
  try {
165
- res = await fetch(REFRESH_URL, {
215
+ // Resolved per call, not once at import: the module is loaded lazily and a
216
+ // test (or an embedder) can set the override after load — the same reason
217
+ // ccusage.mjs reads AGENTS_DECK_NO_INSTALL per call.
218
+ res = await fetch(refreshUrl(), {
166
219
  method: "POST",
167
220
  headers: { "Content-Type": "application/json" },
168
221
  body: JSON.stringify({
@@ -10,7 +10,7 @@
10
10
  import { readFile } from "node:fs/promises";
11
11
  import { join } from "node:path";
12
12
  import { homedir } from "node:os";
13
- import { getCodexAuth, forceCodexRefresh } from "./codex-auth.mjs";
13
+ import { getCodexAuth, forceCodexRefresh, isCredentialHost } from "./codex-auth.mjs";
14
14
  import { PRODUCT } from "./brand.mjs";
15
15
 
16
16
  const CODEX_HOME = process.env.CODEX_HOME ?? join(homedir(), ".codex");
@@ -21,10 +21,22 @@ let _cache = null;
21
21
  let _cacheAt = 0;
22
22
  const CACHE_MS = 60_000;
23
23
 
24
+ // The last base URL we refused, so the refusal is said once rather than once a
25
+ // minute for as long as the config stays that way.
26
+ let _warnedBase = null;
27
+
24
28
  // ── base URL ───────────────────────────────────────────────────────────────
25
29
  // `chatgpt_base_url` in config.toml can point at a proxy, and the path style
26
30
  // follows from its shape exactly as in the CLI: a /backend-api base speaks
27
31
  // /wham/*, anything else speaks /api/codex/*.
32
+ //
33
+ // Whatever it says, the request below carries `Authorization: Bearer
34
+ // <accessToken>` — a live ChatGPT session — so the value is not just a routing
35
+ // preference, it is the answer to "who gets the credential". It was taken
36
+ // verbatim: a line regex, quotes stripped, straight into fetch(), which meant
37
+ // anything able to write that TOML (or to set $CODEX_HOME and point it at its
38
+ // own) could redirect the token to a host of its choosing, over plaintext http
39
+ // if it liked. isCredentialHost is where the two rules live.
28
40
  async function readBaseUrl() {
29
41
  let raw = null;
30
42
  try {
@@ -249,6 +261,21 @@ async function doFetchCodexQuota() {
249
261
  if (auth.apiKeyMode) return fail("api_key_mode");
250
262
 
251
263
  base = await readBaseUrl();
264
+ // Refused before the first byte goes out, and reported rather than
265
+ // swallowed: a panel that says "Codex quota is off because the configured
266
+ // base URL is not an OpenAI one" is a bug report the user can act on, where
267
+ // a silently empty gauge is a mystery. Logged once per distinct value so a
268
+ // 60-second poll does not turn a misconfiguration into a log flood.
269
+ if (!isCredentialHost(base)) {
270
+ if (_warnedBase !== base) {
271
+ _warnedBase = base;
272
+ console.error(
273
+ `${PRODUCT} codex-quota: not sending the ChatGPT token to ${base} — ` +
274
+ `chatgpt_base_url must be an https OpenAI host`,
275
+ );
276
+ }
277
+ return fail("untrusted_base_url");
278
+ }
252
279
  res = await requestUsage(base, auth);
253
280
 
254
281
  // The JWT's own `exp` is not the last word: OpenAI revokes server-side, so
@@ -443,6 +443,18 @@ async function restoreActive(num) {
443
443
  * expiry so a copy left behind in clipboard history stops working, and nothing
444
444
  * more: it is not encryption and is not presented as any.
445
445
  *
446
+ * Say the limit of that out loud, because the UI used to imply the opposite.
447
+ * `exp` is a plain number inside plain base64'd JSON with NO key, MAC or
448
+ * signature over it, so anyone holding the text can decode it, write a later
449
+ * `exp`, re-encode, and import it. unwrapShare's check is therefore a check
450
+ * against staleness, not against an adversary — and it cannot be made into one
451
+ * here. A MAC needs a secret both decks hold, and two decks that already shared
452
+ * a secret would not need this function; and even a perfect signature would
453
+ * only stop THIS import path, since the payload it wraps is the credential
454
+ * itself and `cswap import` accepts it unwrapped. The honest fix for a share
455
+ * that got away is to sign the account out and back in. See share-expiry-
456
+ * forgeable.test.ts, which pins the forgery rather than leaving it implied.
457
+ *
446
458
  * The default export shape is used deliberately, never --full, which would
447
459
  * embed the entire ~/.claude.json including every project and MCP server.
448
460
  */
@@ -473,7 +485,19 @@ export async function shareAccount(num) {
473
485
  if (!Number.isInteger(n) || n < 1 || n > 999) return { ok: false, reason: "bad_account" };
474
486
  const r = await run(await cswapBin(), ["export", "-", "--account", String(n)], { timeout: CSWAP_TIMEOUT_MS });
475
487
  if (!r.ok || !r.stdout.trim()) {
476
- return { ok: false, reason: "export_failed", detail: failureText(r, "cswap export") };
488
+ // The failure sentence is built from stderr ALONE for this one command,
489
+ // because its stdout is the credential. `failureText` concatenates
490
+ // `${stderr}\n${stdout}` and `firstUseful` takes the LAST non-empty line —
491
+ // right for every other cswap command, and here it means any stdout at all
492
+ // outranks the real error. claude-swap writes its diagnostics to stderr
493
+ // specifically so stdout stays pure JSON in pipe mode, and it writes the
494
+ // envelope as its last act; a non-zero exit after a partial write would
495
+ // therefore put the tail of `json.dumps(envelope, indent=2)` in front of the
496
+ // user, and one of those lines is the refresh token on its own.
497
+ //
498
+ // Nothing is lost by dropping it: the ENOENT branch keys off `r.code`, which
499
+ // `run` sets, and cmd.exe's "is not recognized" is stderr's.
500
+ return { ok: false, reason: "export_failed", detail: failureText({ ...r, stdout: "" }, "cswap export") };
477
501
  }
478
502
  return { ok: true, blob: wrapShare(r.stdout), expiresAt: Date.now() + SHARE_TTL_MS };
479
503
  }
@@ -546,10 +570,32 @@ export async function removeAccount(num) {
546
570
  });
547
571
  }
548
572
 
573
+ /**
574
+ * What an alias may be made of.
575
+ *
576
+ * Every other argument this module sends to cswap is an integer bounded to
577
+ * 1..999; the alias was the one free-text field, and `.trim()` was the whole of
578
+ * its validation. That is fine on POSIX, where `run` spawns the argument vector
579
+ * untouched, and not fine on Windows: cswap is a `.cmd` shim there, so the
580
+ * vector goes through `cmd.exe /d /s /c` (see viaCmd). Quote-doubling handles
581
+ * `"` and every other metacharacter, which leaves exactly the residual exec.mjs
582
+ * documents — `%VAR%` expands inside quotes and a command line has no escape for
583
+ * it — so `%USERPROFILE%` in an alias stored the user's home path, and an alias
584
+ * carrying an unbalanced quote plus an `&` could end the quoted region early.
585
+ * An interior newline survived `.trim()` untouched as well.
586
+ *
587
+ * The same allowlist discipline cswap-auto.mjs already applies to its model
588
+ * list, and it closes the unbounded-length half too: an alias is a short name
589
+ * shown instead of an email, so 64 characters is not a constraint anyone meets
590
+ * by accident.
591
+ */
592
+ const ALIAS_OK = /^[A-Za-z0-9 ._-]{1,64}$/;
593
+
549
594
  export async function setAlias(num, alias) {
550
595
  const n = Number(num);
551
596
  if (!Number.isInteger(n) || n < 1 || n > 999) return { ok: false, reason: "bad_account" };
552
597
  const clean = typeof alias === "string" ? alias.trim() : "";
598
+ if (clean && !ALIAS_OK.test(clean)) return { ok: false, reason: "bad_value" };
553
599
  const args = clean ? ["alias", String(n), clean] : ["alias", String(n), "--unset"];
554
600
  return withStoreLock(async () => {
555
601
  const r = await run(await cswapBin(), args, { timeout: CSWAP_TIMEOUT_MS });
@@ -50,8 +50,7 @@ export const isBatch = (file, platform = process.platform) =>
50
50
  * cmd.exe understands inside a quoted string.
51
51
  */
52
52
  export function viaCmd(file, args) {
53
- const q = (s) => `"${String(s).replace(/"/g, '""')}"`;
54
- const line = [file, ...args].map(q).join(" ");
53
+ const line = [file, ...args].map(a => shellQuoteArg(a, "win32")).join(" ");
55
54
  return {
56
55
  file: process.env.comspec || process.env.ComSpec || "cmd.exe",
57
56
  args: ["/d", "/s", "/c", `"${line}"`],
@@ -59,6 +58,42 @@ export function viaCmd(file, args) {
59
58
  };
60
59
  }
61
60
 
61
+ /**
62
+ * Quote ONE argument into a command line a shell will parse.
63
+ *
64
+ * Everything else in this module exists to avoid needing this: an argument
65
+ * vector is handed to spawn untouched and nothing in it is ever read as syntax.
66
+ * Two callers cannot take that route, and they are the reason this is exported.
67
+ * A Claude Code hook is registered as `{type:"command", command:"<string>"}` —
68
+ * the host CLI runs that string THROUGH A SHELL on every tool call, and the
69
+ * format has no argv form to emit instead. So the string has to be built here,
70
+ * correctly, once.
71
+ *
72
+ * The inputs are not user input in the web sense, but they are not constants
73
+ * either: the hook path is built from $CLAUDE_CONFIG_DIR and homedir(), and the
74
+ * node path from process.execPath. Wrapping those in double quotes — what this
75
+ * used to do — is not escaping on POSIX at all: `$(…)`, a backtick and `\` are
76
+ * all still live inside them, so a config dir named `/tmp/a$(id)b` became shell
77
+ * code written into the user's settings.json and executed on every hook fire.
78
+ * A bare `$` in a path is the same bug wearing a duller hat — `$HOME` expands to
79
+ * nothing, the hook path silently becomes wrong, and hooks stop firing with no
80
+ * error anywhere.
81
+ *
82
+ * POSIX gets single quotes, inside which NOTHING is special, with the one
83
+ * escape that form has: close the quote, emit a backslash-quote, reopen.
84
+ * Windows gets cmd.exe's rule — the same one viaCmd applies above, so this file
85
+ * has one Windows quoting rule rather than two — with the same single residual
86
+ * exec.mjs already documents: cmd.exe expands `%VAR%` inside quotes too, and a
87
+ * command line has no escape for it. That is narrower than it sounds, since
88
+ * `%foo%` with no variable `foo` is left alone, and it is a limit of the
89
+ * platform rather than of this function.
90
+ */
91
+ export function shellQuoteArg(arg, platform = process.platform) {
92
+ const s = String(arg ?? "");
93
+ if (platform === "win32") return `"${s.replace(/"/g, '""')}"`;
94
+ return `'${s.split("'").join("'\\''")}'`;
95
+ }
96
+
62
97
  /**
63
98
  * What to hand `spawn`/`execFile` for `file` and `args` on this platform, with
64
99
  * the argument vector intact and no shell.
@@ -204,8 +239,13 @@ const TIMEOUT_TAIL = 8 << 10;
204
239
  * A run stopped by its deadline answers `{ ok: false, code: "ETIMEDOUT",
205
240
  * killed: true, timedOut: true }` — never ok, whatever the child said on its
206
241
  * way out.
242
+ *
243
+ * `env` replaces the child's environment wholesale, the way spawn's does; pass
244
+ * `{...process.env, X: "1"}` to add to it. It exists because the quota probe
245
+ * runs a whole Claude Code and has to mark the run as the deck's own, and that
246
+ * marker is what stops every poll drawing itself onto the canvas.
207
247
  */
208
- export function run(cmd, args, { timeout = 20_000, maxBuffer = 4 << 20 } = {}) {
248
+ export function run(cmd, args, { timeout = 20_000, maxBuffer = 4 << 20, env } = {}) {
209
249
  const tries = candidates(cmd);
210
250
  return new Promise((resolve) => {
211
251
  const attempt = (i) => {
@@ -260,7 +300,16 @@ export function run(cmd, args, { timeout = 20_000, maxBuffer = 4 << 20 } = {}) {
260
300
 
261
301
  try {
262
302
  const cp = execFile(file, argv,
263
- { timeout: 0, shell: false, windowsHide: true, maxBuffer, ...opts }, done);
303
+ { timeout: 0, shell: false, windowsHide: true, maxBuffer, ...(env ? { env } : {}), ...opts }, done);
304
+ // Give the child EOF on stdin straight away, which is what this
305
+ // function's contract has always claimed ("run closes stdin", says
306
+ // runInteractive's header) and what execFile does not do: it leaves the
307
+ // pipe open with nobody at the writing end, so a tool that reads stdin
308
+ // waits for a writer that will never arrive. `claude --print /usage`
309
+ // waits three seconds for exactly that before giving up, which is why
310
+ // the shell command it replaced had to end in `< /dev/null`. Closing
311
+ // the pipe is that redirection without a shell to parse it.
312
+ try { cp.stdin?.on("error", () => {}); cp.stdin?.end(); } catch { /* no stdin to close */ }
264
313
  cp.stdout?.on("data", (d) => { sawOut = (sawOut + d).slice(-TIMEOUT_TAIL); });
265
314
  cp.stderr?.on("data", (d) => { sawErr = (sawErr + d).slice(-TIMEOUT_TAIL); });
266
315
  // The deadline states the outcome itself and only then kills, which is
@@ -1293,20 +1293,57 @@ function readBody(req, limit = 64_000) {
1293
1293
  });
1294
1294
  }
1295
1295
 
1296
+ // How long an oversized POST is drained after it has been refused, so the 413
1297
+ // reaches a poster that is still uploading. Generous next to hook.js's own
1298
+ // one-second budget, and finite so nothing can sit on the socket indefinitely.
1299
+ const OVERSIZE_DRAIN_MS = 10_000;
1300
+
1296
1301
  // `persist` is false when the hook posted this event to another deck as well
1297
1302
  // and elected that one to write it to the log they share. The event is still
1298
1303
  // buffered and broadcast here — every matching deck draws it — it is only the
1299
1304
  // second copy on disk that is dropped.
1300
1305
  function handleEventIngest(req, res, persist = true) {
1301
1306
  let body = "";
1307
+ // Set once the cap is hit, because everything after that point is about an
1308
+ // exchange that is already over: more `data` may still be in flight, and
1309
+ // `end` must not go on to parse the truncated half.
1310
+ let refused = false;
1302
1311
  req.setEncoding("utf8");
1303
1312
  req.on("data", c => {
1313
+ if (refused) return;
1304
1314
  body += c;
1305
1315
  if (body.length > 5_000_000) {
1306
- req.destroy();
1316
+ refused = true;
1317
+ body = ""; // nothing will read it now; let it go
1318
+ // ANSWER, rather than vanish. `req.destroy()` on its own tore the socket
1319
+ // down with no status line on it at all, so the poster learned only that
1320
+ // the connection had gone — indistinguishable from a deck that died or
1321
+ // was never there, and nothing in the exchange to tell those apart. `end`
1322
+ // never fires on a destroyed request either, so the handler below got no
1323
+ // second chance to speak. readBody above gets this right already, by
1324
+ // rejecting into a caller that replies.
1325
+ send(res, 413, { error: "event too large" });
1326
+ // Then keep reading, and throw it away. Answering is not enough on its
1327
+ // own, because the poster is still mid-upload when the answer goes out:
1328
+ // hang up now and its next write lands on a dead socket, it aborts with
1329
+ // EPIPE, and the 413 that was already sitting in its receive buffer is
1330
+ // discarded unread — the same disappearance in a different costume.
1331
+ // `Connection: close` is the tempting version of hanging up and has
1332
+ // exactly that effect: Node destroys the socket the moment such a
1333
+ // response flushes. Draining lets the poster finish and then read the
1334
+ // answer, which is the whole point of answering. `body` no longer grows,
1335
+ // so it costs no memory.
1336
+ req.resume();
1337
+ // Bounded, because draining forever is its own denial of service: a
1338
+ // poster that stops without ending would otherwise hold the socket for as
1339
+ // long as it liked.
1340
+ const grace = setTimeout(() => req.destroy(), OVERSIZE_DRAIN_MS);
1341
+ grace.unref?.();
1342
+ req.on("close", () => clearTimeout(grace));
1307
1343
  }
1308
1344
  });
1309
1345
  req.on("end", () => {
1346
+ if (refused) return;
1310
1347
  let parsed;
1311
1348
  try { parsed = JSON.parse(body); }
1312
1349
  catch { return send(res, 400, { error: "invalid json" }); }
@@ -1314,7 +1351,11 @@ function handleEventIngest(req, res, persist = true) {
1314
1351
  const evt = pushEvent(parsed, "hook", { persist });
1315
1352
  send(res, 200, { ok: true, seq: evt.seq });
1316
1353
  });
1317
- req.on("error", () => send(res, 400, { error: "bad request" }));
1354
+ // Guarded for the same reason `end` is, and more sharply: destroying the
1355
+ // request above is itself what raises this, and answering a second time on a
1356
+ // response already sent throws ERR_HTTP_HEADERS_SENT out of an error handler,
1357
+ // where nothing is waiting to catch it.
1358
+ req.on("error", () => { if (!refused) send(res, 400, { error: "bad request" }); });
1318
1359
  }
1319
1360
 
1320
1361
  function handleSse(req, res) {
@@ -12,6 +12,7 @@ import { join, resolve, dirname } from "node:path";
12
12
  import { setTimeout as delay } from "node:timers/promises";
13
13
  import { fileURLToPath } from "node:url";
14
14
  import { claudeConfigDir } from "./claude-dir.mjs";
15
+ import { shellQuoteArg } from "./exec.mjs";
15
16
  import { PRODUCT } from "./brand.mjs";
16
17
 
17
18
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -68,9 +69,35 @@ const MARK_KEY = "__agent-dag";
68
69
  const LEGACY_MARKS = ["__ccgraph", "__agent-flow"];
69
70
  const LEGACY_DIRS = ["ccgraph", "agent-flow", "agent-dag"];
70
71
 
71
- function hookCommand(installedHookPath, provider) {
72
- const node = process.execPath;
73
- return `"${node}" "${installedHookPath}" --provider ${provider}`;
72
+ /**
73
+ * The `command` string Claude Code stores for our forwarder, and runs THROUGH A
74
+ * SHELL on every tool call.
75
+ *
76
+ * The settings.json hook format is a string, not an argv, so this is one of the
77
+ * two places in the codebase that has to build a shell command line by hand —
78
+ * see shellQuoteArg, which is where the escaping rules and their one Windows
79
+ * residual are written down.
80
+ *
81
+ * It used to wrap both paths in double quotes, which on POSIX escapes nothing:
82
+ * `$(…)`, a backtick and `\` are all still live inside them. Both paths come
83
+ * from outside — `installedHookPath` is built from $CLAUDE_CONFIG_DIR (resolved,
84
+ * never validated) or homedir(), and `node` is process.execPath — so a config
85
+ * dir called `/tmp/a$(id)b` was shell code, written into the user's own settings
86
+ * file and executed on every hook fire for as long as it stayed there. The
87
+ * quieter half of the same bug cost nothing but the feature: an ordinary `$` in
88
+ * a path expanded to nothing, the hook pointed at a file that was not there, and
89
+ * hooks stopped firing with no error to explain it.
90
+ *
91
+ * `provider` is a key of PROVIDERS — "claude" or "codex", never anything a
92
+ * caller chose — and is quoted anyway, because that is not a property worth
93
+ * re-deriving at every reading.
94
+ *
95
+ * Exported, with the node path injectable, so the escaping can be checked
96
+ * against a path the test names rather than against whatever ran the suite.
97
+ */
98
+ export function hookCommand(installedHookPath, provider, node = process.execPath) {
99
+ const q = (s) => shellQuoteArg(s);
100
+ return `${q(node)} ${q(installedHookPath)} --provider ${q(provider)}`;
74
101
  }
75
102
 
76
103
  function isOurEntry(g) {
@@ -19,22 +19,21 @@
19
19
  // notably on macOS, where Claude Code keeps credentials in the Keychain
20
20
  // and that file does not exist, so this is the ONLY self-service path
21
21
  // there. It is also the most expensive: a whole Claude Code process per
22
- // poll. On Windows the binary may be a .cmd wrapper, so exec()
23
- // (shell-based) is used for correct quoting + stdin.
22
+ // poll. On Windows the binary may be a .cmd wrapper, which spawn cannot
23
+ // launch directly — exec.mjs's `run` routes that case through cmd.exe with
24
+ // the argument vector intact, so no shell ever parses a path this module
25
+ // read out of the environment.
24
26
  //
25
27
  // 2 and 3 are rate-floored (SELF_POLL_MS) and gated behind the same 429
26
28
  // cooldown; 1 is not, because it is a local file read.
27
29
  import { activeAccountUsage, requestCollection } from "./claude-accounts.mjs";
28
- import { exec } from "node:child_process";
29
- import { promisify } from "node:util";
30
+ import { run } from "./exec.mjs";
30
31
  import { existsSync } from "node:fs";
31
32
  import { readFile } from "node:fs/promises";
32
33
  import { join, posix as posixPath, win32 as winPath } from "node:path";
33
34
  import { homedir } from "node:os";
34
35
  import { PRODUCT } from "./brand.mjs";
35
36
 
36
- const execAsync = promisify(exec);
37
-
38
37
  const CREDS_PATH = join(homedir(), ".claude", ".credentials.json");
39
38
  const USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
40
39
  const BETA_HEADER = "oauth-2025-04-20";
@@ -147,7 +146,7 @@ async function fetchOAuthUsage() {
147
146
 
148
147
  let _cache = null;
149
148
  let _cacheAt = 0;
150
- let _inflight = null; // deduplicates concurrent exec() calls
149
+ let _inflight = null; // deduplicates concurrent CLI probes
151
150
  let _lastGood = null; // last result that had real quota percentages
152
151
  let _lastSelfPollAt = 0;
153
152
 
@@ -346,35 +345,40 @@ export function quotaClaudeCandidates(platform = process.platform, env = process
346
345
  ];
347
346
  }
348
347
 
349
- /** Build the shell command string for `claude --print /usage`.
348
+ /** Which `claude` to run for `--print /usage`: the first candidate that exists.
349
+ *
350
+ * This used to hand back a whole shell command line — `"<bin>" --print /usage
351
+ * < /dev/null` — for `exec()` to parse. Double quotes are not escaping on
352
+ * POSIX: `$(…)`, backticks and `\` all still work inside them, and every
353
+ * ingredient of that line came from the environment (`%APPDATA%`, `homedir()`),
354
+ * so a home directory named `/home/a$(id)b` was shell code the quota poll ran
355
+ * every minute. A bare `$` was the duller half of the same bug — it expanded
356
+ * to nothing and the probe looked for a binary at a path that did not exist.
350
357
  *
351
- * We use exec() (shell-based) so cmd.exe / sh processes redirects.
352
- * On Windows: `< nul` closes stdin immediately, preventing the 3-second
353
- * "no stdin data" wait the claude CLI does when it detects a pipe.
354
- * On Unix: `< /dev/null` has the same effect.
358
+ * There is nothing left to escape once there is no shell: exec.mjs's `run`
359
+ * spawns the argument vector as given, resolves the Windows `.cmd`/`.exe`
360
+ * spelling itself, and closes the child's stdin — which is what `< /dev/null`
361
+ * was for, since `claude --print` waits three seconds on a stdin pipe nobody
362
+ * is writing to.
355
363
  *
356
364
  * Exported, with everything it touches injectable, so the Windows branch is
357
365
  * testable from the platforms this repo is actually developed on.
358
366
  */
359
- export function buildQuotaShellCmd(platform = process.platform, env = process.env,
360
- home = homedir(), exists = existsSync) {
361
- const win = platform === "win32";
362
- const sep = win ? "\\" : "/";
363
- // A bare name is left to the shell's own lookup; a full path is only worth
364
- // naming when it is actually there.
365
- const bin = quotaClaudeCandidates(platform, env, home)
367
+ export function quotaClaudeBin(platform = process.platform, env = process.env,
368
+ home = homedir(), exists = existsSync) {
369
+ const sep = platform === "win32" ? "\\" : "/";
370
+ // A bare name is left to spawn's own PATH lookup (and, on Windows, to the
371
+ // PATHEXT walk exec.mjs does by hand); a full path is only worth naming when
372
+ // it is actually there.
373
+ return quotaClaudeCandidates(platform, env, home)
366
374
  .find(c => !c.includes(sep) || exists(c)) ?? "claude";
367
- // Quote a path — a username can contain a space — but not a bare name, which
368
- // cmd.exe resolves more predictably unquoted.
369
- const quoted = bin.includes(sep) ? `"${bin}"` : bin;
370
- return `${quoted} --print /usage < ${win ? "nul" : "/dev/null"}`;
371
375
  }
372
376
 
373
377
  export async function fetchClaudeQuota({ force = false } = {}) {
374
378
  const now = Date.now();
375
379
  if (!force && _cache && now - _cacheAt < CACHE_MS) return _cache;
376
380
 
377
- // If another exec() is already in flight, wait for it instead of spawning a
381
+ // If another CLI probe is already in flight, wait for it instead of spawning a
378
382
  // second concurrent process (which can return empty output and overwrite the
379
383
  // good result with 0%).
380
384
  if (_inflight) return _inflight;
@@ -436,30 +440,27 @@ async function nudgeAndReread(previous) {
436
440
  // cliOk — the CLI ran and we recognized its output (preamble present)
437
441
  // parsed — quota percentages object, or null if the "Current session/week"
438
442
  // lines were absent (CLI cold-start, or genuinely <1% usage)
439
- async function _execOnce(shellCmd) {
440
- try {
441
- const { stdout, stderr } = await execAsync(shellCmd, {
442
- timeout: 15_000,
443
- // Marks this Claude Code run as the deck's own. `claude --print /usage`
444
- // is a full invocation, so it fires the hooks we installed, and every
445
- // quota poll was drawing itself onto the canvas as a fresh session with
446
- // no prompt and no tools. Hooks inherit the environment, so hook.js
447
- // sees this and stays quiet.
448
- env: { ...process.env, NO_COLOR: "1", TERM: "dumb", AGENTS_DECK_INTERNAL: "1" },
449
- maxBuffer: 1024 * 1024,
450
- });
451
- const combined = stdout + "\n" + stderr;
452
- const cliOk = /subscription/i.test(combined) || /claude code usage/i.test(combined);
453
- return { cliOk, parsed: parseUsageText(combined) };
454
- } catch (err) {
455
- const msg = err?.stderr ? stripAnsi(err.stderr).trim() : (err?.message ?? String(err));
443
+ async function _execOnce(bin) {
444
+ const r = await run(bin, ["--print", "/usage"], {
445
+ timeout: 15_000,
446
+ maxBuffer: 1024 * 1024,
447
+ // Marks this Claude Code run as the deck's own. `claude --print /usage`
448
+ // is a full invocation, so it fires the hooks we installed, and every
449
+ // quota poll was drawing itself onto the canvas as a fresh session with
450
+ // no prompt and no tools. Hooks inherit the environment, so hook.js
451
+ // sees this and stays quiet.
452
+ env: { ...process.env, NO_COLOR: "1", TERM: "dumb", AGENTS_DECK_INTERNAL: "1" },
453
+ });
454
+ // `run` never rejects, so there is one path rather than two — and the output
455
+ // is kept either way, which matters because the CLI writes the quota lines to
456
+ // stdout and can still exit non-zero afterwards.
457
+ const combined = r.stdout + "\n" + r.stderr;
458
+ if (!r.ok) {
459
+ const msg = stripAnsi(r.stderr).trim() || `claude exited ${r.code}`;
456
460
  console.error(`${PRODUCT} quota: claude CLI failed:`, msg);
457
- if (err?.stdout || err?.stderr) {
458
- const combined = (err.stdout ?? "") + "\n" + (err.stderr ?? "");
459
- return { cliOk: /subscription/i.test(combined), parsed: parseUsageText(combined) };
460
- }
461
- return { cliOk: false, parsed: null };
462
461
  }
462
+ const cliOk = /subscription/i.test(combined) || /claude code usage/i.test(combined);
463
+ return { cliOk, parsed: parseUsageText(combined) };
463
464
  }
464
465
 
465
466
  async function _doFetch(now, force = false) {
@@ -515,7 +516,7 @@ async function _doFetch(now, force = false) {
515
516
  }
516
517
 
517
518
  // Source 3: parse `claude --print /usage` CLI output.
518
- const shellCmd = buildQuotaShellCmd();
519
+ const bin = quotaClaudeBin();
519
520
 
520
521
  // The CLI sometimes omits the "Current session/week" quota lines on a cold
521
522
  // invocation (right after the server starts, or after the page is hard-
@@ -525,7 +526,7 @@ async function _doFetch(now, force = false) {
525
526
  let parsed = null;
526
527
  for (let attempt = 0; attempt < 3; attempt++) {
527
528
  if (attempt > 0) await sleep(1200);
528
- const r = await _execOnce(shellCmd);
529
+ const r = await _execOnce(bin);
529
530
  cliOk = r.cliOk || cliOk;
530
531
  if (r.parsed) { parsed = r.parsed; break; }
531
532
  }