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.
- package/dist/web/assets/{index-C9im4KPT.css → index-CV3aF__6.css} +1 -1
- package/dist/web/assets/{index-BpDHqfOL.js → index-JwmQLh6n.js} +18 -18
- 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 +43 -2
- package/src/server/installer.mjs +30 -3
- package/src/server/quota.mjs +49 -48
- package/src/server/sound-hook.mjs +20 -2
package/dist/web/index.html
CHANGED
|
@@ -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-
|
|
44
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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.
|
|
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": {
|
package/src/server/ccusage.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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) {
|
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
|
@@ -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,
|
|
23
|
-
//
|
|
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 {
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
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
|
|
360
|
-
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
|
|
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
|
|
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(
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
const msg =
|
|
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
|
|
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(
|
|
529
|
+
const r = await _execOnce(bin);
|
|
529
530
|
cliOk = r.cliOk || cliOk;
|
|
530
531
|
if (r.parsed) { parsed = r.parsed; break; }
|
|
531
532
|
}
|