ccdeck 3.29.11 → 3.31.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 +4 -3
- package/bin/agent-dag.js +67 -118
- package/bin/cli/help.js +67 -0
- package/bin/cli/login-item.js +182 -0
- package/bin/cli/one-shot.js +254 -0
- package/bin/cli/package.js +39 -0
- package/bin/cli/pulse.js +98 -0
- package/bin/cli/restart.js +179 -0
- package/bin/cli/screen.js +166 -0
- package/bin/cli/second-start.js +120 -0
- package/bin/cli/startup.js +459 -0
- package/bin/cli/uninstall.js +169 -0
- package/bin/deck.js +112 -1559
- package/dist/web/assets/BrowserWatchModal-n63GjLhv.js +1 -0
- package/dist/web/assets/UsageHistoryModal-DEwBX4Fk.js +1 -0
- package/dist/web/assets/index-CZJf1Cpp.js +299 -0
- package/dist/web/assets/index-D4q1ElXU.css +1 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +15 -14
- package/package.json +1 -1
- package/release-notes.json +40 -0
- package/src/server/account-projects.mjs +25 -11
- package/src/server/account-routes.mjs +254 -0
- package/src/server/activity.mjs +3 -3
- package/src/server/append-budget.mjs +185 -0
- package/src/server/append-failures.mjs +147 -0
- package/src/server/args.mjs +37 -5
- package/src/server/atomic-write.mjs +300 -0
- package/src/server/block-notify.mjs +0 -5
- package/src/server/boot-lock.mjs +1 -1
- package/src/server/brand.mjs +7 -6
- package/src/server/browser-react.mjs +10 -2
- package/src/server/browser-watch-decks.mjs +146 -0
- package/src/server/browser-watch-feed.mjs +69 -0
- package/src/server/browser-watch-log.mjs +173 -0
- package/src/server/browser-watch-mtime.mjs +18 -0
- package/src/server/browser-watch-relay.mjs +112 -0
- package/src/server/browser-watch-routes.mjs +172 -0
- package/src/server/browser-watch-seen.mjs +127 -0
- package/src/server/browser-watch-store.mjs +14 -143
- package/src/server/browser-watch.mjs +278 -626
- package/src/server/canonical-path.mjs +150 -0
- package/src/server/ccusage-failure.mjs +64 -0
- package/src/server/ccusage-install.mjs +459 -0
- package/src/server/ccusage-runner.mjs +271 -0
- package/src/server/ccusage.mjs +21 -756
- package/src/server/claude-accounts.mjs +220 -464
- package/src/server/claude-collector.mjs +189 -0
- package/src/server/claude-dir.mjs +7 -3
- package/src/server/claude-identity.mjs +5 -4
- package/src/server/claude-reset-credits.mjs +130 -0
- package/src/server/claude-verdicts.mjs +174 -0
- package/src/server/clear-route.mjs +191 -0
- package/src/server/codex-auth.mjs +4 -4
- package/src/server/codex-dir.mjs +52 -8
- package/src/server/codex-enrichment.mjs +310 -0
- package/src/server/codex-quota.mjs +5 -3
- package/src/server/codex-translate.mjs +407 -0
- package/src/server/codex-usage.mjs +8 -5
- package/src/server/codex-watch.mjs +496 -0
- package/src/server/cswap-auto-loop.mjs +342 -0
- package/src/server/cswap-auto-readers.mjs +303 -0
- package/src/server/cswap-auto.mjs +51 -609
- package/src/server/cswap-install.mjs +69 -265
- package/src/server/cswap-layout.mjs +242 -0
- package/src/server/deck-home.mjs +2 -2
- package/src/server/deck-prefs.mjs +58 -184
- package/src/server/deck-scope.mjs +52 -0
- package/src/server/desktop-update-routes.mjs +75 -0
- package/src/server/detach.mjs +78 -0
- package/src/server/discovery.mjs +298 -0
- package/src/server/event-log.mjs +328 -0
- package/src/server/event-pipeline.mjs +235 -0
- package/src/server/event-ring.mjs +214 -0
- package/src/server/event-routes.mjs +352 -0
- package/src/server/event-sink.mjs +26 -0
- package/src/server/exec-children.mjs +128 -0
- package/src/server/exec-not-found.mjs +213 -0
- package/src/server/exec-output.mjs +23 -0
- package/src/server/exec-spec.mjs +341 -0
- package/src/server/exec.mjs +74 -661
- package/src/server/health-route.mjs +42 -0
- package/src/server/hook-challenge.mjs +37 -0
- package/src/server/http-io.mjs +104 -0
- package/src/server/index.mjs +174 -7620
- package/src/server/install-layout.mjs +506 -0
- package/src/server/installer.mjs +79 -608
- package/src/server/jsonl-chunks.mjs +139 -0
- package/src/server/lan-accounts.mjs +27 -0
- package/src/server/lan-beacon.mjs +457 -0
- package/src/server/lan-call.mjs +239 -0
- package/src/server/lan-copies.mjs +270 -0
- package/src/server/lan-deck.mjs +583 -0
- package/src/server/lan-dials.mjs +161 -0
- package/src/server/lan-engine.mjs +475 -1024
- package/src/server/lan-hearing.mjs +49 -0
- package/src/server/lan-inbound.mjs +100 -0
- package/src/server/lan-invite-offer.mjs +77 -0
- package/src/server/lan-invite.mjs +207 -0
- package/src/server/lan-lines.mjs +73 -0
- package/src/server/lan-manifest.mjs +74 -0
- package/src/server/lan-reach.mjs +82 -69
- package/src/server/lan-requests.mjs +249 -0
- package/src/server/lan-round-record.mjs +45 -0
- package/src/server/lan-round-timer.mjs +62 -0
- package/src/server/lan-routes.mjs +143 -0
- package/src/server/lan-serve.mjs +112 -0
- package/src/server/lan-socket.mjs +243 -925
- package/src/server/lan-sync.mjs +58 -1026
- package/src/server/lan-tailnet-poll.mjs +57 -0
- package/src/server/lan-turns.mjs +80 -0
- package/src/server/lan-wire.mjs +620 -0
- package/src/server/lifecycle.mjs +421 -0
- package/src/server/listen.mjs +110 -0
- package/src/server/live-decks.mjs +138 -0
- package/src/server/load-average.mjs +32 -0
- package/src/server/log-election.mjs +235 -0
- package/src/server/log-replay.mjs +374 -0
- package/src/server/log-writer.mjs +72 -557
- package/src/server/login-service.mjs +3 -2
- package/src/server/macmon.mjs +1 -1
- package/src/server/memory-files.mjs +163 -0
- package/src/server/memory-metrics.mjs +178 -0
- package/src/server/metrics-history.mjs +94 -0
- package/src/server/metrics-run.mjs +100 -0
- package/src/server/music-routes.mjs +134 -0
- package/src/server/network-metrics.mjs +5 -3
- package/src/server/network-sampler.mjs +256 -0
- package/src/server/npm-latest.mjs +385 -0
- package/src/server/npm-upgrade.mjs +352 -0
- package/src/server/npx.mjs +41 -7
- package/src/server/output-watch.mjs +5 -4
- package/src/server/pinned-build.mjs +110 -0
- package/src/server/prefs-give-back.mjs +206 -0
- package/src/server/prefs-path.mjs +14 -0
- package/src/server/prefs-refusal.mjs +86 -0
- package/src/server/prefs-routes.mjs +99 -0
- package/src/server/prefs-set-aside.mjs +86 -0
- package/src/server/prefs-state.mjs +70 -0
- package/src/server/process-command.mjs +104 -0
- package/src/server/process-list.mjs +547 -0
- package/src/server/provider-status.mjs +307 -0
- package/src/server/pulse-line.mjs +132 -0
- package/src/server/purge-key.mjs +7 -7
- package/src/server/quota-cli.mjs +174 -0
- package/src/server/quota-oauth.mjs +190 -0
- package/src/server/quota-shape.mjs +229 -0
- package/src/server/quota-store-resets.mjs +206 -0
- package/src/server/quota.mjs +123 -509
- package/src/server/relay-guard.mjs +3 -3
- package/src/server/request-gates.mjs +441 -0
- package/src/server/restart-note.mjs +180 -0
- package/src/server/retire-sound-hook.mjs +3 -3
- package/src/server/ring-bounds.mjs +215 -0
- package/src/server/running-deck.mjs +1 -1
- package/src/server/running-version.mjs +24 -0
- package/src/server/self-update.mjs +39 -1371
- package/src/server/session-enrichment.mjs +622 -0
- package/src/server/session-read-gate.mjs +49 -0
- package/src/server/session-tracking.mjs +242 -0
- package/src/server/sse-clients.mjs +222 -0
- package/src/server/stale-discovery.mjs +30 -0
- package/src/server/static-serve.mjs +81 -0
- package/src/server/supervisor.mjs +73 -12
- package/src/server/system-metrics.mjs +169 -1935
- package/src/server/term.mjs +11 -249
- package/src/server/thermal-metrics.mjs +520 -0
- package/src/server/thermal-sampler.mjs +204 -0
- package/src/server/token-redact.mjs +123 -0
- package/src/server/transcript-gate.mjs +107 -0
- package/src/server/transcript-scan.mjs +563 -0
- package/src/server/usage-routes.mjs +108 -0
- package/src/server/uv-bootstrap.mjs +1 -1
- package/src/server/wordmark.mjs +127 -0
- package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
- package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
- package/dist/web/assets/index-Ck2RAla3.css +0 -1
- package/dist/web/assets/index-jmrcEsCj.js +0 -298
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// The Machine panel's thermal section: the sampler that asks this machine
|
|
2
|
+
// whether it is getting hot, every ten seconds, and stops asking one that has
|
|
3
|
+
// never answered.
|
|
4
|
+
//
|
|
5
|
+
// Moved out of system-metrics.mjs unchanged. What is asked, and how each
|
|
6
|
+
// platform's answer is read, is thermal-metrics.mjs, which keeps no state; the
|
|
7
|
+
// minute ring the readings are recorded into is metrics-history.mjs.
|
|
8
|
+
// system-metrics.mjs starts and stops this section (startThermalTimer,
|
|
9
|
+
// stopThermal), draws the thermal chart with the bands of lastThermal, puts
|
|
10
|
+
// thermalSnapshot on /api/system, and re-exports sampleThermal and
|
|
11
|
+
// THROTTLE_LABEL, which is where the suite reaches them.
|
|
12
|
+
import { pointsOf, record } from "./metrics-history.mjs";
|
|
13
|
+
import { readThermal } from "./thermal-metrics.mjs";
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Thermal: how often this machine is asked whether it is getting hot, and when
|
|
17
|
+
// one that has never answered stops being asked. What is asked, and why those
|
|
18
|
+
// sources on each platform, is thermal-metrics.mjs.
|
|
19
|
+
|
|
20
|
+
/** How often the thermal reading is refreshed. Heat moves on the scale of
|
|
21
|
+
* seconds, and the two platforms that cost a subprocess to ask cost 51ms
|
|
22
|
+
* (`ioreg`) and rather less (`pmset`), measured. Linux costs a file read. */
|
|
23
|
+
const THERMAL_INTERVAL_MS = 10_000;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* How many consecutive empty readings before this machine is left alone.
|
|
27
|
+
*
|
|
28
|
+
* The reason is Windows, where MSAcpi_ThermalZoneTemperature is absent on a
|
|
29
|
+
* large share of desktops: without this, every one of those machines would pay
|
|
30
|
+
* a `Get-CimInstance` child every ten seconds, forever, to render a section it
|
|
31
|
+
* can never render. Three rather than one because a single failure can be a
|
|
32
|
+
* hiccup — a timeout, a machine mid-wake — and giving up on a hiccup would lose
|
|
33
|
+
* a reading the machine does have.
|
|
34
|
+
*
|
|
35
|
+
* Not persisted. A restart asks again, which is what should happen after the
|
|
36
|
+
* user installs a driver or changes a firmware setting.
|
|
37
|
+
*/
|
|
38
|
+
const THERMAL_GIVE_UP = 3;
|
|
39
|
+
|
|
40
|
+
let thermal = null;
|
|
41
|
+
let thermalTimer = null;
|
|
42
|
+
let thermalInFlight = false;
|
|
43
|
+
/** Consecutive readings that came back with nothing. See THERMAL_GIVE_UP. */
|
|
44
|
+
let thermalMisses = 0;
|
|
45
|
+
/** Whether this machine has EVER answered. See sampleThermal. */
|
|
46
|
+
let thermalEverAnswered = false;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The thermal reading, whose series are not known until the machine answers.
|
|
50
|
+
*
|
|
51
|
+
* Keyed by the row's own label rather than by position, because the rows are
|
|
52
|
+
* not the same on every platform and a machine can start reporting a sensor it
|
|
53
|
+
* was not reporting before — a GPU driver loads, a laptop is docked. A series
|
|
54
|
+
* that appears late simply has no points before it appeared, which is the truth
|
|
55
|
+
* and draws correctly.
|
|
56
|
+
*/
|
|
57
|
+
function recordThermal(reading, nowMs = Date.now()) {
|
|
58
|
+
if (!reading) return;
|
|
59
|
+
for (const r of reading.celsius ?? []) record(`thermal:${r.label}`, r.celsius, nowMs);
|
|
60
|
+
// Stored as the share TAKEN AWAY, the same way the panel draws it, so the
|
|
61
|
+
// chart and the row cannot disagree about which direction is bad.
|
|
62
|
+
if (reading.throttle) {
|
|
63
|
+
record(`thermal:${THROTTLE_LABEL}`, Math.max(0, 100 - reading.throttle.speedLimit), nowMs);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The one thermal row that is not degrees. Named once so the recorder, the
|
|
68
|
+
* route and the panel cannot drift apart on the spelling. */
|
|
69
|
+
export const THROTTLE_LABEL = "Throttling";
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Whether this machine has been held back AT ALL since the deck started, and
|
|
73
|
+
* when it last was.
|
|
74
|
+
*
|
|
75
|
+
* The row reports the current sample, and on a desktop that current sample is
|
|
76
|
+
* `0%` essentially always — measured here: ninety seconds of AES-NI on twelve
|
|
77
|
+
* cores never moved `CPU_Speed_Limit` off 100. Which is the truth, and which
|
|
78
|
+
* reads as "this readout does not work" the second time somebody looks at it.
|
|
79
|
+
* It was reported that way twice.
|
|
80
|
+
*
|
|
81
|
+
* So the note under the row gets to say the other thing. Nothing new is
|
|
82
|
+
* sampled for it: the minute buckets already hold the peak of every minute, and
|
|
83
|
+
* this is a scan of what is already there. A machine that has never been
|
|
84
|
+
* throttled says so; one that was at lunchtime says when.
|
|
85
|
+
*/
|
|
86
|
+
function heldBackSoFar() {
|
|
87
|
+
const key = `thermal:${THROTTLE_LABEL}`;
|
|
88
|
+
let peak = 0;
|
|
89
|
+
let lastMs = 0;
|
|
90
|
+
for (const { t, v } of pointsOf(key)) {
|
|
91
|
+
if (v <= 0) continue;
|
|
92
|
+
if (v > peak) peak = v;
|
|
93
|
+
lastMs = t;
|
|
94
|
+
}
|
|
95
|
+
return peak > 0 ? { peak, lastMs } : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* One reading at a time, and a machine that cannot answer is asked three times
|
|
100
|
+
* rather than for the life of the process.
|
|
101
|
+
*
|
|
102
|
+
* `deps.read` is a seam rather than a convenience: the rule this function
|
|
103
|
+
* exists for only fires on a machine that answers with nothing, and the machine
|
|
104
|
+
* this was written on answers with something, so there is no other way to run
|
|
105
|
+
* the branch that matters.
|
|
106
|
+
*/
|
|
107
|
+
export async function sampleThermal(deps = {}) {
|
|
108
|
+
if (thermalInFlight) return;
|
|
109
|
+
// Giving up is only ever for a machine that has NEVER answered — the Windows
|
|
110
|
+
// desktop with no MSAcpi class, which would otherwise pay a PowerShell child
|
|
111
|
+
// every ten seconds for the life of the process. A machine that answered once
|
|
112
|
+
// has a sensor, and it keeps being asked however long the silence runs.
|
|
113
|
+
if (!thermalEverAnswered && thermalMisses >= THERMAL_GIVE_UP) return;
|
|
114
|
+
const read = deps.read ?? readThermal;
|
|
115
|
+
thermalInFlight = true;
|
|
116
|
+
try {
|
|
117
|
+
const next = await read();
|
|
118
|
+
if (next) { thermal = next; thermalMisses = 0; thermalEverAnswered = true; recordThermal(next); }
|
|
119
|
+
else if (++thermalMisses >= THERMAL_GIVE_UP) {
|
|
120
|
+
// DROP THE LAST READING. It used to be kept, and that is a number from
|
|
121
|
+
// four minutes ago printed as though it were now — the one thing this
|
|
122
|
+
// whole section refuses. A GPU driver unloads, a laptop is docked, a
|
|
123
|
+
// sensor goes away: the honest answer is that the section stops being
|
|
124
|
+
// drawn, not that it freezes.
|
|
125
|
+
thermal = null;
|
|
126
|
+
// But keep ASKING on a machine that has answered before. The cost
|
|
127
|
+
// argument for giving up was only ever about a machine that can never
|
|
128
|
+
// answer — a Windows desktop with no MSAcpi class paying a PowerShell
|
|
129
|
+
// child every ten seconds forever. One that answered has a sensor, and a
|
|
130
|
+
// silence is a gap rather than an absence.
|
|
131
|
+
if (!thermalEverAnswered && thermalTimer) {
|
|
132
|
+
clearInterval(thermalTimer);
|
|
133
|
+
thermalTimer = null;
|
|
134
|
+
}
|
|
135
|
+
// The one machine where "nothing" is worth doing something about: an
|
|
136
|
+
// Apple Silicon Mac has sensors and no way to read them, and the tool
|
|
137
|
+
// that can is a 746 KB signed binary this deck can fetch. Started HERE
|
|
138
|
+
// rather than at boot on purpose — the boot was just taught not to wait
|
|
139
|
+
// for an install (#742) and nothing waits for this one either. One
|
|
140
|
+
// attempt per process, and only after the give-up, so a machine that
|
|
141
|
+
// does have a sensor never downloads anything. See macmon.mjs.
|
|
142
|
+
if (!thermalEverAnswered && process.platform === "darwin") fetchMacmon(deps);
|
|
143
|
+
}
|
|
144
|
+
} catch { thermalMisses++; }
|
|
145
|
+
finally { thermalInFlight = false; }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Fetch macmon, then ask again — floating, on purpose.
|
|
150
|
+
*
|
|
151
|
+
* Not awaited by sampleThermal, which is itself not awaited by anything: this
|
|
152
|
+
* is a download that may take a minute on a slow line, and the panel it serves
|
|
153
|
+
* is optional. When it lands, the give-up above has already stopped the timer,
|
|
154
|
+
* so the retry has to be made here rather than waited for.
|
|
155
|
+
*/
|
|
156
|
+
function fetchMacmon(deps = {}) {
|
|
157
|
+
const boot = deps.bootstrap ?? (async () => (await import("./macmon.mjs")).bootstrapMacmon());
|
|
158
|
+
Promise.resolve(boot()).then(r => {
|
|
159
|
+
if (!r?.ok) return;
|
|
160
|
+
// A sensor exists after all. Clear the give-up and let the timer run again,
|
|
161
|
+
// which is what turns a downloaded binary into a section on screen without
|
|
162
|
+
// the user restarting anything.
|
|
163
|
+
thermalMisses = 0;
|
|
164
|
+
if (!thermalTimer) {
|
|
165
|
+
thermalTimer = setInterval(() => { sampleThermal(deps); }, THERMAL_INTERVAL_MS);
|
|
166
|
+
// Unref'd like the one startThermalTimer creates: a poll for an optional
|
|
167
|
+
// panel must not be the reason a process refuses to exit.
|
|
168
|
+
thermalTimer.unref?.();
|
|
169
|
+
}
|
|
170
|
+
sampleThermal(deps);
|
|
171
|
+
}).catch(() => {});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The last reading, or null while there is none — before the first answer,
|
|
175
|
+
* and once a silence has dropped it. The thermal chart's bands come from it
|
|
176
|
+
* (thermalSeries in system-metrics.mjs). */
|
|
177
|
+
export function lastThermal() {
|
|
178
|
+
return thermal;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The thermal row /api/system answers: the last reading, with whether the
|
|
182
|
+
* machine has been held back since the deck started, or null without one. */
|
|
183
|
+
export function thermalSnapshot() {
|
|
184
|
+
return thermal ? { ...thermal, heldBack: heldBackSoFar() } : null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The ten-second timer, made where startSystemMetrics in system-metrics.mjs
|
|
188
|
+
* makes the others and unref'd like them. Once a give-up has stopped it,
|
|
189
|
+
* fetchMacmon is what makes it again. */
|
|
190
|
+
export function startThermalTimer() {
|
|
191
|
+
thermalTimer = setInterval(sampleThermal, THERMAL_INTERVAL_MS);
|
|
192
|
+
thermalTimer.unref?.();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Stop the timer and forget the reading, the misses and whether this machine
|
|
196
|
+
* ever answered — this section's half of stopSystemMetrics in
|
|
197
|
+
* system-metrics.mjs, and there for the same reason (#798). */
|
|
198
|
+
export function stopThermal() {
|
|
199
|
+
if (thermalTimer) clearInterval(thermalTimer);
|
|
200
|
+
thermalTimer = null;
|
|
201
|
+
thermal = null;
|
|
202
|
+
thermalMisses = 0;
|
|
203
|
+
thermalEverAnswered = false;
|
|
204
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// The deck's own token, taken back out of an event before anything stores it
|
|
2
|
+
// (#366).
|
|
3
|
+
//
|
|
4
|
+
// This lived in src/server/index.mjs, just above pushEvent, which calls it
|
|
5
|
+
// before anything else sees an event. It reads nothing but HOOK_TOKEN, so it
|
|
6
|
+
// moved to a leaf of its own; the walk is unchanged.
|
|
7
|
+
import { HOOK_TOKEN } from "./request-gates.mjs";
|
|
8
|
+
|
|
9
|
+
// What a redacted occurrence of the deck's token is replaced with. A visible
|
|
10
|
+
// marker rather than an empty string, because the reader of a mangled Bash
|
|
11
|
+
// output deserves to know the deck did it and why, and because the UI renders
|
|
12
|
+
// payload strings verbatim — a silent hole would look like a truncated tool
|
|
13
|
+
// response and get debugged as one.
|
|
14
|
+
const TOKEN_REDACTED = "[redacted: ccdeck token]";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Take the deck's own token back out of an event, before anything stores it.
|
|
18
|
+
*
|
|
19
|
+
* #366 made HOOK_TOKEN the one credential separating "may read" from "may
|
|
20
|
+
* mutate", and there is a path that copies it into a store needing no
|
|
21
|
+
* credential to read. `POST /api/event` is deliberately open — hook/hook.js is
|
|
22
|
+
* installed outside this package, into the user's ~/.claude, so requiring a
|
|
23
|
+
* credential there would silence every session whose hook predates that change
|
|
24
|
+
* — while `GET /api/events`, `GET /events` and events.jsonl are all readable
|
|
25
|
+
* without one. So an agent session that merely LOOKS at the discovery file puts
|
|
26
|
+
* the token into the ring buffer, and that is not an exotic act: `cat
|
|
27
|
+
* ~/.claude/agent-dag/<pid>.json`, a `Read` of it, or a plain `grep -r
|
|
28
|
+
* ~/.claude` all do it, and CC records both the tool input and the tool
|
|
29
|
+
* response. From the buffer it is served to exactly the two callers #366 was
|
|
30
|
+
* written for — the other local UID and the sandboxed subprocess — neither of
|
|
31
|
+
* which can open the discovery file itself but both of which reach the API. For
|
|
32
|
+
* them this turned "cannot mutate" back into "can mutate", the token read out
|
|
33
|
+
* of the event log being the whole of the new gate. What keeps those two out of
|
|
34
|
+
* the discovery file itself — a mode bit on Unix, the profile directory's ACL
|
|
35
|
+
* on Windows, where the chmod is a no-op — is spelled out at presentsDeckToken.
|
|
36
|
+
*
|
|
37
|
+
* Why pushEvent calls this and not handleEventIngest, which is where the
|
|
38
|
+
* report first put it. Events reach the buffer through more than one door:
|
|
39
|
+
* handleEventIngest is the hook's, emitCodexEvent is the Codex rollout
|
|
40
|
+
* watcher's — those events never pass through an HTTP handler at all —
|
|
41
|
+
* replayLog is the boot replay's, `/api/clear` pushes its own marker, and six
|
|
42
|
+
* enrichment sites push synthetic events off the back of a transcript read.
|
|
43
|
+
* Redacting at the ingest handler covers one of those and leaves the next route
|
|
44
|
+
* somebody adds uncovered. pushEvent is the one point every event passes
|
|
45
|
+
* through exactly once, so the check is written once and cannot be forgotten.
|
|
46
|
+
*
|
|
47
|
+
* Why a walk over the string values rather than
|
|
48
|
+
* `JSON.stringify(payload).includes(token)`, which is the obvious one-liner.
|
|
49
|
+
* Measured rather than assumed, on 4.7k real payloads out of a 21MB
|
|
50
|
+
* events.jsonl (mean 5KB serialized): the walk costs 1.3–3.6 µs per event and
|
|
51
|
+
* serialize-then-search costs 14–48 µs, the same order as the JSON.parse the
|
|
52
|
+
* ingest path already pays for the body. Serializing here would also defeat the
|
|
53
|
+
* deliberate optimization in pushEvent, which skips serializing ENTIRELY when
|
|
54
|
+
* nothing is subscribed and nothing is being logged — a headless deck, and the
|
|
55
|
+
* boot replay of a log that only rotates at 50MB. The walk allocates nothing at
|
|
56
|
+
* all until it finds something.
|
|
57
|
+
*
|
|
58
|
+
* Iterative rather than recursive on purpose. The payload arrives from
|
|
59
|
+
* JSON.parse of a body capped at 5MB, which is free to nest as deeply as it
|
|
60
|
+
* likes, and a recursive scan would be a new way to overflow the stack inside
|
|
61
|
+
* the request listener — where nothing catches it.
|
|
62
|
+
*
|
|
63
|
+
* Scope is the token and nothing else in the discovery file. pid, port,
|
|
64
|
+
* workspace, the log path and startedAt are not credentials — /api/health
|
|
65
|
+
* already answers with the workspace path — and redacting the workspace would
|
|
66
|
+
* mangle the `cwd` of every honest event the deck draws.
|
|
67
|
+
*
|
|
68
|
+
* What this does NOT catch, stated plainly so nobody mistakes it for more. A
|
|
69
|
+
* token split across two string fields, or across two events, survives — but
|
|
70
|
+
* every substring search has that shape, and the halves are individually
|
|
71
|
+
* worthless: secretEquals is byte-exact, so a partial token authenticates
|
|
72
|
+
* nothing. Case is not folded for the same reason; HOOK_TOKEN is lowercase hex
|
|
73
|
+
* and an uppercased copy would not authenticate either.
|
|
74
|
+
*
|
|
75
|
+
* And this is not a new oracle. A caller who can POST here and read
|
|
76
|
+
* /api/events can post a guess and watch whether it comes back redacted, which
|
|
77
|
+
* tells them precisely what `x-ccdeck-token: <guess>` on any protected route
|
|
78
|
+
* already tells them — against 256 bits with no structure to search.
|
|
79
|
+
*
|
|
80
|
+
* Returns the payload, because a top-level JSON string is a legal body for
|
|
81
|
+
* `POST /api/event` and a primitive cannot be redacted in place.
|
|
82
|
+
*/
|
|
83
|
+
function redactDeckToken(raw) {
|
|
84
|
+
// HOOK_TOKEN is request-gates.mjs's, and an import is bound before this
|
|
85
|
+
// module's body runs, so it is set long before the first event arrives.
|
|
86
|
+
const token = HOOK_TOKEN;
|
|
87
|
+
if (typeof raw === "string") return raw.includes(token) ? raw.replaceAll(token, TOKEN_REDACTED) : raw;
|
|
88
|
+
if (raw === null || typeof raw !== "object") return raw;
|
|
89
|
+
|
|
90
|
+
const stack = [raw];
|
|
91
|
+
while (stack.length > 0) {
|
|
92
|
+
const node = stack.pop();
|
|
93
|
+
// Arrays and objects are walked separately because an indexed loop over an
|
|
94
|
+
// array is markedly cheaper than `for…in` over one, and this runs on every
|
|
95
|
+
// event: a single PostToolUse response can be an array of thousands.
|
|
96
|
+
if (Array.isArray(node)) {
|
|
97
|
+
for (let i = 0; i < node.length; i++) {
|
|
98
|
+
const v = node[i];
|
|
99
|
+
if (typeof v === "string") {
|
|
100
|
+
if (v.includes(token)) node[i] = v.replaceAll(token, TOKEN_REDACTED);
|
|
101
|
+
} else if (v !== null && typeof v === "object") stack.push(v);
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
104
|
+
// `for…in` is safe on these: they come from JSON.parse or from an object
|
|
105
|
+
// literal in the server's own code, so the prototype chain is
|
|
106
|
+
// Object.prototype, which has nothing enumerable. A `"__proto__"` key in
|
|
107
|
+
// the posted JSON becomes an OWN data property — JSON.parse does not run
|
|
108
|
+
// the setter — so it both enumerates here and takes the assignment below
|
|
109
|
+
// as an ordinary write, leaving the prototype alone.
|
|
110
|
+
for (const k in node) {
|
|
111
|
+
const v = node[k];
|
|
112
|
+
if (typeof v === "string") {
|
|
113
|
+
if (v.includes(token)) node[k] = v.replaceAll(token, TOKEN_REDACTED);
|
|
114
|
+
} else if (v !== null && typeof v === "object") stack.push(v);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return raw;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// What pushEvent calls. Listed rather than marked at the declaration, so the
|
|
122
|
+
// declaration reads as it did where it came from.
|
|
123
|
+
export { redactDeckToken };
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Which transcript paths the deck will open at all: the gate pushEvent asks
|
|
2
|
+
// before any enrichment pass reads the `transcript_path` a hook payload names,
|
|
3
|
+
// and the once-per-path note on stderr when it refuses one.
|
|
4
|
+
//
|
|
5
|
+
// This lived at the end of src/server/transcript-scan.mjs, and nothing in the
|
|
6
|
+
// scanner ever called it. event-pipeline.mjs asks it once, at the one door a
|
|
7
|
+
// caller-chosen path comes through, and only then hands the path to the
|
|
8
|
+
// passes that scan it. The bodies are unchanged, and index.mjs still
|
|
9
|
+
// re-exports isClaudeTranscriptPath.
|
|
10
|
+
import { resolve, sep } from "node:path";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { claudeConfigDir } from "./claude-dir.mjs";
|
|
13
|
+
import { PRODUCT } from "./brand.mjs";
|
|
14
|
+
// Whether this platform's filesystem folds case — the one answer every
|
|
15
|
+
// server-side path comparison gives. See foldsCase in log-election.mjs.
|
|
16
|
+
import { foldsCase } from "./log-election.mjs";
|
|
17
|
+
|
|
18
|
+
// ─── Which paths the deck will follow at all ─────────────────────────────
|
|
19
|
+
// `payload.transcript_path` is a string in the body of `POST /api/event`, and
|
|
20
|
+
// that route is a deliberate OPEN_MUTATION: no token, no Origin, nothing. Until
|
|
21
|
+
// #674 the deck took whatever it said and opened it. The bounds on the read
|
|
22
|
+
// (MAX_SCAN_CHUNK in jsonl-chunks.mjs, MAX_SCAN_BYTES_PER_PASS in
|
|
23
|
+
// transcript-scan.mjs) make that survivable; this makes it uninteresting, and
|
|
24
|
+
// the two are worth having together for different reasons.
|
|
25
|
+
//
|
|
26
|
+
// WHY VALIDATE AT ALL WHEN THE READ IS ALREADY BOUNDED. Because the caller here
|
|
27
|
+
// is not a web page — `isTrustedMutation` refuses `Sec-Fetch-Site: cross-site`
|
|
28
|
+
// before any of this — it is a local process: the sandboxed subprocess with
|
|
29
|
+
// loopback egress that the comment above `isAuthorizedMutation` already names,
|
|
30
|
+
// or another UID on a shared box. Against that caller a ceiling only sets the
|
|
31
|
+
// price per request; it does not take the lever away. What takes it away is
|
|
32
|
+
// that there is no file it can name. Claude Code writes transcripts in exactly
|
|
33
|
+
// one place, `<config dir>/projects/…`, and every legitimate `transcript_path`
|
|
34
|
+
// the deck has ever seen is one of those — so the set of things worth opening
|
|
35
|
+
// is knowable in advance, and checking membership costs one string comparison
|
|
36
|
+
// against a syscall that used to cost the size of the file.
|
|
37
|
+
//
|
|
38
|
+
// WHAT IS LEFT AFTERWARDS, stated plainly: a caller who can WRITE inside that
|
|
39
|
+
// directory can still point the deck at a file of its choosing. That caller is
|
|
40
|
+
// this user's own processes — and this user's own processes can read
|
|
41
|
+
// `<config dir>/agent-dag/*.json`, which is where HOOK_TOKEN lives at mode
|
|
42
|
+
// 0600, so they hold the credential already. The gate reduces the
|
|
43
|
+
// credential-free adversary to the one who was never credential-free. That is
|
|
44
|
+
// the whole of what it claims, and those ceilings on the read are what carries
|
|
45
|
+
// the rest.
|
|
46
|
+
//
|
|
47
|
+
// WHY NOT realpath. A symlink planted inside the projects directory would
|
|
48
|
+
// defeat the containment test — but planting one needs write access to that
|
|
49
|
+
// directory, which is the case above where the caller already holds the token.
|
|
50
|
+
// It would also cost a syscall on every hook event, on a path that runs for
|
|
51
|
+
// every event of every live session.
|
|
52
|
+
//
|
|
53
|
+
// WHY TWO ROOTS. CLAUDE_CONFIG_DIR replaces ~/.claude wholesale, and the deck
|
|
54
|
+
// reads the variable from its OWN environment while the path is written by
|
|
55
|
+
// whatever `claude` process the hook fired in. Those normally agree — the deck
|
|
56
|
+
// installs its hook into the directory it resolves, so a session whose events
|
|
57
|
+
// arrive here is a session reading that same directory — but a deck launched
|
|
58
|
+
// from a desktop shortcut that never sourced the shell rc is a real way for
|
|
59
|
+
// them to disagree in one direction. Accepting the default location as well
|
|
60
|
+
// costs nothing (it is a directory only this user writes either way) and
|
|
61
|
+
// removes half of that failure mode. The other half is why the refusal is
|
|
62
|
+
// logged rather than silent.
|
|
63
|
+
function claudeTranscriptRoots() {
|
|
64
|
+
// Resolved per call, like every other claudeConfigDir() reader in src/server,
|
|
65
|
+
// so nothing captures the answer from an environment that has moved.
|
|
66
|
+
const roots = [resolve(claudeConfigDir(), "projects")];
|
|
67
|
+
const byDefault = resolve(homedir(), ".claude", "projects");
|
|
68
|
+
if (!roots.includes(byDefault)) roots.push(byDefault);
|
|
69
|
+
return roots;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Is `p` a Claude Code transcript, in a directory Claude Code writes them?
|
|
73
|
+
*
|
|
74
|
+
* Containment is compared on the RESOLVED path with a trailing separator, so
|
|
75
|
+
* `…/projects-of-mine/x.jsonl` is not inside `…/projects` and `..` cannot
|
|
76
|
+
* climb out of it. The comparison is case-insensitive on Windows and macOS,
|
|
77
|
+
* whose default filesystems are, because the two halves come from two
|
|
78
|
+
* processes and only one of them chose the casing. */
|
|
79
|
+
export function isClaudeTranscriptPath(p, roots = claudeTranscriptRoots()) {
|
|
80
|
+
if (!p || typeof p !== "string") return false;
|
|
81
|
+
if (!/\.jsonl$/i.test(p)) return false; // the only extension CC writes
|
|
82
|
+
const fold = foldsCase();
|
|
83
|
+
const full = fold ? resolve(p).toLowerCase() : resolve(p);
|
|
84
|
+
for (const root of roots) {
|
|
85
|
+
const prefix = (fold ? root.toLowerCase() : root) + sep;
|
|
86
|
+
if (full.startsWith(prefix) && full.length > prefix.length) return true;
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Refusals are logged once per path and the set is capped, because the point of
|
|
92
|
+
// the log is a misconfigured deck saying so on stderr — one line naming the
|
|
93
|
+
// path and where transcripts are expected — and a caller posting a fresh path
|
|
94
|
+
// per request must not turn that into a second unbounded accumulation.
|
|
95
|
+
const refusedTranscriptPaths = new Set();
|
|
96
|
+
const MAX_REFUSED_TRANSCRIPT_PATHS = 64;
|
|
97
|
+
|
|
98
|
+
function noteRefusedTranscript(p) {
|
|
99
|
+
if (refusedTranscriptPaths.has(p)) return;
|
|
100
|
+
if (refusedTranscriptPaths.size >= MAX_REFUSED_TRANSCRIPT_PATHS) return;
|
|
101
|
+
refusedTranscriptPaths.add(p);
|
|
102
|
+
console.warn(`${PRODUCT}: not reading transcript_path outside ${claudeTranscriptRoots().join(" or ")}: ${p}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// What event-pipeline.mjs calls besides isClaudeTranscriptPath above. Listed
|
|
106
|
+
// rather than marked, so the declaration reads as it did where it came from.
|
|
107
|
+
export { noteRefusedTranscript };
|