agent-dag 1.33.69 → 1.33.71

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.
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>agents-deck</title>
7
7
  <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='84' font-size='84'%3E%E2%97%89%3C/text%3E%3C/svg%3E" />
8
- <script type="module" crossorigin src="/assets/index-D5TRuAbe.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-BlsUOLDL.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/assets/index-CF2yG9oG.css">
10
10
  </head>
11
11
  <body>
package/hook/hook.js CHANGED
@@ -2,14 +2,16 @@
2
2
  // agent-dag hook forwarder. Invoked by Claude Code or Codex CLI as a command
3
3
  // hook. Reads stdin (event JSON), tags it with the provider passed via
4
4
  // `--provider <name>`, finds the matching agent-dag server via per-workspace
5
- // discovery files in <claude config dir>/agent-dag/, and POSTs the payload.
6
- // Dead instances are cleaned up.
5
+ // discovery files in <claude config dir>/agent-dag/, makes that server prove it
6
+ // is the deck the file describes, and POSTs the payload. Dead instances are
7
+ // cleaned up.
7
8
  "use strict";
8
9
 
9
10
  const fs = require("fs");
10
11
  const path = require("path");
11
12
  const http = require("http");
12
13
  const os = require("os");
14
+ const crypto = require("crypto");
13
15
 
14
16
  // Single shared discovery dir — Claude Code and Codex CLI both register here
15
17
  // via the installer. Lets one running agent-dag server receive both providers.
@@ -89,6 +91,111 @@ function isAlive(pid) {
89
91
  catch (e) { return e && e.code === "EPERM"; }
90
92
  }
91
93
 
94
+ /**
95
+ * The answer a deck must give to be handed a session payload.
96
+ *
97
+ * Liveness of the recorded pid is not evidence that the thing listening on the
98
+ * recorded port is a deck. A deck killed with SIGKILL or lost to a power cut
99
+ * leaves its discovery file behind — nothing unlinks it — and every cleanup
100
+ * path here and in the server probes the same pid. Once the OS hands that
101
+ * number to some other long-lived process the file passes forever, and the
102
+ * port it names may by then belong to anything at all (4317, the deck's own
103
+ * default, is also the standard OTLP collector port). What was POSTed there is
104
+ * the whole hook event: prompt text, tool inputs, tool results, cwd.
105
+ *
106
+ * So the port has to prove itself before it is told anything. The deck writes a
107
+ * fresh random token into its discovery file at startup; this hook asks the
108
+ * listener to hash that token against a nonce it has never seen, and sends the
109
+ * payload only if the answer matches. A stranger on the port cannot answer
110
+ * without the token, and the nonce is new every time, so an answer overheard
111
+ * earlier is worth nothing. Note the direction: the hook never transmits the
112
+ * token itself, only a challenge, so a wrong listener learns nothing it could
113
+ * replay against the next event.
114
+ *
115
+ * Both sides must derive the proof identically — src/server/index.mjs exports
116
+ * the same function under the same name, and the pair is pinned by a test.
117
+ */
118
+ function challengeProof(token, nonce) {
119
+ return crypto.createHash("sha256").update(`${token}:${nonce}`).digest("hex");
120
+ }
121
+
122
+ // Constant-time compare, purely so a hostile listener cannot walk the expected
123
+ // proof out of us one byte at a time by timing how long we take to hang up. The
124
+ // lengths are public (64 hex chars) and a mismatched one is rejected outright,
125
+ // which is what timingSafeEqual requires of its arguments anyway.
126
+ function sameProof(got, want) {
127
+ if (typeof got !== "string") return false;
128
+ const a = Buffer.from(got, "utf8");
129
+ const b = Buffer.from(want, "utf8");
130
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
131
+ }
132
+
133
+ // Two round trips now happen per target, and main()'s hard cap is 1500ms, so
134
+ // the pair has to fit inside it with room to spare. The challenge is a bodyless
135
+ // GET to a loopback port — sub-millisecond when a deck is there, and instant
136
+ // ECONNREFUSED when nothing is.
137
+ const CHALLENGE_TIMEOUT_MS = 400;
138
+ const POST_TIMEOUT_MS = 1000;
139
+
140
+ /**
141
+ * Ask the listener to prove it is the deck that wrote `d`, and POST the payload
142
+ * only once it has. `done` runs exactly once, whatever the outcome — a refused
143
+ * connection, a silent port, a wrong answer and a delivered event all just mean
144
+ * this target is finished.
145
+ */
146
+ function deliver(d, body, done) {
147
+ let settled = false;
148
+ const finish = () => { if (settled) return; settled = true; done(); };
149
+
150
+ const nonce = crypto.randomBytes(16).toString("hex");
151
+ const want = challengeProof(d.token, nonce);
152
+
153
+ const req = http.request({
154
+ hostname: "127.0.0.1",
155
+ port: d.port,
156
+ path: `/api/hook-challenge?nonce=${nonce}`,
157
+ method: "GET",
158
+ timeout: CHALLENGE_TIMEOUT_MS,
159
+ }, res => {
160
+ if (res.statusCode !== 200) { res.resume(); return res.on("end", finish); }
161
+ let answer = "";
162
+ res.setEncoding("utf8");
163
+ res.on("data", c => {
164
+ answer += c;
165
+ // A deck answers in ~100 bytes. Anything pouring data at us is not one,
166
+ // and must not be allowed to grow this buffer without bound.
167
+ if (answer.length > 4096) { req.destroy(); finish(); }
168
+ });
169
+ res.on("end", () => {
170
+ // Already given up on this target — a flood we cut off above. Whatever
171
+ // arrived before that is not an answer we are going to act on.
172
+ if (settled) return;
173
+ let proof;
174
+ try { proof = JSON.parse(answer).proof; } catch { return finish(); }
175
+ if (!sameProof(proof, want)) return finish();
176
+ post(d, body, finish);
177
+ });
178
+ });
179
+ req.on("error", finish);
180
+ req.on("timeout", () => req.destroy());
181
+ req.end();
182
+ }
183
+
184
+ function post(d, body, finish) {
185
+ const req = http.request({
186
+ hostname: "127.0.0.1",
187
+ port: d.port,
188
+ path: "/api/event",
189
+ method: "POST",
190
+ headers: { "Content-Type": "application/json" },
191
+ timeout: POST_TIMEOUT_MS,
192
+ }, res => { res.resume(); res.on("end", finish); });
193
+ req.on("error", finish);
194
+ req.on("timeout", () => req.destroy());
195
+ req.write(body);
196
+ req.end();
197
+ }
198
+
92
199
  function main() {
93
200
  // Hard cap so a stuck server can never wedge the host CLI.
94
201
  setTimeout(() => process.exit(0), 1500);
@@ -129,6 +236,12 @@ function main() {
129
236
  let d;
130
237
  try { d = JSON.parse(fs.readFileSync(path.join(DIR, file), "utf8")); } catch { continue; }
131
238
  if (typeof d.workspace !== "string" || !d.pid || !d.port) continue;
239
+ // No token, no payload. A file this old was written by a deck from before
240
+ // the handshake existed; that deck rewrites its file with a token the
241
+ // moment it restarts, so the cost of refusing is a blind deck until the
242
+ // next start, against the cost of trusting an unauthenticated port with
243
+ // every prompt the user types.
244
+ if (typeof d.token !== "string" || d.token === "") continue;
132
245
 
133
246
  if (!isAlive(d.pid)) {
134
247
  try { fs.unlinkSync(path.join(DIR, file)); } catch {}
@@ -154,22 +267,7 @@ function main() {
154
267
  let pending = targets.length;
155
268
  const done = () => { if (--pending <= 0) process.exit(0); };
156
269
 
157
- for (const { d } of targets) {
158
- let settled = false;
159
- const finish = () => { if (settled) return; settled = true; done(); };
160
- const req = http.request({
161
- hostname: "127.0.0.1",
162
- port: d.port,
163
- path: "/api/event",
164
- method: "POST",
165
- headers: { "Content-Type": "application/json" },
166
- timeout: 1000,
167
- }, res => { res.resume(); res.on("end", finish); });
168
- req.on("error", finish);
169
- req.on("timeout", () => req.destroy());
170
- req.write(taggedInput);
171
- req.end();
172
- }
270
+ for (const { d } of targets) deliver(d, taggedInput, done);
173
271
  });
174
272
  }
175
273
 
@@ -177,5 +275,5 @@ function main() {
177
275
  // the installer writes is `"<node>" "<...>/hook.js" --provider <name>`. Under a
178
276
  // require() it exports the matching rule and starts nothing, which is what lets
179
277
  // the rule be tested without a 1.5s exit timer in the test runner.
180
- module.exports = { cwdInWorkspace, foldsCase };
278
+ module.exports = { cwdInWorkspace, foldsCase, challengeProof };
181
279
  if (require.main === module) main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-dag",
3
- "version": "1.33.69",
3
+ "version": "1.33.71",
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": {
@@ -8,6 +8,7 @@ import { extname, join, resolve, dirname as pdirname, sep } from "node:path";
8
8
  import { fileURLToPath, pathToFileURL } from "node:url";
9
9
  import { dirname } from "node:path";
10
10
  import { createInterface } from "node:readline";
11
+ import { createHash, randomBytes } from "node:crypto";
11
12
  import { claudeConfigDir } from "./claude-dir.mjs";
12
13
 
13
14
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -1343,6 +1344,46 @@ function handleHealth(_req, res) {
1343
1344
  });
1344
1345
  }
1345
1346
 
1347
+ // A secret this process, and only this process, knows. It goes into the
1348
+ // discovery file bin/deck.js writes (mode 0600) and is never sent anywhere:
1349
+ // hook/hook.js reads it from that file and asks us to hash it against a nonce
1350
+ // before it will send us a single session payload.
1351
+ //
1352
+ // Fresh per start, deliberately. A discovery file that outlives its deck —
1353
+ // SIGKILL and power cuts both leave one behind — names a pid the OS is free to
1354
+ // hand to something else and a port that by then may belong to anything. The
1355
+ // pid probe below cannot tell that apart from a running deck, so the token is
1356
+ // what actually distinguishes us: the replacement process does not have it, and
1357
+ // neither does the next deck, so a stale file authenticates nothing.
1358
+ const HOOK_TOKEN = randomBytes(32).toString("hex");
1359
+
1360
+ /** The token this deck expects to be challenged on. Written by writeDiscovery. */
1361
+ export function hookToken() { return HOOK_TOKEN; }
1362
+
1363
+ /**
1364
+ * The proof of knowing `token`, for a nonce the challenger chose.
1365
+ *
1366
+ * hook/hook.js spells this out a second time — it is installed outside the
1367
+ * package and cannot import from here — and a test pins the two against each
1368
+ * other. Changing one without the other silently blinds the deck.
1369
+ */
1370
+ export function challengeProof(token, nonce) {
1371
+ return createHash("sha256").update(`${token}:${nonce}`).digest("hex");
1372
+ }
1373
+
1374
+ // GET /api/hook-challenge?nonce=… — answer a hook's challenge.
1375
+ //
1376
+ // The nonce is the caller's, so the answer proves knowledge of the token
1377
+ // without disclosing it, and proves it for this exchange only. Answering
1378
+ // freely is safe: a caller that can reach this port can already read the
1379
+ // discovery file that holds the token, being the same user on the same
1380
+ // machine, and the response is a hash the browser cannot read cross-origin.
1381
+ function handleHookChallenge(_req, res, url) {
1382
+ const nonce = url.searchParams.get("nonce") ?? "";
1383
+ if (!nonce || nonce.length > 256) return send(res, 400, { error: "bad nonce" });
1384
+ send(res, 200, { proof: challengeProof(HOOK_TOKEN, nonce) });
1385
+ }
1386
+
1346
1387
  function isProcessAlive(pid) {
1347
1388
  try { process.kill(pid, 0); return true; }
1348
1389
  catch (e) { return e && e.code === "EPERM"; }
@@ -1517,6 +1558,7 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
1517
1558
 
1518
1559
  if (req.method === "POST" && url.pathname === "/api/event") return guard(handleEventIngest(req, res), res);
1519
1560
  if (req.method === "GET" && url.pathname === "/api/health") return handleHealth(req, res);
1561
+ if (req.method === "GET" && url.pathname === "/api/hook-challenge") return handleHookChallenge(req, res, url);
1520
1562
  if (req.method === "GET" && url.pathname === "/events") return handleSse(req, res);
1521
1563
  if (req.method === "GET" && url.pathname === "/api/version") return guard(handleVersion(req, res), res);
1522
1564
  if (req.method === "POST" && url.pathname === "/api/upgrade") return guard(handleUpgrade(req, res), res);
@@ -300,16 +300,25 @@ export function hasCodexInstalled() {
300
300
  return existsSync(CODEX_DIR);
301
301
  }
302
302
 
303
- export async function writeDiscovery({ port, workspace }) {
303
+ // The token is the deck's proof of identity, so the file holding it is the
304
+ // deck's key material: readable and writable by its owner, nobody else. Windows
305
+ // ignores the mode (NTFS ACLs inherit from the profile directory, which is
306
+ // already per-user), and a file left over from an earlier run under a recycled
307
+ // pid keeps its old mode through writeFile, hence the explicit chmod.
308
+ export async function writeDiscovery({ port, workspace, token }) {
304
309
  await ensureDir(AGENT_DAG_DIR);
305
310
  const file = join(AGENT_DAG_DIR, `${process.pid}.json`);
306
311
  const data = {
307
312
  pid: process.pid,
308
313
  port,
309
314
  workspace: workspace ?? "",
315
+ // Without this the hooks refuse to post: a file naming a port it cannot
316
+ // authenticate is exactly the stale-file case they now decline to trust.
317
+ token: token ?? "",
310
318
  startedAt: new Date().toISOString(),
311
319
  };
312
- await writeFile(file, JSON.stringify(data, null, 2) + "\n", "utf8");
320
+ await writeFile(file, JSON.stringify(data, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
321
+ await chmod(file, 0o600).catch(() => {});
313
322
  return file;
314
323
  }
315
324