ccdeck 3.41.3 → 3.42.0
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 +3 -1
- package/bin/deck.js +4 -0
- package/dist/web/assets/{BrowserWatchModal-6TctCbc4.js → BrowserWatchModal-TdSBv7_T.js} +1 -1
- package/dist/web/assets/{RadioPanel-ZDDwNYnL.js → RadioPanel-bJc004IK.js} +1 -1
- package/dist/web/assets/{TrafficRadar-lfG5ekUR.js → TrafficRadar-pRTWTOpJ.js} +1 -1
- package/dist/web/assets/UsageHistoryModal-BBAEDRj9.js +1 -0
- package/dist/web/assets/index-BPLXy9SO.css +1 -0
- package/dist/web/assets/index-DzDnLqDX.js +307 -0
- package/dist/web/index.html +15 -4
- package/package.json +1 -1
- package/release-notes.json +18 -0
- package/src/server/agent-activity.mjs +33 -10
- package/src/server/browser-watch-decks.mjs +3 -2
- package/src/server/browser-watch.mjs +3 -4
- package/src/server/codex-watch.mjs +3 -3
- package/src/server/event-pipeline.mjs +2 -0
- package/src/server/feedback-form.mjs +1 -0
- package/src/server/index.mjs +4 -0
- package/src/server/pinned-build.mjs +1 -0
- package/src/server/reports-routes.mjs +31 -12
- package/src/server/request-gates.mjs +2 -0
- package/src/server/tool-audit-operations.mjs +112 -0
- package/src/server/tool-audit-report.mjs +115 -0
- package/src/server/tool-audit-routes.mjs +38 -0
- package/src/server/tool-audit.mjs +126 -0
- package/dist/web/assets/UsageHistoryModal-Be-9GVIE.js +0 -1
- package/dist/web/assets/index-BgSnAnP6.css +0 -1
- package/dist/web/assets/index-C80Hp_2s.js +0 -307
package/dist/web/index.html
CHANGED
|
@@ -53,8 +53,8 @@
|
|
|
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 */;
|
|
56
|
+
var themes = /* theme-ids:start */ ["light","dark","rider-black","vscode-black","omarchy","matrix","black-contrast","white-contrast","catppuccin-mocha","gruvbox-dark","gruvbox-light","gruvbox-material"] /* theme-ids:end */;
|
|
57
|
+
var lightThemes = /* theme-light-ids:start */ ["light","white-contrast","gruvbox-light"] /* theme-light-ids:end */;
|
|
58
58
|
var stored = null;
|
|
59
59
|
// `window.localStorage` is a GETTER that throws where site data is
|
|
60
60
|
// blocked, so the property read is inside the try, not just getItem.
|
|
@@ -66,8 +66,19 @@
|
|
|
66
66
|
document.documentElement.setAttribute("data-color-scheme", lightThemes.indexOf(theme) >= 0 ? "light" : "dark");
|
|
67
67
|
})();
|
|
68
68
|
</script>
|
|
69
|
-
<script
|
|
70
|
-
|
|
69
|
+
<script>
|
|
70
|
+
(function () {
|
|
71
|
+
var size = null, density = null;
|
|
72
|
+
try {
|
|
73
|
+
size = window.localStorage.getItem("agent-dag.text-size");
|
|
74
|
+
density = window.localStorage.getItem("agent-dag.density");
|
|
75
|
+
} catch (e) {}
|
|
76
|
+
document.documentElement.setAttribute("data-text-size", size === "smallest" || size === "smaller" || size === "larger" || size === "largest" ? size : "default");
|
|
77
|
+
document.documentElement.setAttribute("data-density", density === "tightest" || density === "tighter" || density === "compact" || density === "comfortable" ? density : "default");
|
|
78
|
+
})();
|
|
79
|
+
</script>
|
|
80
|
+
<script type="module" crossorigin src="/assets/index-DzDnLqDX.js"></script>
|
|
81
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BPLXy9SO.css">
|
|
71
82
|
</head>
|
|
72
83
|
<body>
|
|
73
84
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccdeck",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.42.0",
|
|
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": {
|
package/release-notes.json
CHANGED
|
@@ -78,6 +78,24 @@
|
|
|
78
78
|
"3.40.1",
|
|
79
79
|
"3.41.3"
|
|
80
80
|
],
|
|
81
|
+
"3.42.0": [
|
|
82
|
+
{
|
|
83
|
+
"title": "🔠 Text size and density, in Settings",
|
|
84
|
+
"body": "Five text sizes and five spacing levels, under Settings › Text & density."
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"title": "🛠️ Tool activity: which tools your agents use, how long they take and what fails",
|
|
88
|
+
"body": "A new page on the stripe on the right. Its history is kept on this device only."
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
"title": "🎨 Gruvbox Dark, Light and Material themes",
|
|
92
|
+
"body": "Pick one in Settings › General. Switching themes now fades instead of snapping."
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
"title": "✨ A session-list drawer on phones, whole dates in usage history, the waiting session in the minimap",
|
|
96
|
+
"body": "Smaller fixes for the phone layout, the usage history chart and the canvas minimap."
|
|
97
|
+
}
|
|
98
|
+
],
|
|
81
99
|
"3.41.1": [
|
|
82
100
|
{
|
|
83
101
|
"title": "🪟 More reliable Windows updates",
|
|
@@ -33,10 +33,16 @@
|
|
|
33
33
|
// the same GitLab project — settings, jobs, branches. Fourteen rows
|
|
34
34
|
// describe that worse than one card does. That is what toEpisodes is for.
|
|
35
35
|
// * Without exclusions the deck reports ITSELF: 41 visits to 127.0.0.1:4317
|
|
36
|
-
// and 34 to 127.0.0.1:4399 carry FROM_API,
|
|
37
|
-
// through `open` on every start (bin/deck.js) and the listen fallback range
|
|
36
|
+
// and 34 to 127.0.0.1:4399 carry FROM_API, and the listen fallback range
|
|
38
37
|
// means it is not always the same port. defaultExclusions() is that, and
|
|
39
|
-
// only that.
|
|
38
|
+
// only that. This note used to say `open` was the reason — ccdeck opens its
|
|
39
|
+
// own tab with it on every start (bin/deck.js). Retested on 2026-10-14, a
|
|
40
|
+
// URL that `open` (macOS, Chrome 155) or `xdg-open` (Linux, Brave 1.95)
|
|
41
|
+
// hands to a running Chromium is recorded as AUTO_TOPLEVEL WITHOUT the bit,
|
|
42
|
+
// so those 75 visits most likely came from something else that loaded the
|
|
43
|
+
// deck's page (they were FROM_API over LINK, the shape an extension's
|
|
44
|
+
// tabs.update leaves). The exclusion stands either way: the deck's own
|
|
45
|
+
// origin does collect FROM_API visits.
|
|
40
46
|
//
|
|
41
47
|
// WHAT "NOBODY WAS AT THE BROWSER" MEANS HERE. It is derived from the visit
|
|
42
48
|
// list itself: a human visit is any visit WITHOUT the bit. There is no OS idle
|
|
@@ -65,8 +71,25 @@
|
|
|
65
71
|
// reports program navigation; the person reading the card decides what it was.
|
|
66
72
|
|
|
67
73
|
/** Chrome's PAGE_TRANSITION_FROM_API qualifier — bit 27 of `visits.transition`.
|
|
68
|
-
*
|
|
69
|
-
*
|
|
74
|
+
* Chromium defines it only as "originated from an external application", so
|
|
75
|
+
* what sets it is Chrome's own code, and it is narrower than "any API":
|
|
76
|
+
*
|
|
77
|
+
* SET by a page loaded with the DevTools protocol's `Page.navigate`, whatever
|
|
78
|
+
* transition type was asked for (content/browser/devtools/protocol/
|
|
79
|
+
* page_handler.cc), which is how Playwright and Puppeteer go to an address,
|
|
80
|
+
* and by an extension's `chrome.tabs.update` with a URL
|
|
81
|
+
* (chrome/browser/extensions/api/tabs/tabs_api.cc).
|
|
82
|
+
*
|
|
83
|
+
* NOT SET by `Target.createTarget` with a URL (AUTO_TOPLEVEL,
|
|
84
|
+
* chrome/browser/devtools/chrome_devtools_manager_delegate.cc), an
|
|
85
|
+
* extension's `chrome.tabs.create` (LINK, extensions/open_tab_helper.cc), a
|
|
86
|
+
* link a program clicks, or a URL handed over by `open` or `xdg-open`
|
|
87
|
+
* (AUTO_TOPLEVEL, chrome/browser/ui/startup/startup_browser_creator_impl.cc).
|
|
88
|
+
*
|
|
89
|
+
* Tested on 2026-10-14 on Brave 1.95 (Chromium 153, Linux) and Chrome 155
|
|
90
|
+
* (macOS 26.6) with throwaway profiles. The NOT SET ones are invisible to this
|
|
91
|
+
* module, and since a visit without the bit counts as a person (classify),
|
|
92
|
+
* they can also cancel a program's flagged page within the quiet window. */
|
|
70
93
|
export const FROM_API = 0x08000000;
|
|
71
94
|
|
|
72
95
|
/** The same bit for the arbitrary-precision path below. */
|
|
@@ -227,11 +250,11 @@ function hostParts(text) {
|
|
|
227
250
|
* Exclusion matchers for the deck's own pages, from the origins it is listening
|
|
228
251
|
* on — `["http://127.0.0.1:4317"]`, or whatever the fallback range settled on.
|
|
229
252
|
*
|
|
230
|
-
* This is not politeness.
|
|
231
|
-
*
|
|
232
|
-
* exactly where the rule is most sensitive: nobody is
|
|
233
|
-
*
|
|
234
|
-
* frequent finding is the feature itself.
|
|
253
|
+
* This is not politeness. The measured profile held FROM_API visits to the
|
|
254
|
+
* deck's own origin (the header's note says why `open` is not their cause),
|
|
255
|
+
* and those visits land exactly where the rule is most sensitive: nobody is
|
|
256
|
+
* browsing while a program loads the deck's page. Unfiltered, the feature's
|
|
257
|
+
* loudest and most frequent finding is the feature itself.
|
|
235
258
|
*
|
|
236
259
|
* Each origin expands to its port on all three loopback spellings, because the
|
|
237
260
|
* deck opens 127.0.0.1 and a returning user may have typed localhost, and one
|
|
@@ -34,8 +34,9 @@ import { isProcessAlive } from "./deck-probe.mjs";
|
|
|
34
34
|
* `portRange`), so a machine that has been running decks for a month has tabs
|
|
35
35
|
* on several. The real profile this feature was tuned against carried 41 visits
|
|
36
36
|
* to 127.0.0.1:4317 and 34 to 127.0.0.1:4399 — two ports, both this deck, both
|
|
37
|
-
* FROM_API
|
|
38
|
-
* panel would have shown
|
|
37
|
+
* FROM_API (not from `open`, which left no FROM_API when tested: see FROM_API in
|
|
38
|
+
* agent-activity.mjs), and every one of them a card the panel would have shown
|
|
39
|
+
* its owner about itself.
|
|
39
40
|
*
|
|
40
41
|
* The whole range rather than the ports seen: the alternative is to remember
|
|
41
42
|
* which ports past decks used, which is a file to keep, a file to migrate, and
|
|
@@ -664,10 +664,9 @@ async function readProfile(profile, { sinceChromeTime, copyDir, deps, quietMs, o
|
|
|
664
664
|
* Everything the panel draws, in one object.
|
|
665
665
|
*
|
|
666
666
|
* `deckOrigins` are the addresses this deck is listening on. They are excluded
|
|
667
|
-
* by default and it is not an optimisation:
|
|
668
|
-
*
|
|
669
|
-
*
|
|
670
|
-
* time it launches.
|
|
667
|
+
* by default and it is not an optimisation: the deck's own origin collects
|
|
668
|
+
* FROM_API visits like any other program's page (see defaultExclusions), so a
|
|
669
|
+
* watch without them reports the deck as the intruder.
|
|
671
670
|
*/
|
|
672
671
|
export async function browserWatchSnapshot({
|
|
673
672
|
deckOrigins = [],
|
|
@@ -244,8 +244,8 @@ async function readCodexHeader(path) {
|
|
|
244
244
|
// write it to the log they share. The event is still buffered and broadcast —
|
|
245
245
|
// every deck watching the session draws it — it is only the second copy on disk
|
|
246
246
|
// that is dropped. See writesCodexLog in log-election.mjs.
|
|
247
|
-
function emitCodexEvent(payload, persist) {
|
|
248
|
-
pushEvent(payload, "codex", { persist });
|
|
247
|
+
function emitCodexEvent(payload, persist, toolAuditAt) {
|
|
248
|
+
pushEvent(payload, "codex", { persist, toolAuditAt });
|
|
249
249
|
}
|
|
250
250
|
|
|
251
251
|
/**
|
|
@@ -405,7 +405,7 @@ function emitCodexLines(state, consume, persist) {
|
|
|
405
405
|
}
|
|
406
406
|
if (payload) {
|
|
407
407
|
ensureCodexRoot(state, persist);
|
|
408
|
-
emitCodexEvent(payload, persist);
|
|
408
|
+
emitCodexEvent(payload, persist, typeof obj.timestamp === "string" ? Date.parse(obj.timestamp) : undefined);
|
|
409
409
|
}
|
|
410
410
|
}
|
|
411
411
|
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// imports reach pushEvent through event-sink.mjs, connected below as this
|
|
10
10
|
// module loads; index.mjs's routes import it from here. The bodies are
|
|
11
11
|
// unchanged.
|
|
12
|
+
import { noteToolAudit } from "./tool-audit.mjs";
|
|
12
13
|
import { PRODUCT } from "./brand.mjs";
|
|
13
14
|
import { createBlockNotifier } from "./block-notify.mjs";
|
|
14
15
|
import { notify as osNotify } from "./browser-react.mjs";
|
|
@@ -122,6 +123,7 @@ export function pushEvent(raw, source, opts = {}) {
|
|
|
122
123
|
// Numbered, charged, held, and the ring evicted behind it — see admitEvent.
|
|
123
124
|
const evt = admitEvent(raw, source, opts.receivedAt ?? Date.now());
|
|
124
125
|
const seq = evt.seq;
|
|
126
|
+
noteToolAudit(raw, source, opts);
|
|
125
127
|
|
|
126
128
|
// Does this event reach the log at all? Not on a replay (it came from
|
|
127
129
|
// there), not when the hook told us another deck owns this session's log,
|
|
@@ -139,6 +139,7 @@ export function upstreamForm({ fields, images }, facts) {
|
|
|
139
139
|
if (contact) text("contact", contact);
|
|
140
140
|
text("appVersion", facts.appVersion);
|
|
141
141
|
text("platform", facts.platform);
|
|
142
|
+
if (facts.deviceId) text("deviceId", facts.deviceId);
|
|
142
143
|
for (const [index, { data, format }] of images.entries()) {
|
|
143
144
|
const { type, extension } = FORMAT[format];
|
|
144
145
|
parts.push(`${head(IMAGES_FIELD, `; filename="image-${index + 1}.${extension}"`)}Content-Type: ${type}\r\n\r\n`, data, "\r\n");
|
package/src/server/index.mjs
CHANGED
|
@@ -164,6 +164,8 @@ import { handleBrowserWatch, handleBrowserWatchDismiss, handleBrowserWatchSettin
|
|
|
164
164
|
// The music routes, every one behind AGENTS_DECK_NO_MUSIC — see music-routes.mjs.
|
|
165
165
|
import { handleBestOfNostalgia, handleCafeMusicBgm, handleClaudeFm, handleFmStation, handleGoodLifeRadio, handleLiveRadioMix, handleLofiGirl, handleRadioStations, handleRadioClick, handleMusicConfig } from "./music-routes.mjs";
|
|
166
166
|
// The usage panel's quota and history reads — see usage-routes.mjs.
|
|
167
|
+
import { startToolAudit } from "./tool-audit.mjs";
|
|
168
|
+
import { handleToolAudit } from "./tool-audit-routes.mjs";
|
|
167
169
|
import { startQuotaHistory } from "./quota-history.mjs";
|
|
168
170
|
import { handleCcusage, handleCcusageSession, handleCodexQuota, handleCodexUsage, handleProviderStatus, handleQuota, handleQuotaHistory } from "./usage-routes.mjs";
|
|
169
171
|
// The port fallback, one listen attempt, and the words for a failed one — see
|
|
@@ -192,6 +194,7 @@ export function requestUrl(rawUrl) {
|
|
|
192
194
|
|
|
193
195
|
export async function startServer({ port = 4317, host = "127.0.0.1", persist = null, portRange = [4318, 4400], workspace = "", codex = true, claude = true, onRestart = null, onStop = null, cswapQuiet = null } = {}) {
|
|
194
196
|
startQuotaHistory();
|
|
197
|
+
await startToolAudit();
|
|
195
198
|
armLifecycle({ onRestart, onStop, persist, onSelfUpdate: () => reporter.noteSelfUpdate() });
|
|
196
199
|
// Which tree and which CLIs, normalised once — see setDeckScope.
|
|
197
200
|
setDeckScope({ workspace, claude, codex });
|
|
@@ -318,6 +321,7 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
318
321
|
if (req.method === "POST" && url.pathname === "/api/restart") return guard(handleRestart(req, res), res);
|
|
319
322
|
if (req.method === "POST" && url.pathname === "/api/presence") return guard(handlePresence(req, res), res);
|
|
320
323
|
if (req.method === "POST" && url.pathname === "/api/shutdown") return guard(handleStop(req, res), res);
|
|
324
|
+
if (["GET", "POST"].includes(req.method) && ["/api/tool-audit", "/api/tool-audit/storage"].includes(url.pathname)) return guard(handleToolAudit(req, res), res);
|
|
321
325
|
if (req.method === "GET" && url.pathname === "/api/quota/history") return guard(handleQuotaHistory(req, res), res);
|
|
322
326
|
if (req.method === "GET" && url.pathname === "/api/quota") return guard(handleQuota(req, res), res);
|
|
323
327
|
if (req.method === "GET" && url.pathname === "/api/codex-usage") return guard(handleCodexUsage(req, res), res);
|
|
@@ -7,7 +7,7 @@ import { reportsVetoed } from "./deck-prefs.mjs";
|
|
|
7
7
|
import { MAX_REQUEST_BYTES, isMultipart, readFeedbackForm, upstreamForm } from "./feedback-form.mjs";
|
|
8
8
|
import { readBody, readBytes, send } from "./http-io.mjs";
|
|
9
9
|
import { heldPrefs } from "./prefs-state.mjs";
|
|
10
|
-
import { REPORTS_API, installFacts, reporter } from "./reports.mjs";
|
|
10
|
+
import { REPORTS_API, deviceIdToken, installFacts, reporter, reportsOn } from "./reports.mjs";
|
|
11
11
|
|
|
12
12
|
async function readJson(req, res, limit) {
|
|
13
13
|
const raw = await readBody(req, res, limit).catch(() => null);
|
|
@@ -72,35 +72,54 @@ export async function handleClientError(req, res, { report = reporter } = {}) {
|
|
|
72
72
|
* one message. AGENTS_DECK_NO_INSTALL=1 still wins, because the README promises
|
|
73
73
|
* that it "turns off everything but the quota reads", and a launch script's
|
|
74
74
|
* word about the machine outranks a button on the page.
|
|
75
|
+
*
|
|
76
|
+
* `device` is the fingerprint feedbackDeviceId picks; a test passes its own,
|
|
77
|
+
* or null for none.
|
|
75
78
|
*/
|
|
76
|
-
export async function handleFeedback(req, res, { fetchImpl = globalThis.fetch, env = process.env } = {}) {
|
|
79
|
+
export async function handleFeedback(req, res, { fetchImpl = globalThis.fetch, env = process.env, device = feedbackDeviceId({ env }) } = {}) {
|
|
77
80
|
if (env.AGENTS_DECK_NO_INSTALL === "1") return send(res, 403, { ok: false, reason: "vetoed" });
|
|
81
|
+
// Left out of the post entirely when there is none: JSON drops an undefined.
|
|
82
|
+
const deviceId = device || undefined;
|
|
78
83
|
const contentType = req.headers?.["content-type"];
|
|
79
|
-
if (isMultipart(contentType)) return forwardFeedbackForm(req, res, contentType, { fetchImpl, env });
|
|
84
|
+
if (isMultipart(contentType)) return forwardFeedbackForm(req, res, contentType, { fetchImpl, env, deviceId });
|
|
80
85
|
const body = await readJson(req, res, 32_000);
|
|
81
86
|
if (!body) return send(res, 400, { ok: false, reason: "bad_request" });
|
|
82
87
|
const facts = feedbackFacts({ env });
|
|
83
88
|
const contact = typeof body.contact === "string" && body.contact.trim() ? body.contact.trim() : undefined;
|
|
84
89
|
return forwardFeedback(res, fetchImpl, {
|
|
85
90
|
headers: { "content-type": "application/json", "user-agent": `ccdeck/${facts.appVersion}` },
|
|
86
|
-
body: JSON.stringify({ kind: body.kind, title: body.title, body: body.body, contact, ...facts }),
|
|
91
|
+
body: JSON.stringify({ kind: body.kind, title: body.title, body: body.body, contact, ...facts, deviceId }),
|
|
87
92
|
timeoutMs: JSON_TIMEOUT_MS,
|
|
88
93
|
});
|
|
89
94
|
}
|
|
90
95
|
|
|
91
96
|
/**
|
|
92
|
-
* What this deck adds to a piece of
|
|
93
|
-
* version, and the system as `os-arch` — `darwin-arm64`.
|
|
94
|
-
*
|
|
95
|
-
* and
|
|
96
|
-
*
|
|
97
|
-
*
|
|
97
|
+
* What the dialog says, before Send, that this deck adds to a piece of
|
|
98
|
+
* feedback: the ccdeck version, and the system as `os-arch` — `darwin-arm64`.
|
|
99
|
+
* Both posts carry these two, from this one function, so what the line shows
|
|
100
|
+
* and what the API gets for them cannot drift apart.
|
|
101
|
+
*
|
|
102
|
+
* The posts also carry the device fingerprint (feedbackDeviceId), and the line
|
|
103
|
+
* does not show it: the owner's decision of 2026-10-14, so a report can be
|
|
104
|
+
* tied to the install that sent it. installFacts' other fields (the channel,
|
|
105
|
+
* the runtime, a locale) never go with feedback.
|
|
98
106
|
*/
|
|
99
107
|
export function feedbackFacts({ env = process.env } = {}) {
|
|
100
108
|
const facts = installFacts({ env });
|
|
101
109
|
return { appVersion: facts.version, platform: `${facts.os}-${facts.arch}` };
|
|
102
110
|
}
|
|
103
111
|
|
|
112
|
+
/**
|
|
113
|
+
* The device fingerprint a piece of feedback carries: the same one-way hash
|
|
114
|
+
* every usage report sends (deviceIdToken), and under the same rule — only
|
|
115
|
+
* while reports are on. Switched off, or vetoed with AGENTS_DECK_NO_REPORTS=1,
|
|
116
|
+
* the fingerprint stays home here as it does everywhere else, and the feedback
|
|
117
|
+
* still goes. It is read here, never taken from the page.
|
|
118
|
+
*/
|
|
119
|
+
export function feedbackDeviceId({ env = process.env, prefs = heldPrefs.current() } = {}) {
|
|
120
|
+
return reportsOn(prefs, env) ? deviceIdToken() : undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
104
123
|
/** `GET /api/feedback`: what a report sent from here would carry besides its
|
|
105
124
|
* words, for the dialog to say before Send. Nothing leaves the machine. */
|
|
106
125
|
export function handleFeedbackFacts(_req, res, { env = process.env } = {}) {
|
|
@@ -113,7 +132,7 @@ const JSON_TIMEOUT_MS = 20_000;
|
|
|
113
132
|
* seconds of upload before the API has read a byte of it. */
|
|
114
133
|
const FORM_TIMEOUT_MS = 120_000;
|
|
115
134
|
|
|
116
|
-
async function forwardFeedbackForm(req, res, contentType, { fetchImpl, env }) {
|
|
135
|
+
async function forwardFeedbackForm(req, res, contentType, { fetchImpl, env, deviceId }) {
|
|
117
136
|
// Over the cap, readBytes has already answered 413 too_large and the 400
|
|
118
137
|
// below is a no-op; this is the answer to a read that failed some other way.
|
|
119
138
|
const bytes = await readBytes(req, res, MAX_REQUEST_BYTES, { ok: false, reason: "too_large" }).catch(() => null);
|
|
@@ -125,7 +144,7 @@ async function forwardFeedbackForm(req, res, contentType, { fetchImpl, env }) {
|
|
|
125
144
|
return forwardFeedback(res, fetchImpl, {
|
|
126
145
|
// No content type: fetch writes the form's own, boundary and all.
|
|
127
146
|
headers: { "user-agent": `ccdeck/${facts.appVersion}` },
|
|
128
|
-
body: upstreamForm(form, facts),
|
|
147
|
+
body: upstreamForm(form, { ...facts, deviceId }),
|
|
129
148
|
timeoutMs: FORM_TIMEOUT_MS,
|
|
130
149
|
});
|
|
131
150
|
}
|
|
@@ -430,6 +430,8 @@ 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/tool-audit",
|
|
434
|
+
"/api/tool-audit/storage",
|
|
433
435
|
"/api/system/traffic-radar",
|
|
434
436
|
"/api/system/traffic-radar/capture",
|
|
435
437
|
"/api/system/traffic-radar/capture/event",
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Classify shell operations without evaluating shell syntax or retaining arguments.
|
|
2
|
+
const safe = value => typeof value === 'string' && /^[A-Za-z0-9_./:+-]{1,100}$/.test(value) ? value : null;
|
|
3
|
+
const basename = value => value.split(/[\\/]/).at(-1).replace(/\.exe$/i, '');
|
|
4
|
+
const takesValue = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--config-env']);
|
|
5
|
+
|
|
6
|
+
function shellWords(command) {
|
|
7
|
+
const segments = [[]]; let token = '', quote = '', escaped = false;
|
|
8
|
+
const word = () => { if (token) segments.at(-1).push(token); token = ''; };
|
|
9
|
+
for (const ch of command.slice(0, 32768)) {
|
|
10
|
+
if (escaped) { token += ch; escaped = false; continue; }
|
|
11
|
+
if (ch === '\\' && quote !== "'") { escaped = true; continue; }
|
|
12
|
+
if (quote) { if (ch === quote) quote = ''; else token += ch; continue; }
|
|
13
|
+
if (ch === '"' || ch === "'") { quote = ch; continue; }
|
|
14
|
+
if (/\s/.test(ch)) { if (ch === '\n') { word(); segments.push([]); } else word(); }
|
|
15
|
+
else if (';&|'.includes(ch)) { word(); if (segments.at(-1).length) segments.push([]); }
|
|
16
|
+
else token += ch;
|
|
17
|
+
}
|
|
18
|
+
word();
|
|
19
|
+
return { segments: segments.filter(s => s.length), uncertain: !!quote || escaped || /\$\(|`|<<|\b(?:if|for|while|case|function)\b/.test(command) };
|
|
20
|
+
}
|
|
21
|
+
function operation(words, depth = 0) {
|
|
22
|
+
let i = 0;
|
|
23
|
+
while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i] ?? '')) i++;
|
|
24
|
+
if (words[i] === 'env') { i++; while (words[i]?.startsWith('-') || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i] ?? '')) i++; }
|
|
25
|
+
if (words[i] === 'sudo') { i++; if (words[i] === '-u') i += 2; }
|
|
26
|
+
if (words[i] === 'timeout') { i++; while (words[i]?.startsWith('-')) i++; i++; }
|
|
27
|
+
const exe = safe(words[i]); if (!exe) return 'shell (unclassified)';
|
|
28
|
+
const program = basename(exe); const args = words.slice(i + 1);
|
|
29
|
+
if (['sh', 'bash', 'zsh', 'pwsh', 'powershell'].includes(program) && depth < 2) {
|
|
30
|
+
const at = args.findIndex(a => /^-(?:[a-z]*c|Command)$/i.test(a));
|
|
31
|
+
if (at >= 0 && args[at + 1]) return shellOperations(args[at + 1], depth + 1);
|
|
32
|
+
}
|
|
33
|
+
if (program === 'git') {
|
|
34
|
+
let j = 0;
|
|
35
|
+
while (args[j]?.startsWith('-')) { const a = args[j++]; if (takesValue.has(a)) j++; }
|
|
36
|
+
const sub = safe(args[j]); return sub && /^[a-z][a-z-]*$/.test(sub) ? `git ${sub}` : 'git';
|
|
37
|
+
}
|
|
38
|
+
if (['npm', 'pnpm', 'yarn', 'bun', 'dotnet', 'cargo', 'docker', 'kubectl', 'gh', 'uv'].includes(program)) {
|
|
39
|
+
const sub = args.find(a => !a.startsWith('-'));
|
|
40
|
+
if (!safe(sub) || !/^[a-z][a-z-]*$/.test(sub)) return program;
|
|
41
|
+
const at = args.indexOf(sub);
|
|
42
|
+
if (['run', 'run-script'].includes(sub) && safe(args[at + 1])) return `${program} run ${args[at + 1]}`;
|
|
43
|
+
if (program === 'docker' && ['compose', 'container', 'image'].includes(sub) && /^[a-z][a-z-]*$/.test(args[at + 1] ?? '')) return `${program} ${sub} ${args[at + 1]}`;
|
|
44
|
+
return `${program} ${sub}`;
|
|
45
|
+
}
|
|
46
|
+
return safe(program) ?? 'shell (unclassified)';
|
|
47
|
+
}
|
|
48
|
+
export function shellOperations(command, depth = 0) {
|
|
49
|
+
const { segments, uncertain } = shellWords(command);
|
|
50
|
+
if (uncertain || !segments.length || segments.length > 32) return ['shell (unclassified)'];
|
|
51
|
+
return [...new Set(segments.flatMap(words => operation(words, depth)))].slice(0, 16);
|
|
52
|
+
}
|
|
53
|
+
export function auditOperations(name, input) {
|
|
54
|
+
const tool = safe(name) ?? 'tool (unclassified)';
|
|
55
|
+
if (['exec', 'functions.exec'].includes(name) && typeof input?.script === 'string') return { tool, operations: scriptOperations(input.script), shell: true, wrapped: true };
|
|
56
|
+
if (/^(?:Bash|Shell|exec_command|run|shell_command|functions\.exec_command|mcp__.*__exec_command)$/i.test(name ?? '')) {
|
|
57
|
+
const command = input?.command ?? input?.cmd;
|
|
58
|
+
if (typeof command === 'string' && command.length <= 32768) return { tool, operations: shellOperations(command), shell: true };
|
|
59
|
+
return { tool, operations: ['shell (unclassified)'], shell: true };
|
|
60
|
+
}
|
|
61
|
+
return { tool, operations: [tool], shell: false };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Tokenize the wrapper without running it. Strings/comments cannot masquerade
|
|
65
|
+
// as tools calls; only literal cmd values are classified as shell operations.
|
|
66
|
+
export function scriptOperations(script) {
|
|
67
|
+
if (typeof script !== 'string' || script.length > 32768) return ['exec (unclassified)'];
|
|
68
|
+
const tokens = []; let i = 0;
|
|
69
|
+
while (i < script.length) {
|
|
70
|
+
const c = script[i];
|
|
71
|
+
if (/\s/.test(c)) { i++; continue; }
|
|
72
|
+
if (script.slice(i, i + 2) === '//') { i = script.indexOf('\n', i); if (i < 0) break; continue; }
|
|
73
|
+
if (script.slice(i, i + 2) === '/*') { const end = script.indexOf('*/', i + 2); if (end < 0) break; i = end + 2; continue; }
|
|
74
|
+
if (['"', "'", '`'].includes(c)) {
|
|
75
|
+
const quote = c; i++; let value = '', valid = true, closed = false;
|
|
76
|
+
while (i < script.length) {
|
|
77
|
+
const ch = script[i++];
|
|
78
|
+
if (ch === quote) { closed = true; break; }
|
|
79
|
+
if (quote === '`' && ch === '$' && script[i] === '{') valid = false;
|
|
80
|
+
if (ch !== '\\') { value += ch; continue; }
|
|
81
|
+
const escaped = script[i++];
|
|
82
|
+
if (escaped === undefined) { valid = false; break; }
|
|
83
|
+
const simple = { n: '\n', r: '\r', t: '\t', '\\': '\\', '"': '"', "'": "'", '`': '`' };
|
|
84
|
+
if (Object.hasOwn(simple, escaped)) value += simple[escaped];
|
|
85
|
+
else if (escaped === '\n') continue;
|
|
86
|
+
else valid = false;
|
|
87
|
+
}
|
|
88
|
+
tokens.push({ kind: 'string', value: valid && closed ? value : null }); continue;
|
|
89
|
+
}
|
|
90
|
+
const id = /^[A-Za-z_$][\w$]*/.exec(script.slice(i));
|
|
91
|
+
if (id) { tokens.push({ kind: 'id', value: id[0] }); i += id[0].length; }
|
|
92
|
+
else { tokens.push({ kind: 'punct', value: c }); i++; }
|
|
93
|
+
}
|
|
94
|
+
const operations = [];
|
|
95
|
+
for (let j = 0; j < tokens.length - 4; j++) {
|
|
96
|
+
if (tokens[j].kind !== 'id' || tokens[j].value !== 'tools' || tokens[j + 1].value !== '.' || tokens[j + 2].kind !== 'id' || tokens[j + 3].value !== '(') continue;
|
|
97
|
+
const method = tokens[j + 2].value;
|
|
98
|
+
if (method !== 'exec_command') { operations.push(safe(method) ?? 'tool (unclassified)'); continue; }
|
|
99
|
+
let cmd = null, depth = 0;
|
|
100
|
+
if (tokens[j + 4].value === '{') {
|
|
101
|
+
for (let k = j + 4; k < tokens.length; k++) {
|
|
102
|
+
const t = tokens[k];
|
|
103
|
+
if (t.kind === 'punct' && t.value === '{') depth++;
|
|
104
|
+
if (t.kind === 'punct' && t.value === '}' && --depth <= 0) break;
|
|
105
|
+
if (depth === 1 && t.value === 'cmd' && ['id', 'string'].includes(t.kind) && tokens[k + 1]?.value === ':' && tokens[k + 2]?.kind === 'string'
|
|
106
|
+
&& [',', '}'].includes(tokens[k + 3]?.value)) { cmd = tokens[k + 2].value; break; }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
operations.push(...(cmd === null ? ['shell (unclassified)'] : shellOperations(cmd)));
|
|
110
|
+
}
|
|
111
|
+
return operations.length ? [...new Set(operations)].slice(0, 16) : ['exec (unclassified)'];
|
|
112
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Runs in a worker: complete local journals, not the canvas's bounded call list.
|
|
2
|
+
export async function buildToolAuditReport({ files, query, now, meta }, { createReadStream, createInterface }) {
|
|
3
|
+
const DAY = 86400000;
|
|
4
|
+
const finite = n => typeof n === 'number' && Number.isFinite(n);
|
|
5
|
+
const valid = r => /^[a-f0-9]{64}$/.test(r?.id ?? '') && ['claude', 'codex'].includes(r.provider)
|
|
6
|
+
&& ['start', 'end'].includes(r.kind) && finite(r.t) && (r.at === null || finite(r.at))
|
|
7
|
+
&& ['hook', 'rollout'].includes(r.clock)
|
|
8
|
+
&& (r.kind === 'end' ? typeof r.failed === 'boolean' : typeof r.tool === 'string' && r.tool.length <= 100
|
|
9
|
+
&& Array.isArray(r.operations) && r.operations.length > 0 && r.operations.length <= 16
|
|
10
|
+
&& r.operations.every(s => typeof s === 'string' && s.length <= 150));
|
|
11
|
+
const percentile = (sorted, p) => sorted.length ? sorted[Math.max(0, Math.ceil(sorted.length * p) - 1)] : null;
|
|
12
|
+
const calls = new Map(); let corrupt = 0, duplicates = 0;
|
|
13
|
+
// Explicit failure rather than a convincing-looking partial report.
|
|
14
|
+
for (const file of files) {
|
|
15
|
+
if (file.size > 64 * 1024 * 1024) throw new Error('Daily tool history exceeds report capacity');
|
|
16
|
+
if (!file.size) continue;
|
|
17
|
+
const stream = createReadStream(file.path, { start: 0, end: file.size - 1, encoding: 'utf8' });
|
|
18
|
+
const lines = createInterface({ input: stream, crlfDelay: Infinity });
|
|
19
|
+
try {
|
|
20
|
+
for await (const line of lines) {
|
|
21
|
+
if (!line) continue;
|
|
22
|
+
let r; try { r = JSON.parse(line); } catch { corrupt++; continue; }
|
|
23
|
+
if (!valid(r)) { corrupt++; continue; }
|
|
24
|
+
let call = calls.get(r.id);
|
|
25
|
+
if (!call) { if (calls.size >= 500000) throw new Error('Tool history exceeds report capacity; choose shorter retention in Settings'); call = {}; calls.set(r.id, call); }
|
|
26
|
+
if (call[r.kind]) { duplicates++; continue; }
|
|
27
|
+
call[r.kind] = r;
|
|
28
|
+
}
|
|
29
|
+
} finally { lines.close(); stream.destroy(); }
|
|
30
|
+
}
|
|
31
|
+
const since = query.since ?? now - (query.days ?? 7) * DAY;
|
|
32
|
+
const until = query.until ?? now;
|
|
33
|
+
const retainedSince = Math.max(meta.startedAt, Math.floor(now / DAY) * DAY - (meta.retentionDays - 1) * DAY);
|
|
34
|
+
const daily = new Map();
|
|
35
|
+
const dayFormatter = query.history ? new Intl.DateTimeFormat('en-CA', { timeZone: query.timeZone ?? 'UTC', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23' }) : null;
|
|
36
|
+
const dayKey = time => {
|
|
37
|
+
const parts = Object.fromEntries(dayFormatter.formatToParts(new Date(time)).map(part => [part.type, part.value]));
|
|
38
|
+
return `${parts.year}-${parts.month}-${parts.day}`;
|
|
39
|
+
};
|
|
40
|
+
// Calendar labels advance independently of offsets: DST days may be 23 or 25 hours.
|
|
41
|
+
if (query.history && query.operation) {
|
|
42
|
+
const first = Date.parse(dayKey(since)), last = Date.parse(dayKey(until));
|
|
43
|
+
if (!Number.isFinite(first) || !Number.isFinite(last) || last - first > 91 * DAY) throw new Error('Daily history exceeds report capacity');
|
|
44
|
+
const retainedDay = dayKey(retainedSince);
|
|
45
|
+
const atBoundary = (time, end = false) => {
|
|
46
|
+
const parts = Object.fromEntries(dayFormatter.formatToParts(new Date(time)).map(part => [part.type, part.value]));
|
|
47
|
+
return parts.hour === (end ? '23' : '00') && parts.minute === (end ? '59' : '00') && parts.second === (end ? '59' : '00') && ((time % 1000) + 1000) % 1000 === (end ? 999 : 0);
|
|
48
|
+
};
|
|
49
|
+
const firstDay = dayKey(since), lastDay = dayKey(until);
|
|
50
|
+
const firstPartial = !atBoundary(since), lastPartial = !atBoundary(until, true), retainedPartial = !atBoundary(retainedSince);
|
|
51
|
+
for (let date = first; date <= last; date += DAY) {
|
|
52
|
+
const key = new Date(date).toISOString().slice(0, 10);
|
|
53
|
+
daily.set(key, { date: key, calls: 0, measured: 0, shared: 0, providers: { claude: 0, codex: 0 }, durations: [],
|
|
54
|
+
status: key < retainedDay ? 'outside-history' : 'no-calls', partial: key === retainedDay && retainedPartial || key === firstDay && firstPartial || key === lastDay && lastPartial });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const groups = new Map(); let total = 0, measured = 0, failed = 0, pending = 0, orphaned = 0;
|
|
58
|
+
const details = [];
|
|
59
|
+
let totalMs = null, observedSince = null, observedUntil = null;
|
|
60
|
+
const providers = { claude: { calls: 0, measured: 0, failed: 0 }, codex: { calls: 0, measured: 0, failed: 0 } };
|
|
61
|
+
for (const [id, call] of calls) {
|
|
62
|
+
const start = call.start, end = call.end;
|
|
63
|
+
if (!start) { orphaned++; continue; }
|
|
64
|
+
if (start.t < since || start.t > until || query.provider !== 'all' && start.provider !== query.provider) continue;
|
|
65
|
+
total++; providers[start.provider].calls++;
|
|
66
|
+
observedSince = observedSince === null ? start.t : Math.min(observedSince, start.t);
|
|
67
|
+
observedUntil = observedUntil === null ? start.t : Math.max(observedUntil, start.t);
|
|
68
|
+
const duration = end && start.clock === end.clock && finite(start.at) && finite(end.at)
|
|
69
|
+
&& end.at >= start.at && end.at - start.at <= 7 * DAY ? end.at - start.at : null;
|
|
70
|
+
if (duration !== null) { measured++; providers[start.provider].measured++; totalMs = (totalMs ?? 0) + duration; }
|
|
71
|
+
if (end?.failed) { failed++; providers[start.provider].failed++; }
|
|
72
|
+
if (!end) pending++;
|
|
73
|
+
const shared = start.operations.length > 1;
|
|
74
|
+
for (const operation of start.operations) {
|
|
75
|
+
let group = groups.get(operation);
|
|
76
|
+
if (!group) { group = { operation, calls: 0, completed: 0, failed: 0, missingTiming: 0, pending: 0, shared: 0, durations: [], providers: { claude: 0, codex: 0 }, tools: new Set() }; groups.set(operation, group); }
|
|
77
|
+
group.calls++; group.providers[start.provider]++; group.tools.add(start.tool);
|
|
78
|
+
if (end) group.completed++; else group.pending++;
|
|
79
|
+
if (end?.failed) group.failed++;
|
|
80
|
+
if (shared) group.shared++;
|
|
81
|
+
if (duration !== null) group.durations.push(duration); else group.missingTiming++;
|
|
82
|
+
if (operation === query.operation && daily.size) {
|
|
83
|
+
const bucket = daily.get(dayKey(start.t));
|
|
84
|
+
if (bucket) { bucket.calls++; bucket.providers[start.provider]++; if (shared) bucket.shared++; if (duration !== null) { bucket.measured++; bucket.durations.push(duration); } }
|
|
85
|
+
}
|
|
86
|
+
if (operation === query.operation) details.push({ id, operation, provider: start.provider, tool: start.tool,
|
|
87
|
+
capturedAt: start.t, durationMs: duration, state: !end ? 'unmatched' : end.failed ? 'failed' : 'success', clock: start.clock, shared });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const rows = [...groups.values()].map(g => {
|
|
91
|
+
const durations = g.durations.sort((a, b) => a - b);
|
|
92
|
+
const n = durations.length;
|
|
93
|
+
return { operation: g.operation, calls: g.calls, completed: g.completed, failed: g.failed,
|
|
94
|
+
errorRate: g.completed ? g.failed / g.completed : null, measured: n, missingTiming: g.missingTiming,
|
|
95
|
+
unmatched: g.pending, shared: g.shared, providers: g.providers, tools: [...g.tools],
|
|
96
|
+
totalMs: n ? durations.reduce((a, b) => a + b, 0) : null,
|
|
97
|
+
averageMs: n ? durations.reduce((a, b) => a + b, 0) / n : null,
|
|
98
|
+
medianMs: n ? n % 2 ? durations[Math.floor(n / 2)] : (durations[n / 2 - 1] + durations[n / 2]) / 2 : null,
|
|
99
|
+
p95Ms: n >= 20 ? percentile(durations, .95) : null, maxMs: n ? durations.at(-1) : null };
|
|
100
|
+
}).sort((a, b) => b.calls - a.calls || a.operation.localeCompare(b.operation));
|
|
101
|
+
return { ok: true, capturedAt: now, since, until, provider: query.provider, total, measured, failed, unmatched: pending,
|
|
102
|
+
corrupt, duplicates, orphaned, rows, totalMs, completed: total - pending, providers, observedSince, observedUntil,
|
|
103
|
+
retainedSince,
|
|
104
|
+
details: query.operation ? { operation: query.operation, total: details.length,
|
|
105
|
+
...(query.history ? { capturedAt: now, measurements: rows.find(row => row.operation === query.operation) ?? null, history: { timeZone: query.timeZone ?? 'UTC', days: [...daily.values()].map(bucket => {
|
|
106
|
+
const sorted = bucket.durations.sort((a, b) => a - b), n = sorted.length;
|
|
107
|
+
return { date: bucket.date, calls: bucket.calls, measured: n, shared: bucket.shared, providers: bucket.providers, partial: bucket.partial,
|
|
108
|
+
status: n ? 'measured' : bucket.calls ? 'missing-timing' : bucket.status,
|
|
109
|
+
averageMs: n ? sorted.reduce((sum, duration) => sum + duration, 0) / n : null,
|
|
110
|
+
medianMs: n ? n % 2 ? sorted[Math.floor(n / 2)] : (sorted[n / 2 - 1] + sorted[n / 2]) / 2 : null,
|
|
111
|
+
maxMs: n ? sorted.at(-1) : null, p95Ms: n >= 20 ? percentile(sorted, .95) : null };
|
|
112
|
+
}) } } : {}),
|
|
113
|
+
slowest: details.filter(d => d.durationMs !== null).sort((a, b) => b.durationMs - a.durationMs).slice(0, 20),
|
|
114
|
+
recent: details.sort((a, b) => b.capturedAt - a.capturedAt).slice(0, 30) } : null };
|
|
115
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { toolAudit } from './tool-audit.mjs';
|
|
2
|
+
import { readBody, send } from './http-io.mjs';
|
|
3
|
+
const calendarDate = value => /^\d{4}-\d{2}-\d{2}$/.test(value ?? '') && Number.isFinite(Date.parse(value)) && new Date(value).toISOString().slice(0, 10) === value;
|
|
4
|
+
export function auditQuery(params) {
|
|
5
|
+
const provider = params.get('provider') ?? 'all';
|
|
6
|
+
const days = Number(params.get('days') ?? 7);
|
|
7
|
+
const operation = params.get('operation') ?? undefined;
|
|
8
|
+
const since = params.get('since'), until = params.get('until');
|
|
9
|
+
const history = params.get('history');
|
|
10
|
+
let timeZone = params.get('timeZone') ?? 'UTC';
|
|
11
|
+
if (history !== null && history !== '1' || history && !operation || timeZone.length > 80) return null;
|
|
12
|
+
try { timeZone = new Intl.DateTimeFormat('en', { timeZone }).resolvedOptions().timeZone; } catch { return null; }
|
|
13
|
+
if (!['all', 'claude', 'codex'].includes(provider) || ![1, 7, 30, 60, 90].includes(days)
|
|
14
|
+
|| operation && operation.length > 150 || !!since !== !!until) return null;
|
|
15
|
+
if (since && (!calendarDate(since) || !calendarDate(until) || since > until || Date.parse(until) - Date.parse(since) >= 90 * 86400000)) return null;
|
|
16
|
+
return { provider, days, operation, ...(history ? { history: true, timeZone } : {}), ...(since ? { since: Date.parse(since), until: Date.parse(until) + 86400000 - 1 } : {}) };
|
|
17
|
+
}
|
|
18
|
+
export async function handleToolAudit(req, res) {
|
|
19
|
+
const audit = toolAudit();
|
|
20
|
+
if (!audit) return send(res, 503, { ok: false, reason: 'unavailable' });
|
|
21
|
+
if (req.method === 'GET') {
|
|
22
|
+
if (new URL(req.url, 'http://localhost').pathname.endsWith('/storage')) return send(res, 200, await audit.status());
|
|
23
|
+
const query = auditQuery(new URL(req.url, 'http://localhost').searchParams);
|
|
24
|
+
if (!query) return send(res, 400, { ok: false, reason: 'bad_range' });
|
|
25
|
+
const controller = new AbortController();
|
|
26
|
+
const cancel = () => { if (!res.writableEnded) controller.abort(); };
|
|
27
|
+
res.once('close', cancel);
|
|
28
|
+
let report;
|
|
29
|
+
try { report = await audit.report(query, controller.signal); }
|
|
30
|
+
catch (e) { if (controller.signal.aborted) return; throw e; }
|
|
31
|
+
finally { res.off('close', cancel); }
|
|
32
|
+
return send(res, report.ok ? 200 : 409, report);
|
|
33
|
+
}
|
|
34
|
+
let body; try { body = JSON.parse(await readBody(req, res, 2048)); } catch { return send(res, 400, { ok: false, reason: 'bad_body' }); }
|
|
35
|
+
if (body?.action === 'clear' && body.confirm === true) return send(res, 200, await audit.clear());
|
|
36
|
+
if (body?.action === 'retention' && [30, 60, 90].includes(body.days)) return send(res, 200, await audit.configure(body.days));
|
|
37
|
+
return send(res, 400, { ok: false, reason: 'bad_action' });
|
|
38
|
+
}
|