agent-dag 1.34.0 → 1.34.2
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.
- package/bin/deck.js +11 -4
- package/dist/web/assets/{index-BpDHqfOL.js → index-CLdlWxJO.js} +18 -18
- package/dist/web/assets/{index-C9im4KPT.css → index-CV3aF__6.css} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/src/server/ccusage.mjs +24 -2
- package/src/server/codex-auth.mjs +55 -2
- package/src/server/codex-quota.mjs +28 -1
- package/src/server/cswap-admin.mjs +47 -1
- package/src/server/exec.mjs +53 -4
- package/src/server/index.mjs +50 -3
- package/src/server/installer.mjs +30 -3
- package/src/server/quota.mjs +78 -51
- package/src/server/sound-hook.mjs +20 -2
|
@@ -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
|
-
|
|
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 });
|
package/src/server/exec.mjs
CHANGED
|
@@ -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
|
|
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
|
package/src/server/index.mjs
CHANGED
|
@@ -638,7 +638,13 @@ function maybeResolveContext(payload) {
|
|
|
638
638
|
const CODEX_HOME = process.env.CODEX_HOME
|
|
639
639
|
? resolve(process.env.CODEX_HOME)
|
|
640
640
|
: join(homedir(), ".codex");
|
|
641
|
-
|
|
641
|
+
// Exported because bin/deck.js prints this path in the boot banner, and it used
|
|
642
|
+
// to build its own `join(homedir(), ".codex", "sessions")` for the purpose —
|
|
643
|
+
// which ignored CODEX_HOME and so named a directory that does not exist on any
|
|
644
|
+
// machine that sets it. Handing out the binding the watcher itself reads, rather
|
|
645
|
+
// than a second computation of the same rule, is what makes the printed path and
|
|
646
|
+
// the tailed path unable to disagree.
|
|
647
|
+
export const CODEX_SESSIONS_DIR = join(CODEX_HOME, "sessions");
|
|
642
648
|
const codexRolloutPathBySid = new Map();
|
|
643
649
|
const lastCodexUsageReadAt = new Map();
|
|
644
650
|
const pendingCodexUsageReads = new Set();
|
|
@@ -1293,20 +1299,57 @@ function readBody(req, limit = 64_000) {
|
|
|
1293
1299
|
});
|
|
1294
1300
|
}
|
|
1295
1301
|
|
|
1302
|
+
// How long an oversized POST is drained after it has been refused, so the 413
|
|
1303
|
+
// reaches a poster that is still uploading. Generous next to hook.js's own
|
|
1304
|
+
// one-second budget, and finite so nothing can sit on the socket indefinitely.
|
|
1305
|
+
const OVERSIZE_DRAIN_MS = 10_000;
|
|
1306
|
+
|
|
1296
1307
|
// `persist` is false when the hook posted this event to another deck as well
|
|
1297
1308
|
// and elected that one to write it to the log they share. The event is still
|
|
1298
1309
|
// buffered and broadcast here — every matching deck draws it — it is only the
|
|
1299
1310
|
// second copy on disk that is dropped.
|
|
1300
1311
|
function handleEventIngest(req, res, persist = true) {
|
|
1301
1312
|
let body = "";
|
|
1313
|
+
// Set once the cap is hit, because everything after that point is about an
|
|
1314
|
+
// exchange that is already over: more `data` may still be in flight, and
|
|
1315
|
+
// `end` must not go on to parse the truncated half.
|
|
1316
|
+
let refused = false;
|
|
1302
1317
|
req.setEncoding("utf8");
|
|
1303
1318
|
req.on("data", c => {
|
|
1319
|
+
if (refused) return;
|
|
1304
1320
|
body += c;
|
|
1305
1321
|
if (body.length > 5_000_000) {
|
|
1306
|
-
|
|
1322
|
+
refused = true;
|
|
1323
|
+
body = ""; // nothing will read it now; let it go
|
|
1324
|
+
// ANSWER, rather than vanish. `req.destroy()` on its own tore the socket
|
|
1325
|
+
// down with no status line on it at all, so the poster learned only that
|
|
1326
|
+
// the connection had gone — indistinguishable from a deck that died or
|
|
1327
|
+
// was never there, and nothing in the exchange to tell those apart. `end`
|
|
1328
|
+
// never fires on a destroyed request either, so the handler below got no
|
|
1329
|
+
// second chance to speak. readBody above gets this right already, by
|
|
1330
|
+
// rejecting into a caller that replies.
|
|
1331
|
+
send(res, 413, { error: "event too large" });
|
|
1332
|
+
// Then keep reading, and throw it away. Answering is not enough on its
|
|
1333
|
+
// own, because the poster is still mid-upload when the answer goes out:
|
|
1334
|
+
// hang up now and its next write lands on a dead socket, it aborts with
|
|
1335
|
+
// EPIPE, and the 413 that was already sitting in its receive buffer is
|
|
1336
|
+
// discarded unread — the same disappearance in a different costume.
|
|
1337
|
+
// `Connection: close` is the tempting version of hanging up and has
|
|
1338
|
+
// exactly that effect: Node destroys the socket the moment such a
|
|
1339
|
+
// response flushes. Draining lets the poster finish and then read the
|
|
1340
|
+
// answer, which is the whole point of answering. `body` no longer grows,
|
|
1341
|
+
// so it costs no memory.
|
|
1342
|
+
req.resume();
|
|
1343
|
+
// Bounded, because draining forever is its own denial of service: a
|
|
1344
|
+
// poster that stops without ending would otherwise hold the socket for as
|
|
1345
|
+
// long as it liked.
|
|
1346
|
+
const grace = setTimeout(() => req.destroy(), OVERSIZE_DRAIN_MS);
|
|
1347
|
+
grace.unref?.();
|
|
1348
|
+
req.on("close", () => clearTimeout(grace));
|
|
1307
1349
|
}
|
|
1308
1350
|
});
|
|
1309
1351
|
req.on("end", () => {
|
|
1352
|
+
if (refused) return;
|
|
1310
1353
|
let parsed;
|
|
1311
1354
|
try { parsed = JSON.parse(body); }
|
|
1312
1355
|
catch { return send(res, 400, { error: "invalid json" }); }
|
|
@@ -1314,7 +1357,11 @@ function handleEventIngest(req, res, persist = true) {
|
|
|
1314
1357
|
const evt = pushEvent(parsed, "hook", { persist });
|
|
1315
1358
|
send(res, 200, { ok: true, seq: evt.seq });
|
|
1316
1359
|
});
|
|
1317
|
-
|
|
1360
|
+
// Guarded for the same reason `end` is, and more sharply: destroying the
|
|
1361
|
+
// request above is itself what raises this, and answering a second time on a
|
|
1362
|
+
// response already sent throws ERR_HTTP_HEADERS_SENT out of an error handler,
|
|
1363
|
+
// where nothing is waiting to catch it.
|
|
1364
|
+
req.on("error", () => { if (!refused) send(res, 400, { error: "bad request" }); });
|
|
1318
1365
|
}
|
|
1319
1366
|
|
|
1320
1367
|
function handleSse(req, res) {
|
package/src/server/installer.mjs
CHANGED
|
@@ -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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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) {
|
package/src/server/quota.mjs
CHANGED
|
@@ -13,29 +13,29 @@
|
|
|
13
13
|
// schedule and writes what it got; reading that file costs nothing and
|
|
14
14
|
// spends none of the budget. Used whenever it holds a recent enough row.
|
|
15
15
|
// 2. The OAuth usage API directly, with the token from
|
|
16
|
-
//
|
|
16
|
+
// .credentials.json inside the Claude config dir — $CLAUDE_CONFIG_DIR when
|
|
17
|
+
// it is set, ~/.claude otherwise. Exact and instant. Mechanism
|
|
17
18
|
// reverse-engineered from steipete/CodexBar.
|
|
18
19
|
// 3. `claude --print /usage`, parsed. Used when there is no readable token —
|
|
19
20
|
// notably on macOS, where Claude Code keeps credentials in the Keychain
|
|
20
21
|
// and that file does not exist, so this is the ONLY self-service path
|
|
21
22
|
// there. It is also the most expensive: a whole Claude Code process per
|
|
22
|
-
// poll. On Windows the binary may be a .cmd wrapper,
|
|
23
|
-
//
|
|
23
|
+
// poll. On Windows the binary may be a .cmd wrapper, which spawn cannot
|
|
24
|
+
// launch directly — exec.mjs's `run` routes that case through cmd.exe with
|
|
25
|
+
// the argument vector intact, so no shell ever parses a path this module
|
|
26
|
+
// read out of the environment.
|
|
24
27
|
//
|
|
25
28
|
// 2 and 3 are rate-floored (SELF_POLL_MS) and gated behind the same 429
|
|
26
29
|
// cooldown; 1 is not, because it is a local file read.
|
|
27
30
|
import { activeAccountUsage, requestCollection } from "./claude-accounts.mjs";
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
31
|
+
import { claudeConfigDir } from "./claude-dir.mjs";
|
|
32
|
+
import { run } from "./exec.mjs";
|
|
30
33
|
import { existsSync } from "node:fs";
|
|
31
34
|
import { readFile } from "node:fs/promises";
|
|
32
35
|
import { join, posix as posixPath, win32 as winPath } from "node:path";
|
|
33
36
|
import { homedir } from "node:os";
|
|
34
37
|
import { PRODUCT } from "./brand.mjs";
|
|
35
38
|
|
|
36
|
-
const execAsync = promisify(exec);
|
|
37
|
-
|
|
38
|
-
const CREDS_PATH = join(homedir(), ".claude", ".credentials.json");
|
|
39
39
|
const USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
|
|
40
40
|
const BETA_HEADER = "oauth-2025-04-20";
|
|
41
41
|
const WIN_5H_SEC = 18000;
|
|
@@ -44,9 +44,34 @@ const WIN_7D_SEC = 604800;
|
|
|
44
44
|
// 429 cooldown gate — after a rate-limit, skip the API until this passes.
|
|
45
45
|
let _rateLimitedUntil = 0;
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Where Claude Code keeps the OAuth credentials this module borrows a token
|
|
49
|
+
* from.
|
|
50
|
+
*
|
|
51
|
+
* It is `.credentials.json` inside the Claude config dir, and that dir moves:
|
|
52
|
+
* CLAUDE_CONFIG_DIR replaces ~/.claude wholesale rather than overlaying it, so
|
|
53
|
+
* on a machine where it is set there is no ~/.claude to read at all. Hardcoding
|
|
54
|
+
* ~/.claude here did not fail loudly — it made readOAuthToken() return null
|
|
55
|
+
* forever, which reads exactly like "this machine keeps its credentials in the
|
|
56
|
+
* Keychain", and the quota chain quietly fell through to source 3 on every poll
|
|
57
|
+
* it was allowed to make. See src/server/claude-dir.mjs, which owns the rule
|
|
58
|
+
* and is the only place it is spelled.
|
|
59
|
+
*
|
|
60
|
+
* Resolved per call rather than frozen into a module-level constant, for the
|
|
61
|
+
* same reason claudeConfigDir() is a function: a constant captured at import
|
|
62
|
+
* time is a value nothing can observe or correct afterwards, and this module is
|
|
63
|
+
* imported lazily by the /api/quota route rather than at a point in startup
|
|
64
|
+
* anyone here controls.
|
|
65
|
+
*
|
|
66
|
+
* Exported for tests — it is the whole of the bug, and it is pure.
|
|
67
|
+
*/
|
|
68
|
+
export function credentialsPath() {
|
|
69
|
+
return join(claudeConfigDir(), ".credentials.json");
|
|
70
|
+
}
|
|
71
|
+
|
|
47
72
|
async function readOAuthToken() {
|
|
48
73
|
try {
|
|
49
|
-
const raw = await readFile(
|
|
74
|
+
const raw = await readFile(credentialsPath(), "utf8");
|
|
50
75
|
const auth = JSON.parse(raw)?.claudeAiOauth;
|
|
51
76
|
if (!auth?.accessToken) return null;
|
|
52
77
|
// expiresAt is epoch milliseconds. If expired, the CLI fallback handles it.
|
|
@@ -147,7 +172,7 @@ async function fetchOAuthUsage() {
|
|
|
147
172
|
|
|
148
173
|
let _cache = null;
|
|
149
174
|
let _cacheAt = 0;
|
|
150
|
-
let _inflight = null; // deduplicates concurrent
|
|
175
|
+
let _inflight = null; // deduplicates concurrent CLI probes
|
|
151
176
|
let _lastGood = null; // last result that had real quota percentages
|
|
152
177
|
let _lastSelfPollAt = 0;
|
|
153
178
|
|
|
@@ -346,35 +371,40 @@ export function quotaClaudeCandidates(platform = process.platform, env = process
|
|
|
346
371
|
];
|
|
347
372
|
}
|
|
348
373
|
|
|
349
|
-
/**
|
|
374
|
+
/** Which `claude` to run for `--print /usage`: the first candidate that exists.
|
|
375
|
+
*
|
|
376
|
+
* This used to hand back a whole shell command line — `"<bin>" --print /usage
|
|
377
|
+
* < /dev/null` — for `exec()` to parse. Double quotes are not escaping on
|
|
378
|
+
* POSIX: `$(…)`, backticks and `\` all still work inside them, and every
|
|
379
|
+
* ingredient of that line came from the environment (`%APPDATA%`, `homedir()`),
|
|
380
|
+
* so a home directory named `/home/a$(id)b` was shell code the quota poll ran
|
|
381
|
+
* every minute. A bare `$` was the duller half of the same bug — it expanded
|
|
382
|
+
* to nothing and the probe looked for a binary at a path that did not exist.
|
|
350
383
|
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
384
|
+
* There is nothing left to escape once there is no shell: exec.mjs's `run`
|
|
385
|
+
* spawns the argument vector as given, resolves the Windows `.cmd`/`.exe`
|
|
386
|
+
* spelling itself, and closes the child's stdin — which is what `< /dev/null`
|
|
387
|
+
* was for, since `claude --print` waits three seconds on a stdin pipe nobody
|
|
388
|
+
* is writing to.
|
|
355
389
|
*
|
|
356
390
|
* Exported, with everything it touches injectable, so the Windows branch is
|
|
357
391
|
* testable from the platforms this repo is actually developed on.
|
|
358
392
|
*/
|
|
359
|
-
export function
|
|
360
|
-
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
|
|
393
|
+
export function quotaClaudeBin(platform = process.platform, env = process.env,
|
|
394
|
+
home = homedir(), exists = existsSync) {
|
|
395
|
+
const sep = platform === "win32" ? "\\" : "/";
|
|
396
|
+
// A bare name is left to spawn's own PATH lookup (and, on Windows, to the
|
|
397
|
+
// PATHEXT walk exec.mjs does by hand); a full path is only worth naming when
|
|
398
|
+
// it is actually there.
|
|
399
|
+
return quotaClaudeCandidates(platform, env, home)
|
|
366
400
|
.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
401
|
}
|
|
372
402
|
|
|
373
403
|
export async function fetchClaudeQuota({ force = false } = {}) {
|
|
374
404
|
const now = Date.now();
|
|
375
405
|
if (!force && _cache && now - _cacheAt < CACHE_MS) return _cache;
|
|
376
406
|
|
|
377
|
-
// If another
|
|
407
|
+
// If another CLI probe is already in flight, wait for it instead of spawning a
|
|
378
408
|
// second concurrent process (which can return empty output and overwrite the
|
|
379
409
|
// good result with 0%).
|
|
380
410
|
if (_inflight) return _inflight;
|
|
@@ -436,30 +466,27 @@ async function nudgeAndReread(previous) {
|
|
|
436
466
|
// cliOk — the CLI ran and we recognized its output (preamble present)
|
|
437
467
|
// parsed — quota percentages object, or null if the "Current session/week"
|
|
438
468
|
// lines were absent (CLI cold-start, or genuinely <1% usage)
|
|
439
|
-
async function _execOnce(
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
const msg =
|
|
469
|
+
async function _execOnce(bin) {
|
|
470
|
+
const r = await run(bin, ["--print", "/usage"], {
|
|
471
|
+
timeout: 15_000,
|
|
472
|
+
maxBuffer: 1024 * 1024,
|
|
473
|
+
// Marks this Claude Code run as the deck's own. `claude --print /usage`
|
|
474
|
+
// is a full invocation, so it fires the hooks we installed, and every
|
|
475
|
+
// quota poll was drawing itself onto the canvas as a fresh session with
|
|
476
|
+
// no prompt and no tools. Hooks inherit the environment, so hook.js
|
|
477
|
+
// sees this and stays quiet.
|
|
478
|
+
env: { ...process.env, NO_COLOR: "1", TERM: "dumb", AGENTS_DECK_INTERNAL: "1" },
|
|
479
|
+
});
|
|
480
|
+
// `run` never rejects, so there is one path rather than two — and the output
|
|
481
|
+
// is kept either way, which matters because the CLI writes the quota lines to
|
|
482
|
+
// stdout and can still exit non-zero afterwards.
|
|
483
|
+
const combined = r.stdout + "\n" + r.stderr;
|
|
484
|
+
if (!r.ok) {
|
|
485
|
+
const msg = stripAnsi(r.stderr).trim() || `claude exited ${r.code}`;
|
|
456
486
|
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
487
|
}
|
|
488
|
+
const cliOk = /subscription/i.test(combined) || /claude code usage/i.test(combined);
|
|
489
|
+
return { cliOk, parsed: parseUsageText(combined) };
|
|
463
490
|
}
|
|
464
491
|
|
|
465
492
|
async function _doFetch(now, force = false) {
|
|
@@ -515,7 +542,7 @@ async function _doFetch(now, force = false) {
|
|
|
515
542
|
}
|
|
516
543
|
|
|
517
544
|
// Source 3: parse `claude --print /usage` CLI output.
|
|
518
|
-
const
|
|
545
|
+
const bin = quotaClaudeBin();
|
|
519
546
|
|
|
520
547
|
// The CLI sometimes omits the "Current session/week" quota lines on a cold
|
|
521
548
|
// invocation (right after the server starts, or after the page is hard-
|
|
@@ -525,7 +552,7 @@ async function _doFetch(now, force = false) {
|
|
|
525
552
|
let parsed = null;
|
|
526
553
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
527
554
|
if (attempt > 0) await sleep(1200);
|
|
528
|
-
const r = await _execOnce(
|
|
555
|
+
const r = await _execOnce(bin);
|
|
529
556
|
cliOk = r.cliOk || cliOk;
|
|
530
557
|
if (r.parsed) { parsed = r.parsed; break; }
|
|
531
558
|
}
|
|
@@ -17,6 +17,7 @@ import { homedir } from "node:os";
|
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
18
18
|
import { claudeConfigDir } from "./claude-dir.mjs";
|
|
19
19
|
import { readSettingsForWrite, writeFileAtomic, installScript } from "./installer.mjs";
|
|
20
|
+
import { shellQuoteArg } from "./exec.mjs";
|
|
20
21
|
|
|
21
22
|
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
22
23
|
const CLAUDE_DIR = claudeConfigDir();
|
|
@@ -34,6 +35,18 @@ const PARKED_PATH = join(homedir(), ".agents-deck", "parked-sound-hooks.json");
|
|
|
34
35
|
// what is already there — never to modify or remove it.
|
|
35
36
|
const SOUND_HINTS = [/\bafplay\b/i, /Media\.SoundPlayer/i, /\bpaplay\b/i, /\baplay\b/i, /canberra-gtk-play/i];
|
|
36
37
|
|
|
38
|
+
/**
|
|
39
|
+
* The Stop hook's `command` string, escaped for the shell that will run it.
|
|
40
|
+
*
|
|
41
|
+
* Same shape and same reasoning as installer.mjs's hookCommand — see the note
|
|
42
|
+
* there — kept separate because this entry takes no `--provider` and is written
|
|
43
|
+
* to a different key. Exported, with the node path injectable, so the escaping
|
|
44
|
+
* is checked against a path the test names.
|
|
45
|
+
*/
|
|
46
|
+
export function soundHookCommand(notifyPath, node = process.execPath) {
|
|
47
|
+
return `${shellQuoteArg(node)} ${shellQuoteArg(notifyPath)}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
37
50
|
/**
|
|
38
51
|
* Read settings.json, refusing to guess at a file that will not parse.
|
|
39
52
|
*
|
|
@@ -338,8 +351,13 @@ export async function setSoundHook(enabled) {
|
|
|
338
351
|
hooks: [{
|
|
339
352
|
type: "command",
|
|
340
353
|
// Absolute node path, matching how the event hooks are installed: the
|
|
341
|
-
// shell a hook runs in does not necessarily have the user's PATH.
|
|
342
|
-
|
|
354
|
+
// shell a hook runs in does not necessarily have the user's PATH. And
|
|
355
|
+
// properly escaped for that shell, for the reason installer.mjs's
|
|
356
|
+
// hookCommand spells out — NOTIFY_PATH is built from
|
|
357
|
+
// $CLAUDE_CONFIG_DIR, double quotes do not suppress `$(…)` or a
|
|
358
|
+
// backtick on POSIX, and this string is executed at the end of every
|
|
359
|
+
// turn.
|
|
360
|
+
command: soundHookCommand(NOTIFY_PATH),
|
|
343
361
|
timeout: 5,
|
|
344
362
|
}],
|
|
345
363
|
});
|