ccdeck 3.38.6 → 3.39.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/README.md +68 -9
- package/THIRD_PARTY_NOTICES.md +12 -3
- package/dist/web/assets/BrowserWatchModal-ByzpjRVo.js +1 -0
- package/dist/web/assets/TrafficRadar-CyYzkKvE.js +1 -0
- package/dist/web/assets/{UsageHistoryModal-C775SS_q.js → UsageHistoryModal-C5K8FVaU.js} +1 -1
- package/dist/web/assets/index-DGMP4S3t.js +305 -0
- package/dist/web/assets/index-WHHCRAB3.css +1 -0
- package/dist/web/index.html +11 -9
- package/package.json +10 -4
- package/release-notes.json +28 -0
- package/src/server/cswap-admin.mjs +74 -11
- package/src/server/index.mjs +7 -1
- package/src/server/request-gates.mjs +3 -0
- package/src/server/traffic-radar-capture.mjs +136 -0
- package/src/server/traffic-radar-codec-LICENSES.md +597 -0
- package/src/server/traffic-radar-codec-source.mjs +34 -0
- package/src/server/traffic-radar-codec.mjs +41 -0
- package/src/server/traffic-radar-config.mjs +87 -0
- package/src/server/traffic-radar-helper.mjs +43 -0
- package/src/server/traffic-radar-otlp.json +1 -0
- package/src/server/traffic-radar-routes.mjs +40 -0
- package/src/server/traffic-radar-wire.mjs +228 -0
- package/src/server/traffic-radar.mjs +134 -0
- package/dist/web/assets/BrowserWatchModal-FsBjYpPs.js +0 -1
- package/dist/web/assets/index-BKLXyjIh.js +0 -303
- package/dist/web/assets/index-DnJPDhs9.css +0 -1
package/dist/web/index.html
CHANGED
|
@@ -49,23 +49,25 @@
|
|
|
49
49
|
Vite leaves untouched in the built HTML (it rewrites only scripts with
|
|
50
50
|
a src or type="module"). Deliberately dependency-free, and kept honest
|
|
51
51
|
against resolveTheme() in theme.ts by theme-first-paint.test.ts, which
|
|
52
|
-
runs this exact text. With nothing stored,
|
|
52
|
+
runs this exact text. With nothing stored, Rider Black is selected. -->
|
|
53
53
|
<script>
|
|
54
54
|
(function () {
|
|
55
|
-
|
|
55
|
+
// Generated from JSON alongside the CSS and Settings catalog.
|
|
56
|
+
var themes = /* theme-ids:start */ ["light","dark","rider-black","vscode-black","omarchy","matrix","black-contrast","white-contrast","catppuccin-mocha"] /* theme-ids:end */;
|
|
57
|
+
var lightThemes = /* theme-light-ids:start */ ["light","white-contrast"] /* theme-light-ids:end */;
|
|
58
|
+
var stored = null;
|
|
56
59
|
// `window.localStorage` is a GETTER that throws where site data is
|
|
57
60
|
// blocked, so the property read is inside the try, not just getItem.
|
|
58
61
|
// A refused store must cost a preference and never the boot.
|
|
59
62
|
try { stored = window.localStorage.getItem("agent-dag.theme"); } catch (e) {}
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
document.documentElement.setAttribute("data-theme"
|
|
64
|
-
stored === "light" || (stored !== "dark" && light) ? "light" : "dark");
|
|
63
|
+
// Rider Black is the default; saved choices always win.
|
|
64
|
+
var theme = themes.indexOf(stored) >= 0 ? stored : "rider-black";
|
|
65
|
+
document.documentElement.setAttribute("data-theme", theme);
|
|
66
|
+
document.documentElement.setAttribute("data-color-scheme", lightThemes.indexOf(theme) >= 0 ? "light" : "dark");
|
|
65
67
|
})();
|
|
66
68
|
</script>
|
|
67
|
-
<script type="module" crossorigin src="/assets/index-
|
|
68
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
69
|
+
<script type="module" crossorigin src="/assets/index-DGMP4S3t.js"></script>
|
|
70
|
+
<link rel="stylesheet" crossorigin href="/assets/index-WHHCRAB3.css">
|
|
69
71
|
</head>
|
|
70
72
|
<body>
|
|
71
73
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccdeck",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.39.1",
|
|
4
4
|
"description": "Local dashboard for Claude Code and Codex CLI sessions: which Claude Code session needs input, every tool call and Claude Code subagent live, token cost and quota, several Claude accounts. Run npx ccdeck.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -18,13 +18,15 @@
|
|
|
18
18
|
"LICENSING.md"
|
|
19
19
|
],
|
|
20
20
|
"scripts": {
|
|
21
|
+
"themes:generate": "node scripts/build-themes.mjs",
|
|
22
|
+
"themes:check": "node scripts/build-themes.mjs --check",
|
|
21
23
|
"dev:web": "vite",
|
|
22
24
|
"dev:server": "node src/server/index.mjs",
|
|
23
25
|
"start": "node bin/agent-dag.js",
|
|
24
|
-
"build": "vite build",
|
|
26
|
+
"build": "node scripts/build-radar-codec.mjs && vite build",
|
|
25
27
|
"test": "vitest run",
|
|
26
|
-
"prepublishOnly": "
|
|
27
|
-
"prepack": "
|
|
28
|
+
"prepublishOnly": "npm run build",
|
|
29
|
+
"prepack": "npm run build",
|
|
28
30
|
"typecheck": "tsc --noEmit -p tsconfig.build.json"
|
|
29
31
|
},
|
|
30
32
|
"engines": {
|
|
@@ -77,7 +79,11 @@
|
|
|
77
79
|
"@types/react-dom": "^18.3.1",
|
|
78
80
|
"@vitejs/plugin-react": "^4.3.4",
|
|
79
81
|
"dagre": "^0.8.5",
|
|
82
|
+
"esbuild": "0.25.12",
|
|
80
83
|
"hls.js": "^1.7.3",
|
|
84
|
+
"hpack.js": "2.1.6",
|
|
85
|
+
"long": "5.3.2",
|
|
86
|
+
"protobufjs": "8.8.0",
|
|
81
87
|
"react": "^18.3.1",
|
|
82
88
|
"react-dom": "^18.3.1",
|
|
83
89
|
"reactflow": "^11.11.4",
|
package/release-notes.json
CHANGED
|
@@ -75,6 +75,34 @@
|
|
|
75
75
|
"3.38.2",
|
|
76
76
|
"3.38.6"
|
|
77
77
|
],
|
|
78
|
+
"3.39.1": [
|
|
79
|
+
{
|
|
80
|
+
"title": "🎨 More themes, with clearer contrast",
|
|
81
|
+
"body": "Choose Omarchy, Matrix, Black Contrast, White Contrast or Catppuccin Mocha in Settings › Appearance. Fresh installations start with Rider Black; your saved theme choice is preserved."
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"title": "A little more room on the canvas",
|
|
85
|
+
"body": "The minimap is smaller, leaving more room for the character and your sessions. Undo and Resume now sit beside each other at the bottom of the canvas. Telemetry Radar is marked Beta while it is being tested and improved."
|
|
86
|
+
}
|
|
87
|
+
],
|
|
88
|
+
"3.39.0": [
|
|
89
|
+
{
|
|
90
|
+
"title": "🔎 Inspect Claude Code telemetry on macOS",
|
|
91
|
+
"body": "Telemetry Radar shows Claude Code's configured telemetry and can inspect traffic at destinations you choose. When settings are absent, it can show observed connections separately; a connection alone does not prove telemetry or reveal message contents. Capture starts only after you run its generated command in Terminal, which uses macOS tcpdump with administrator permission. Captured telemetry can contain prompt or response content, and other apps sending to the same destination may appear too. Messages stay in ccdeck's memory for up to five minutes; no capture file is written."
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
"title": "🧭 Open panels from the edges",
|
|
95
|
+
"body": "Open the session list, Claude accounts, Usage, This machine, Usage history and Browser Watch from the window edges. You can keep several panels open beside the canvas; on a phone, the same controls sit in a bottom dock."
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
"title": "More control while you work",
|
|
99
|
+
"body": "Turn off single-key shortcuts if typed letters should not control the deck, see each stage while adding a Claude account, and undo a recent rearrangement. The topbar also names the Claude Code session waiting on you."
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
"title": "🎨 Two new dark themes",
|
|
103
|
+
"body": "Choose Rider Black or VS Code Black in Settings › Appearance. Both add a darker canvas with flatter panels and distinct focus accents; your existing Light or Dark choice stays as it was."
|
|
104
|
+
}
|
|
105
|
+
],
|
|
78
106
|
"3.38.5": [
|
|
79
107
|
{
|
|
80
108
|
"title": "🔧 Settings are in one place now",
|
|
@@ -282,6 +282,36 @@ export function newSlot(before, after) {
|
|
|
282
282
|
return fresh.length === 1 ? fresh[0] : null;
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
+
/**
|
|
286
|
+
* The slot the account that just signed in occupies after `cswap add`, or null.
|
|
287
|
+
*
|
|
288
|
+
* ASKED OF THE IDENTITY, NOT OF WHAT IS NEW. `before` is the store as it was
|
|
289
|
+
* when the sign-in started, minutes ago, and a share or a Local network round
|
|
290
|
+
* can land other accounts in between. Reading "the one new slot" then named
|
|
291
|
+
* somebody else's slot for a re-sign-in of an account already here, and found
|
|
292
|
+
* nothing for a new account that arrived beside another.
|
|
293
|
+
*
|
|
294
|
+
* A NEW slot with this address still comes first, because one address under
|
|
295
|
+
* two organizations is two accounts: signing into the second organization of
|
|
296
|
+
* an address the store already holds adds a slot, and slotOf — which falls
|
|
297
|
+
* back to the first slot with the address when the CLI names no organization
|
|
298
|
+
* — would answer with the old one and call the new account a refresh. A slot
|
|
299
|
+
* whose organization the store does not record is not ruled out by one.
|
|
300
|
+
*
|
|
301
|
+
* newSlot is the last answer, for a store that keeps no address for the slot
|
|
302
|
+
* — the case it was written for — since nothing else can point at it then.
|
|
303
|
+
*/
|
|
304
|
+
export function signedInSlot(before, after, identity) {
|
|
305
|
+
const had = new Set(before.slots);
|
|
306
|
+
const org = identity?.orgId ?? "";
|
|
307
|
+
const fresh = after.slots.filter(s =>
|
|
308
|
+
!had.has(s)
|
|
309
|
+
&& sameAddress(after.emails[s], identity?.email)
|
|
310
|
+
&& (!org || !after.orgs?.[s] || after.orgs[s] === org));
|
|
311
|
+
if (fresh.length === 1) return fresh[0];
|
|
312
|
+
return slotOf(after, identity) ?? newSlot(before, after);
|
|
313
|
+
}
|
|
314
|
+
|
|
285
315
|
const sameAddress = (a, b) => String(a ?? "").trim().toLowerCase() === String(b ?? "").trim().toLowerCase();
|
|
286
316
|
|
|
287
317
|
/**
|
|
@@ -379,9 +409,14 @@ let _starting = null;
|
|
|
379
409
|
|
|
380
410
|
export function loginState() {
|
|
381
411
|
if (!_login) return { state: "idle" };
|
|
382
|
-
const { state, url, error, account, expiresAt, restore } = _login;
|
|
412
|
+
const { state, step, url, error, account, expiresAt, restore } = _login;
|
|
383
413
|
return {
|
|
384
414
|
state, url: url ?? null, error: error ?? null, account: account ?? null, expiresAt: expiresAt ?? null,
|
|
415
|
+
// Where inside `registering` the flow is — see LOGIN_STEPS. Null before it
|
|
416
|
+
// and on a failure that happened before it, and left on the step that was
|
|
417
|
+
// running when anything after it failed, which is how the dialog knows
|
|
418
|
+
// which of its stages to mark.
|
|
419
|
+
step: step ?? null,
|
|
385
420
|
// The sign-in's OTHER outcome, and the one it used to drop (#951). `true`
|
|
386
421
|
// until a restore has actually been attempted, because every state before
|
|
387
422
|
// that — awaiting_url, awaiting_code, registering — has moved nothing that
|
|
@@ -391,6 +426,23 @@ export function loginState() {
|
|
|
391
426
|
};
|
|
392
427
|
}
|
|
393
428
|
|
|
429
|
+
/**
|
|
430
|
+
* The points a sign-in passes through after the browser said yes, in order.
|
|
431
|
+
*
|
|
432
|
+
* `registering` covers all three and used to be reported as one state, so the
|
|
433
|
+
* dialog could only say "registering…" for however long they took together —
|
|
434
|
+
* an identity check, a `cswap add` that may first wait its turn on the store
|
|
435
|
+
* lock, and a `cswap switch` back to the account the user was on. Each is set
|
|
436
|
+
* on the flow at the moment it begins, never ahead of it, so a dialog that
|
|
437
|
+
* shows the step is showing what the deck is doing rather than a guess.
|
|
438
|
+
*
|
|
439
|
+
* confirm the claude CLI is asked who signed in (and, for a pasted code,
|
|
440
|
+
* first takes the code)
|
|
441
|
+
* save claude-swap records the account
|
|
442
|
+
* restore the account the user was working in is put back in front
|
|
443
|
+
*/
|
|
444
|
+
export const LOGIN_STEPS = Object.freeze(["confirm", "save", "restore"]);
|
|
445
|
+
|
|
394
446
|
/** The restore verdict, in the two fields a caller reads. Written once here so
|
|
395
447
|
* the login route, the cancel result and the dialog's type cannot drift. */
|
|
396
448
|
function restoreFields(restore) {
|
|
@@ -624,6 +676,7 @@ async function spawnLogin(email) {
|
|
|
624
676
|
// code posted while the question is out would otherwise register the same
|
|
625
677
|
// login a second time, with a second `cswap add` racing this one.
|
|
626
678
|
flow.state = "registering";
|
|
679
|
+
flow.step = "confirm";
|
|
627
680
|
const identity = await currentIdentity();
|
|
628
681
|
if (flow !== _login) return;
|
|
629
682
|
// A clean exit with somebody logged in is a completed sign-in, including
|
|
@@ -693,6 +746,10 @@ function loginFailureText(r) {
|
|
|
693
746
|
* with; the done handler simply drops it.
|
|
694
747
|
*/
|
|
695
748
|
async function registerSignedIn(flow, identity) {
|
|
749
|
+
// Before the lock, not inside it: waiting for another change to the store to
|
|
750
|
+
// finish is part of saving this one, and the dialog says so rather than
|
|
751
|
+
// going on claiming the sign-in is still being confirmed.
|
|
752
|
+
flow.step = "save";
|
|
696
753
|
return withStoreLock(async () => {
|
|
697
754
|
const add = await run(await cswapBin(), ["add"], { timeout: CSWAP_TIMEOUT_MS });
|
|
698
755
|
if (!add.ok) {
|
|
@@ -707,10 +764,9 @@ async function registerSignedIn(flow, identity) {
|
|
|
707
764
|
}
|
|
708
765
|
|
|
709
766
|
const after = await readStore();
|
|
710
|
-
|
|
711
|
-
//
|
|
712
|
-
|
|
713
|
-
const num = slot ?? slotOf(after, identity) ?? null;
|
|
767
|
+
// The slot that holds the identity that just signed in, found by asking for
|
|
768
|
+
// it rather than by looking for what is new: see signedInSlot.
|
|
769
|
+
const num = signedInSlot(flow.before, after, identity);
|
|
714
770
|
|
|
715
771
|
// Recorded now that claude-swap holds the account, and before the roster
|
|
716
772
|
// is invalidated below, so no read can land between the two and cache a
|
|
@@ -722,16 +778,20 @@ async function registerSignedIn(flow, identity) {
|
|
|
722
778
|
// or a Local network round can land a different account in between: two
|
|
723
779
|
// new slots, newSlot answers null, and the account this sign-in did add
|
|
724
780
|
// would never be marked.
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
781
|
+
//
|
|
782
|
+
// And the success card's "added" is the same answer, asked once. It used to
|
|
783
|
+
// be `newSlot(before, after) != null`, which a share landing during the
|
|
784
|
+
// sign-in turned into the wrong sentence either way.
|
|
785
|
+
const email = num != null ? (after.emails[num] || identity.email) : identity.email;
|
|
786
|
+
const org = num != null ? (after.orgs?.[num] ?? "") : "";
|
|
787
|
+
const added = num != null && !holds(flow.before, email, org);
|
|
788
|
+
if (num != null) await tellOrigins("signedIn", { email, org, added });
|
|
730
789
|
|
|
731
790
|
// Assigned, not discarded. `done` is still the right state — the account
|
|
732
791
|
// WAS added — but it is `done` with a qualification whenever this says the
|
|
733
792
|
// machine did not go back, and the dialog renders that qualification
|
|
734
793
|
// instead of "The account you were using is still active".
|
|
794
|
+
flow.step = "restore";
|
|
735
795
|
flow.restore = await restoreActive(flow.previousActive);
|
|
736
796
|
invalidateClaudeAccountsCache();
|
|
737
797
|
// Collect straight away, so the new row shows numbers instead of "never
|
|
@@ -739,7 +799,7 @@ async function registerSignedIn(flow, identity) {
|
|
|
739
799
|
runDetached(await cswapBin(), ["list"]);
|
|
740
800
|
|
|
741
801
|
flow.state = "done";
|
|
742
|
-
flow.account = { num, email: identity.email, added
|
|
802
|
+
flow.account = { num, email: identity.email, added };
|
|
743
803
|
return { ok: true, ...loginState() };
|
|
744
804
|
});
|
|
745
805
|
}
|
|
@@ -759,6 +819,7 @@ export async function submitLoginCode(code) {
|
|
|
759
819
|
|
|
760
820
|
const askedBefore = flow.prompts;
|
|
761
821
|
flow.state = "registering";
|
|
822
|
+
flow.step = "confirm";
|
|
762
823
|
flow.child.write(code.trim() + "\n");
|
|
763
824
|
|
|
764
825
|
// Whichever comes first: the CLI finishing, or it asking again. A wrong code
|
|
@@ -770,11 +831,13 @@ export async function submitLoginCode(code) {
|
|
|
770
831
|
]);
|
|
771
832
|
if (r === "rejected") {
|
|
772
833
|
flow.state = "awaiting_code";
|
|
834
|
+
flow.step = null;
|
|
773
835
|
flow.error = "that code was not accepted — copy it again from the browser";
|
|
774
836
|
return { ok: false, reason: "code_rejected", ...loginState() };
|
|
775
837
|
}
|
|
776
838
|
if (r === "slow") {
|
|
777
839
|
flow.state = "awaiting_code";
|
|
840
|
+
flow.step = null;
|
|
778
841
|
flow.error = "the claude CLI has not answered — try the code again";
|
|
779
842
|
return { ok: false, reason: "no_verdict", ...loginState() };
|
|
780
843
|
}
|
package/src/server/index.mjs
CHANGED
|
@@ -20,6 +20,8 @@ export { challengeDeck, challengeProof, isProcessAlive };
|
|
|
20
20
|
// The gates in front of the route table — see src/server/request-gates.mjs,
|
|
21
21
|
// which also holds the per-process token the strictest of them checks.
|
|
22
22
|
import { GUARDED_READS, OPEN_MUTATIONS, isAuthorizedDataRead, isAuthorizedMutation, isTrustedMutation, isTrustedRead } from "./request-gates.mjs";
|
|
23
|
+
import { trafficRadar } from "./traffic-radar.mjs";
|
|
24
|
+
import { handleTrafficCapture, isAuthorizedTrafficIngest } from "./traffic-radar-routes.mjs";
|
|
23
25
|
// How much the event ring may hold and what one event is charged against it —
|
|
24
26
|
// see ring-bounds.mjs. The four it exported from this file, it still exports.
|
|
25
27
|
export { MAX_BUFFER, MAX_BUFFER_CHARS, MAX_RING_ENTRIES, payloadChars } from "./ring-bounds.mjs";
|
|
@@ -280,7 +282,7 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
280
282
|
// added later is protected until someone deliberately lists it as open,
|
|
281
283
|
// which is the direction this has to fail in. See isAuthorizedMutation.
|
|
282
284
|
if (req.method !== "GET" && req.method !== "HEAD"
|
|
283
|
-
&& !OPEN_MUTATIONS.has(url.pathname) && !isAuthorizedMutation(req)) {
|
|
285
|
+
&& !OPEN_MUTATIONS.has(url.pathname) && !isAuthorizedMutation(req) && !isAuthorizedTrafficIngest(req, url)) {
|
|
284
286
|
return send(res, 401, { error: "unauthenticated" });
|
|
285
287
|
}
|
|
286
288
|
|
|
@@ -330,6 +332,10 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
330
332
|
if (req.method === "GET" && url.pathname === "/api/rating") return handleRatingRead(req, res);
|
|
331
333
|
if (req.method === "POST" && url.pathname === "/api/rating") return guard(handleRatingWrite(req, res), res);
|
|
332
334
|
if (req.method === "GET" && url.pathname === "/api/system") return send(res, 200, systemSnapshot());
|
|
335
|
+
if (req.method === "GET" && url.pathname === "/api/system/traffic-radar") {
|
|
336
|
+
return guard(trafficRadar.read().then(snapshot => send(res, 200, snapshot)), res);
|
|
337
|
+
}
|
|
338
|
+
if (url.pathname.startsWith("/api/system/traffic-radar/")) return guard(handleTrafficCapture(req, res, url), res);
|
|
333
339
|
// On demand only — the process list costs a subprocess on every platform,
|
|
334
340
|
// so it is fetched while the detail panel is open and never on the timer.
|
|
335
341
|
if (req.method === "GET" && url.pathname === "/api/system/processes") {
|
|
@@ -430,6 +430,9 @@ export function isAuthorizedDataRead(req) {
|
|
|
430
430
|
* costs nothing: every fetch of both is in src/web, and a page's own GET
|
|
431
431
|
* carries Sec-Fetch-Site: same-origin. */
|
|
432
432
|
export const GUARDED_READS = new Set([
|
|
433
|
+
"/api/system/traffic-radar",
|
|
434
|
+
"/api/system/traffic-radar/capture",
|
|
435
|
+
"/api/system/traffic-radar/capture/event",
|
|
433
436
|
"/events",
|
|
434
437
|
"/api/events",
|
|
435
438
|
"/api/claude-accounts",
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { isIP } from "node:net";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { createWireCapture } from "./traffic-radar-wire.mjs";
|
|
6
|
+
|
|
7
|
+
const WINDOW = 300_000;
|
|
8
|
+
const DURATION = 600_000;
|
|
9
|
+
const MAX_CHARS = 4_000_000;
|
|
10
|
+
const quote = value => `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
11
|
+
export function captureDestination(value) {
|
|
12
|
+
const match = /^(\d{1,3}(?:\.\d{1,3}){3}):(\d{1,5})$/.exec(String(value));
|
|
13
|
+
if (!match || isIP(match[1]) !== 4 || +match[2] < 1 || +match[2] > 65535) throw new Error('Choose an IPv4 address and TCP port.');
|
|
14
|
+
return { host: match[1], port: +match[2] };
|
|
15
|
+
}
|
|
16
|
+
async function networkInterface(host) {
|
|
17
|
+
return new Promise(resolve => execFile('/sbin/route', ['-n', 'get', host], { timeout: 2000, maxBuffer: 16_384 }, (error, stdout) => {
|
|
18
|
+
const name = !error && /interface:\s+([a-zA-Z0-9_.:-]{1,32})/.exec(stdout)?.[1];
|
|
19
|
+
resolve(name || null);
|
|
20
|
+
}));
|
|
21
|
+
}
|
|
22
|
+
export function exportSummary(payload, signal) {
|
|
23
|
+
const group = signal === 'traces' ? 'Spans' : signal === 'logs' ? 'Logs' : 'Metrics';
|
|
24
|
+
const item = signal === 'traces' ? 'spans' : signal === 'logs' ? 'logRecords' : 'metrics';
|
|
25
|
+
const records = (payload['resource' + group] ?? []).flatMap(r => (r['scope' + group] ?? []).flatMap(s => s[item] ?? []));
|
|
26
|
+
const fields = new Set(), sessionIds = new Set();
|
|
27
|
+
const collect = attributes => {
|
|
28
|
+
for (const a of attributes ?? []) if (['session.id', 'session_id'].includes(a.key)) {
|
|
29
|
+
const id = a.value?.stringValue;
|
|
30
|
+
if (typeof id === 'string' && id.length > 0 && id.length <= 200) sessionIds.add(id);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
for (const resource of payload['resource' + group] ?? []) {
|
|
34
|
+
collect(resource.resource?.attributes);
|
|
35
|
+
for (const scope of resource['scope' + group] ?? []) for (const record of scope[item] ?? []) {
|
|
36
|
+
collect(record.attributes);
|
|
37
|
+
for (const kind of ['gauge', 'sum', 'histogram', 'exponentialHistogram', 'summary'])
|
|
38
|
+
for (const point of record[kind]?.dataPoints ?? []) collect(point.attributes);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
for (const record of records) for (const a of record.attributes ?? []) {
|
|
42
|
+
if (/prompt/i.test(a.key)) fields.add('Prompt');
|
|
43
|
+
if (/response|completion/i.test(a.key)) fields.add('Response');
|
|
44
|
+
if (/tool/i.test(a.key)) fields.add('Tool');
|
|
45
|
+
}
|
|
46
|
+
return { count: records.length, name: String(records[0]?.eventName || records[0]?.name || signal).slice(0, 200), content: [...fields], sessionIds: [...sessionIds] };
|
|
47
|
+
}
|
|
48
|
+
export function createTrafficCapture({ now = Date.now, platform = process.platform, findInterface = networkInterface, wire = createWireCapture } = {}) {
|
|
49
|
+
let session = null, parsers = [], timer = null, nextId = 1, chars = 0;
|
|
50
|
+
const events = new Map();
|
|
51
|
+
let observations = [];
|
|
52
|
+
function prune() {
|
|
53
|
+
observations = observations.filter(entry => now() - entry.observedAt <= WINDOW);
|
|
54
|
+
for (const [id, event] of events) if (now() - event.observedAt > WINDOW) { events.delete(id); chars -= event.chars; }
|
|
55
|
+
if (session?.token && now() >= session.expiresAt) stop('expired');
|
|
56
|
+
else if (session?.token && session.sources.some(s => s.lastInputAt !== null) &&
|
|
57
|
+
session.sources.filter(s => s.lastInputAt !== null).every(s => now() - s.lastInputAt > 5000)) stop('interrupted');
|
|
58
|
+
if (!session?.token && !events.size && !observations.length && timer) { clearInterval(timer); timer = null; }
|
|
59
|
+
}
|
|
60
|
+
function stop(state = 'stopped') {
|
|
61
|
+
if (session) { session.state = state; session.token = null; for (const source of session.sources) source.command = null; }
|
|
62
|
+
for (const parser of parsers) parser.close(); parsers = [];
|
|
63
|
+
}
|
|
64
|
+
function addExport(value) {
|
|
65
|
+
const text = JSON.stringify(value.payload);
|
|
66
|
+
const cost = text.length + value.protobufBase64.length;
|
|
67
|
+
if (cost > 1_000_000) { session.issues.payload_limit = (session.issues.payload_limit ?? 0) + 1; return null; }
|
|
68
|
+
const id = nextId++;
|
|
69
|
+
while (events.size >= 100 || (chars + cost > MAX_CHARS && events.size)) {
|
|
70
|
+
const first = events.keys().next().value; chars -= events.get(first).chars; events.delete(first);
|
|
71
|
+
}
|
|
72
|
+
events.set(id, { ...value, id, observedAt: now(), chars: cost, ...exportSummary(value.payload, value.signal), outcome: 'unconfirmed', response: null });
|
|
73
|
+
chars += cost;
|
|
74
|
+
return id;
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
async prepare(destination, localPort) {
|
|
78
|
+
if (platform !== 'darwin') throw new Error('Passive capture setup is available on macOS.');
|
|
79
|
+
const targets = [...new Set((Array.isArray(destination) ? destination : [destination]).map(String))];
|
|
80
|
+
if (!targets.length || targets.length > 8) throw new Error('Choose between one and eight destinations.');
|
|
81
|
+
const addresses = targets.map(captureDestination);
|
|
82
|
+
if (!Number.isInteger(localPort) || localPort < 1 || localPort > 65535) throw new Error('Local ccdeck port unavailable.');
|
|
83
|
+
const names = await Promise.all(addresses.map(({ host }) => findInterface(host)));
|
|
84
|
+
if (names.some(name => !name || !/^[a-zA-Z0-9_.:-]{1,32}$/.test(name))) throw new Error('Network interface unavailable. Check the destination and retry.');
|
|
85
|
+
stop(); events.clear(); observations = []; chars = 0;
|
|
86
|
+
const token = randomBytes(32).toString('hex');
|
|
87
|
+
const helper = fileURLToPath(new URL('./traffic-radar-helper.mjs', import.meta.url));
|
|
88
|
+
const sources = addresses.map(({ host, port }, index) => {
|
|
89
|
+
const filter = `host ${host} and tcp port ${port}`;
|
|
90
|
+
const command = `sudo /usr/sbin/tcpdump -i ${quote(names[index])} -nn -U -s 0 -w - ${quote(filter)} | ${quote(process.execPath)} ${quote(helper)} ${quote(`http://127.0.0.1:${localPort}`)} ${quote(token)} ${quote(index)}`;
|
|
91
|
+
return { destination: `${host}:${port}`, interface: names[index], command, lastInputAt: null, bytes: 0 };
|
|
92
|
+
});
|
|
93
|
+
session = { id: randomBytes(8).toString('hex'), state: 'awaiting', destination: sources[0].destination, interface: sources[0].interface,
|
|
94
|
+
sources, startedAt: now(), expiresAt: now() + DURATION, lastInputAt: null, token, bytes: 0, issues: {} };
|
|
95
|
+
parsers = addresses.map(({ host, port }) => wire({ host, port, onExport: addExport, onResponse: (id, response) => {
|
|
96
|
+
const event = events.get(id);
|
|
97
|
+
if (event) { event.response = response; event.outcome = response.outcome; }
|
|
98
|
+
}, onObservation: value => { observations.unshift({ id: nextId++, ...value, observedAt: now() }); observations = observations.slice(0, 100); }, onIssue: code => { session.issues[code] = (session.issues[code] ?? 0) + 1; } }));
|
|
99
|
+
if (!timer) { timer = setInterval(prune, 1000); timer.unref?.(); }
|
|
100
|
+
return { command: sources[0].command, commands: sources.map(({ command, destination }) => ({ command, destination })), expiresAt: session.expiresAt, sessionId: session.id };
|
|
101
|
+
},
|
|
102
|
+
accepts(token) {
|
|
103
|
+
prune();
|
|
104
|
+
return typeof token === 'string' && /^[a-f0-9]{64}$/.test(token) && !!session?.token
|
|
105
|
+
&& timingSafeEqual(Buffer.from(token), Buffer.from(session.token));
|
|
106
|
+
},
|
|
107
|
+
ingest(token, chunk, source = 0) {
|
|
108
|
+
if (!this.accepts(token)) return false;
|
|
109
|
+
if (chunk.length > 262_144) throw new Error('Capture chunk too large.');
|
|
110
|
+
if (!Number.isInteger(source) || !parsers[source]) return false;
|
|
111
|
+
session.sources[source].lastInputAt = now();
|
|
112
|
+
session.sources[source].bytes += chunk.length;
|
|
113
|
+
session.lastInputAt = now();
|
|
114
|
+
if (chunk.length) session.state = 'capturing';
|
|
115
|
+
else if (session.state === 'awaiting') session.state = 'receiving';
|
|
116
|
+
session.bytes += chunk.length;
|
|
117
|
+
try { parsers[source].feed(chunk); } catch { stop('error'); return false; }
|
|
118
|
+
return true;
|
|
119
|
+
},
|
|
120
|
+
read() {
|
|
121
|
+
prune();
|
|
122
|
+
return { ok: true, sessionId: session?.id ?? null, state: session?.state ?? 'idle', destination: session?.destination ?? null, interface: session?.interface ?? null,
|
|
123
|
+
startedAt: session?.startedAt ?? null, expiresAt: session?.expiresAt ?? null, lastInputAt: session?.lastInputAt ?? null,
|
|
124
|
+
sources: session?.sources.map(({ command, ...source }) => ({ ...source, active: source.lastInputAt !== null && now() - source.lastInputAt <= 5000 && !!session.token })) ?? [],
|
|
125
|
+
observations: [...observations],
|
|
126
|
+
bytes: session?.bytes ?? 0, issues: { ...session?.issues },
|
|
127
|
+
events: [...events.values()].reverse().map(({ payload, protobufBase64, chars, flow, ...event }) => event),
|
|
128
|
+
};
|
|
129
|
+
},
|
|
130
|
+
detail(id) { prune(); const event = events.get(id); return event ? { payload: event.payload, protobufBase64: event.protobufBase64 } : null; },
|
|
131
|
+
stop,
|
|
132
|
+
clear() { events.clear(); observations = []; chars = 0; },
|
|
133
|
+
dispose() { stop(); events.clear(); observations = []; if (timer) clearInterval(timer); timer = null; },
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
export const trafficCapture = createTrafficCapture();
|