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.
Files changed (178) hide show
  1. package/README.md +4 -3
  2. package/bin/agent-dag.js +67 -118
  3. package/bin/cli/help.js +67 -0
  4. package/bin/cli/login-item.js +182 -0
  5. package/bin/cli/one-shot.js +254 -0
  6. package/bin/cli/package.js +39 -0
  7. package/bin/cli/pulse.js +98 -0
  8. package/bin/cli/restart.js +179 -0
  9. package/bin/cli/screen.js +166 -0
  10. package/bin/cli/second-start.js +120 -0
  11. package/bin/cli/startup.js +459 -0
  12. package/bin/cli/uninstall.js +169 -0
  13. package/bin/deck.js +112 -1559
  14. package/dist/web/assets/BrowserWatchModal-n63GjLhv.js +1 -0
  15. package/dist/web/assets/UsageHistoryModal-DEwBX4Fk.js +1 -0
  16. package/dist/web/assets/index-CZJf1Cpp.js +299 -0
  17. package/dist/web/assets/index-D4q1ElXU.css +1 -0
  18. package/dist/web/index.html +2 -2
  19. package/hook/hook.js +15 -14
  20. package/package.json +1 -1
  21. package/release-notes.json +40 -0
  22. package/src/server/account-projects.mjs +25 -11
  23. package/src/server/account-routes.mjs +254 -0
  24. package/src/server/activity.mjs +3 -3
  25. package/src/server/append-budget.mjs +185 -0
  26. package/src/server/append-failures.mjs +147 -0
  27. package/src/server/args.mjs +37 -5
  28. package/src/server/atomic-write.mjs +300 -0
  29. package/src/server/block-notify.mjs +0 -5
  30. package/src/server/boot-lock.mjs +1 -1
  31. package/src/server/brand.mjs +7 -6
  32. package/src/server/browser-react.mjs +10 -2
  33. package/src/server/browser-watch-decks.mjs +146 -0
  34. package/src/server/browser-watch-feed.mjs +69 -0
  35. package/src/server/browser-watch-log.mjs +173 -0
  36. package/src/server/browser-watch-mtime.mjs +18 -0
  37. package/src/server/browser-watch-relay.mjs +112 -0
  38. package/src/server/browser-watch-routes.mjs +172 -0
  39. package/src/server/browser-watch-seen.mjs +127 -0
  40. package/src/server/browser-watch-store.mjs +14 -143
  41. package/src/server/browser-watch.mjs +278 -626
  42. package/src/server/canonical-path.mjs +150 -0
  43. package/src/server/ccusage-failure.mjs +64 -0
  44. package/src/server/ccusage-install.mjs +459 -0
  45. package/src/server/ccusage-runner.mjs +271 -0
  46. package/src/server/ccusage.mjs +21 -756
  47. package/src/server/claude-accounts.mjs +220 -464
  48. package/src/server/claude-collector.mjs +189 -0
  49. package/src/server/claude-dir.mjs +7 -3
  50. package/src/server/claude-identity.mjs +5 -4
  51. package/src/server/claude-reset-credits.mjs +130 -0
  52. package/src/server/claude-verdicts.mjs +174 -0
  53. package/src/server/clear-route.mjs +191 -0
  54. package/src/server/codex-auth.mjs +4 -4
  55. package/src/server/codex-dir.mjs +52 -8
  56. package/src/server/codex-enrichment.mjs +310 -0
  57. package/src/server/codex-quota.mjs +5 -3
  58. package/src/server/codex-translate.mjs +407 -0
  59. package/src/server/codex-usage.mjs +8 -5
  60. package/src/server/codex-watch.mjs +496 -0
  61. package/src/server/cswap-auto-loop.mjs +342 -0
  62. package/src/server/cswap-auto-readers.mjs +303 -0
  63. package/src/server/cswap-auto.mjs +51 -609
  64. package/src/server/cswap-install.mjs +69 -265
  65. package/src/server/cswap-layout.mjs +242 -0
  66. package/src/server/deck-home.mjs +2 -2
  67. package/src/server/deck-prefs.mjs +58 -184
  68. package/src/server/deck-scope.mjs +52 -0
  69. package/src/server/desktop-update-routes.mjs +75 -0
  70. package/src/server/detach.mjs +78 -0
  71. package/src/server/discovery.mjs +298 -0
  72. package/src/server/event-log.mjs +328 -0
  73. package/src/server/event-pipeline.mjs +235 -0
  74. package/src/server/event-ring.mjs +214 -0
  75. package/src/server/event-routes.mjs +352 -0
  76. package/src/server/event-sink.mjs +26 -0
  77. package/src/server/exec-children.mjs +128 -0
  78. package/src/server/exec-not-found.mjs +213 -0
  79. package/src/server/exec-output.mjs +23 -0
  80. package/src/server/exec-spec.mjs +341 -0
  81. package/src/server/exec.mjs +74 -661
  82. package/src/server/health-route.mjs +42 -0
  83. package/src/server/hook-challenge.mjs +37 -0
  84. package/src/server/http-io.mjs +104 -0
  85. package/src/server/index.mjs +174 -7620
  86. package/src/server/install-layout.mjs +506 -0
  87. package/src/server/installer.mjs +79 -608
  88. package/src/server/jsonl-chunks.mjs +139 -0
  89. package/src/server/lan-accounts.mjs +27 -0
  90. package/src/server/lan-beacon.mjs +457 -0
  91. package/src/server/lan-call.mjs +239 -0
  92. package/src/server/lan-copies.mjs +270 -0
  93. package/src/server/lan-deck.mjs +583 -0
  94. package/src/server/lan-dials.mjs +161 -0
  95. package/src/server/lan-engine.mjs +475 -1024
  96. package/src/server/lan-hearing.mjs +49 -0
  97. package/src/server/lan-inbound.mjs +100 -0
  98. package/src/server/lan-invite-offer.mjs +77 -0
  99. package/src/server/lan-invite.mjs +207 -0
  100. package/src/server/lan-lines.mjs +73 -0
  101. package/src/server/lan-manifest.mjs +74 -0
  102. package/src/server/lan-reach.mjs +82 -69
  103. package/src/server/lan-requests.mjs +249 -0
  104. package/src/server/lan-round-record.mjs +45 -0
  105. package/src/server/lan-round-timer.mjs +62 -0
  106. package/src/server/lan-routes.mjs +143 -0
  107. package/src/server/lan-serve.mjs +112 -0
  108. package/src/server/lan-socket.mjs +243 -925
  109. package/src/server/lan-sync.mjs +58 -1026
  110. package/src/server/lan-tailnet-poll.mjs +57 -0
  111. package/src/server/lan-turns.mjs +80 -0
  112. package/src/server/lan-wire.mjs +620 -0
  113. package/src/server/lifecycle.mjs +421 -0
  114. package/src/server/listen.mjs +110 -0
  115. package/src/server/live-decks.mjs +138 -0
  116. package/src/server/load-average.mjs +32 -0
  117. package/src/server/log-election.mjs +235 -0
  118. package/src/server/log-replay.mjs +374 -0
  119. package/src/server/log-writer.mjs +72 -557
  120. package/src/server/login-service.mjs +3 -2
  121. package/src/server/macmon.mjs +1 -1
  122. package/src/server/memory-files.mjs +163 -0
  123. package/src/server/memory-metrics.mjs +178 -0
  124. package/src/server/metrics-history.mjs +94 -0
  125. package/src/server/metrics-run.mjs +100 -0
  126. package/src/server/music-routes.mjs +134 -0
  127. package/src/server/network-metrics.mjs +5 -3
  128. package/src/server/network-sampler.mjs +256 -0
  129. package/src/server/npm-latest.mjs +385 -0
  130. package/src/server/npm-upgrade.mjs +352 -0
  131. package/src/server/npx.mjs +41 -7
  132. package/src/server/output-watch.mjs +5 -4
  133. package/src/server/pinned-build.mjs +110 -0
  134. package/src/server/prefs-give-back.mjs +206 -0
  135. package/src/server/prefs-path.mjs +14 -0
  136. package/src/server/prefs-refusal.mjs +86 -0
  137. package/src/server/prefs-routes.mjs +99 -0
  138. package/src/server/prefs-set-aside.mjs +86 -0
  139. package/src/server/prefs-state.mjs +70 -0
  140. package/src/server/process-command.mjs +104 -0
  141. package/src/server/process-list.mjs +547 -0
  142. package/src/server/provider-status.mjs +307 -0
  143. package/src/server/pulse-line.mjs +132 -0
  144. package/src/server/purge-key.mjs +7 -7
  145. package/src/server/quota-cli.mjs +174 -0
  146. package/src/server/quota-oauth.mjs +190 -0
  147. package/src/server/quota-shape.mjs +229 -0
  148. package/src/server/quota-store-resets.mjs +206 -0
  149. package/src/server/quota.mjs +123 -509
  150. package/src/server/relay-guard.mjs +3 -3
  151. package/src/server/request-gates.mjs +441 -0
  152. package/src/server/restart-note.mjs +180 -0
  153. package/src/server/retire-sound-hook.mjs +3 -3
  154. package/src/server/ring-bounds.mjs +215 -0
  155. package/src/server/running-deck.mjs +1 -1
  156. package/src/server/running-version.mjs +24 -0
  157. package/src/server/self-update.mjs +39 -1371
  158. package/src/server/session-enrichment.mjs +622 -0
  159. package/src/server/session-read-gate.mjs +49 -0
  160. package/src/server/session-tracking.mjs +242 -0
  161. package/src/server/sse-clients.mjs +222 -0
  162. package/src/server/stale-discovery.mjs +30 -0
  163. package/src/server/static-serve.mjs +81 -0
  164. package/src/server/supervisor.mjs +73 -12
  165. package/src/server/system-metrics.mjs +169 -1935
  166. package/src/server/term.mjs +11 -249
  167. package/src/server/thermal-metrics.mjs +520 -0
  168. package/src/server/thermal-sampler.mjs +204 -0
  169. package/src/server/token-redact.mjs +123 -0
  170. package/src/server/transcript-gate.mjs +107 -0
  171. package/src/server/transcript-scan.mjs +563 -0
  172. package/src/server/usage-routes.mjs +108 -0
  173. package/src/server/uv-bootstrap.mjs +1 -1
  174. package/src/server/wordmark.mjs +127 -0
  175. package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
  176. package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
  177. package/dist/web/assets/index-Ck2RAla3.css +0 -1
  178. 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 };