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.
- package/bin/deck.js +4 -2
- package/dist/web/assets/{index-D5TRuAbe.js → index-BlsUOLDL.js} +4 -4
- package/dist/web/index.html +1 -1
- package/hook/hook.js +117 -19
- package/package.json +1 -1
- package/src/server/index.mjs +42 -0
- package/src/server/installer.mjs +11 -2
package/dist/web/index.html
CHANGED
|
@@ -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-
|
|
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/,
|
|
6
|
-
// Dead instances are
|
|
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.
|
|
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": {
|
package/src/server/index.mjs
CHANGED
|
@@ -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);
|
package/src/server/installer.mjs
CHANGED
|
@@ -300,16 +300,25 @@ export function hasCodexInstalled() {
|
|
|
300
300
|
return existsSync(CODEX_DIR);
|
|
301
301
|
}
|
|
302
302
|
|
|
303
|
-
|
|
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
|
|