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
@@ -14,8 +14,8 @@
14
14
  //
15
15
  // WHERE IT SURFACES. The Browser Watch panel, under "Can this browser be
16
16
  // driven": the verdict, the grants each profile actually holds, and the
17
- // killswitch command as text to copy. `browser-watch.mjs`'s `relayGuard` does
18
- // the two reads this module refuses to do and calls everything below.
17
+ // killswitch command as text to copy. `browser-watch-relay.mjs`'s `relayGuard`
18
+ // does the two reads this module refuses to do and calls everything below.
19
19
  //
20
20
  // WHAT THIS MODULE DOES. Two reads and a string. It says whether the extension
21
21
  // is installed and what it was granted (from a profile's "Secure Preferences",
@@ -25,7 +25,7 @@
25
25
  // the user to paste.
26
26
  //
27
27
  // WHY IT NEVER ELEVATES AND NEVER WRITES. Not squeamishness — the deck's own
28
- // threat model. `isTrustedMutation` in index.mjs deliberately lets a request
28
+ // threat model. `isTrustedMutation` deliberately lets a request
29
29
  // carrying no Origin header through (`if (!hasOrigin && !site) return true;`)
30
30
  // so that hook.js and curl keep working, on the reasoning that a process able
31
31
  // to POST to loopback can already run anything as the user. That reasoning
@@ -0,0 +1,441 @@
1
+ // Who may ask this deck for what: the four gates the router runs in front of
2
+ // every route, the two route lists they consult, and the per-process token the
3
+ // strictest of them checks.
4
+ //
5
+ // These lived in src/server/index.mjs, between the discovery sweep and the
6
+ // listen loop, and they are one argument rather than a row of helpers: whether
7
+ // a page chose the request, whether it was addressed to this machine, and who
8
+ // is asking, each comment leaning on the one before it. They answer from the
9
+ // headers alone — nothing here reads the ring, the log or a socket — so they
10
+ // sit in a leaf that imports one node builtin, and the ORDER the router asks
11
+ // them in stays in startServer, where it has always been written down.
12
+ //
13
+ // HOOK_TOKEN moved with them rather than being handed in, because
14
+ // presentsDeckToken is the one place it is checked. Its other readers — the
15
+ // hook's challenge, the event redaction and the discovery file — only need the
16
+ // value, and import it from here; index.mjs still exports hookToken() under
17
+ // the name bin/deck.js and the tests have always used.
18
+ import { randomBytes, timingSafeEqual } from "node:crypto";
19
+
20
+ // A secret this process, and only this process, knows. It goes into the
21
+ // discovery file bin/deck.js writes (mode 0600) and is never sent anywhere:
22
+ // hook/hook.js reads it from that file and asks us to hash it against a nonce
23
+ // before it will send us a single session payload.
24
+ //
25
+ // Fresh per start, deliberately. A discovery file that outlives its deck —
26
+ // SIGKILL and power cuts both leave one behind — names a pid the OS is free to
27
+ // hand to something else and a port that by then may belong to anything. The
28
+ // pid probe, isProcessAlive in deck-probe.mjs, cannot tell that apart from a
29
+ // running deck, so the token is what actually distinguishes us: the
30
+ // replacement process does not have it, and neither does the next deck, so a
31
+ // stale file authenticates nothing.
32
+ export const HOOK_TOKEN = randomBytes(32).toString("hex");
33
+
34
+ // Is this request allowed to be answered at all, whatever it asks for?
35
+ //
36
+ // The rebinding gate below was reached only by mutations, on the reasoning that
37
+ // a cross-site page cannot read a loopback reply. That is true of a genuinely
38
+ // cross-origin page and false of a rebound one: the browser resolved
39
+ // attacker.example to 127.0.0.1 itself, so it calls the reply same-origin and
40
+ // hands the body to the page. Every read was therefore open to exactly the
41
+ // attack the mutation gate was written to stop — and the reads are where the
42
+ // secrets are. GET /api/events is the whole ring buffer: prompt text, the Bash
43
+ // command lines the agent ran, the paths and contents it wrote, the contents of
44
+ // every file it read back. /api/claude-accounts names the accounts,
45
+ // /api/claude-accounts/login carries a live OAuth authorize URL, /api/health the
46
+ // absolute workspace path.
47
+ //
48
+ // So the Host check runs for every method now. What it asks is only the
49
+ // rebinding question — did this request arrive addressed to a name that can
50
+ // only ever be this machine — and it asks it of browser-shaped requests alone,
51
+ // meaning anything carrying an Origin, fetch metadata or a Referer. A client
52
+ // sending none of them is not a page and has no ambient authority to borrow:
53
+ // that is hook/hook.js, a plain Node http.request from the user's own machine,
54
+ // and it keeps reaching the deck under whatever name it used before.
55
+ //
56
+ // THE REFERER IS ON THAT LIST because on one browser it is the only mark a page
57
+ // leaves (#1168). A same-origin GET carries no Origin, and Safari 16.0-16.3
58
+ // sends no Sec-Fetch-Site — the premise isAuthorizedDataRead's fallback is
59
+ // built on — so a rebound page on that browser sent `Host: attacker.example:
60
+ // 4317` and `Referer: http://attacker.example:4317/` and nothing else, was not
61
+ // browser-shaped by the test as it stood, and was answered. Measured against a
62
+ // running deck before this line changed: /api/health (the absolute workspace
63
+ // path), /api/hook-challenge (the proof oracle, which handleHookChallenge says
64
+ // a rebound page cannot see), /api/system/processes and the page itself all
65
+ // answered 200. The guarded reads held only because isAuthorizedDataRead
66
+ // happens to test the Host before it reads the Referer. No client of this
67
+ // server that is not a browser sends a Referer — hook.js, the desktop app and
68
+ // bin/ send none — so nothing that reached the deck before is turned away by
69
+ // this, and the deck's own page names a loopback Host whatever it sends.
70
+ //
71
+ // Deliberately not part of this: the Sec-Fetch-Site test that isTrustedMutation
72
+ // applies. `cross-site` on a read is an ordinary top-level navigation — a link
73
+ // to http://localhost:4317 clicked on any page — and the document it loads is
74
+ // the deck's own UI on the deck's own origin, which is not an attack and used
75
+ // to work. Rebinding does not need that test either: a rebound page's requests
76
+ // report `same-origin`, and it is the Host that gives it away.
77
+ export function isTrustedRead({ origin, host, secFetchSite, referer } = {}) {
78
+ const browserShaped = (typeof origin === "string" && origin !== "")
79
+ || (typeof secFetchSite === "string" && secFetchSite.trim() !== "")
80
+ || (typeof referer === "string" && referer.trim() !== "");
81
+ if (!browserShaped) return true;
82
+ return isLoopbackHost(host);
83
+ }
84
+
85
+ // Is this mutating request allowed to be acted on?
86
+ //
87
+ // The deck binds 127.0.0.1, which sounds private but is reachable from every
88
+ // page the user's browser has open: a cross-site POST with a CORS-safelisted
89
+ // `Content-Type: text/plain` fires no preflight, so any visited page could
90
+ // remove a Claude account, import an attacker-crafted one, switch the live
91
+ // account, flip auto-switching, start a global npm upgrade, restart the deck
92
+ // or truncate the event log. None of those need to read the response, so the
93
+ // same-origin policy alone never stopped them.
94
+ //
95
+ // Two headers decide it, and both are set by the browser itself — page script
96
+ // cannot forge either, they are forbidden header names:
97
+ //
98
+ // Sec-Fetch-Site present on every modern-browser request. `same-origin`
99
+ // (our own UI) and `none` (the user typed the URL) pass;
100
+ // `cross-site` and `same-site` are exactly the attack and
101
+ // are refused. Absent on older Safari, hence the second half.
102
+ // Origin present on every browser POST, including same-origin ones.
103
+ // It must name this very server: comparing it to the Host
104
+ // header the browser filled in from the target URL. A page
105
+ // on http://evil.com — or on http://localhost:8000, which is
106
+ // just as cross-origin — cannot make the two agree. The
107
+ // opaque `null` origin (sandboxed iframe, data: URL) fails
108
+ // to parse and is refused with it.
109
+ //
110
+ // Agreement between the two is necessary but not sufficient, because both are
111
+ // derived from the URL the page was served from and neither says a word about
112
+ // the address the socket actually landed on — so the Host must also name a
113
+ // loopback identity. See isLoopbackHost for the attack that gets through
114
+ // without it.
115
+ //
116
+ // A request carrying neither header is not a browser request and is allowed
117
+ // whatever Host it names: that is hook/hook.js, a plain Node http.request from
118
+ // the user's own machine that sends no Origin at all, plus curl and the deck's
119
+ // own tooling. Ambient browser authority is the whole threat here, and those
120
+ // clients have none — a process that can POST here can already run anything as
121
+ // the user, and nothing it sends is chosen by a page.
122
+ export function isTrustedMutation({ origin, host, secFetchSite } = {}) {
123
+ const site = typeof secFetchSite === "string" ? secFetchSite.trim().toLowerCase() : "";
124
+ if (site && site !== "same-origin" && site !== "none") return false;
125
+ const hasOrigin = typeof origin === "string" && origin !== "";
126
+ if (!hasOrigin && !site) return true;
127
+ // Either header present means a browser sent this, so the rebinding gate
128
+ // applies even to the shape that carries fetch metadata but no Origin. Every
129
+ // request reaching this line is browser-shaped by the test just above, so
130
+ // isTrustedRead here is exactly its isLoopbackHost half.
131
+ if (!isTrustedRead({ origin, host, secFetchSite })) return false;
132
+ return hasOrigin ? originMatchesHost(origin, host) : true;
133
+ }
134
+
135
+ // Was this request addressed to a name that can only ever be this machine?
136
+ //
137
+ // Origin === Host alone is not a defence against DNS rebinding: the page is
138
+ // served from http://attacker.example:4317, the attacker re-points that record
139
+ // at 127.0.0.1, and the browser then sends Host: attacker.example:4317 with a
140
+ // matching Origin and Sec-Fetch-Site: same-origin — every header self-consistent
141
+ // and every one attacker-chosen, because fetch metadata comes from the origin
142
+ // tuple (scheme, host, port), not from the resolved IP. The browser also treats
143
+ // the reply as same-origin, so the page reads the body: that is the account
144
+ // share envelope, with the OAuth token in it. A loopback literal has no DNS
145
+ // record to re-point, and `localhost` is reserved to loopback, so requiring one
146
+ // of them is what separates the deck's own UI from the rebound page.
147
+ function isLoopbackHost(host) {
148
+ if (typeof host !== "string") return false;
149
+ const authority = host.trim().toLowerCase();
150
+ // A real Host header is bare authority. Userinfo or a path would let
151
+ // `evil.example@127.0.0.1` and `127.0.0.1/…` parse to a loopback hostname
152
+ // while naming something else, so refuse them rather than reason about them.
153
+ if (authority === "" || /[/\\?#@\s]/.test(authority)) return false;
154
+ let name;
155
+ try { name = new URL(`http://${authority}`).hostname; } catch { return false; }
156
+ if (name === "localhost" || name === "[::1]") return true;
157
+ // The whole 127.0.0.0/8 is this machine, not just .0.1 — a second deck parked
158
+ // on 127.0.0.2 is as local as the first. URL only produces a dotted quad for
159
+ // something it already validated as an IPv4 address, so shape is enough here.
160
+ return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(name);
161
+ }
162
+
163
+ // Does `origin` name the same host:port the request was addressed to? Ports are
164
+ // part of an origin, so http://127.0.0.1:8000 is not http://127.0.0.1:4317 —
165
+ // and the default port is spelled both ways depending on the client, so it is
166
+ // normalised off both sides before comparing.
167
+ function originMatchesHost(origin, host) {
168
+ if (typeof host !== "string" || host === "") return false;
169
+ let parsed;
170
+ try { parsed = new URL(origin); } catch { return false; }
171
+ const dropDefaultPort = (h, scheme) =>
172
+ h.replace(scheme === "https:" ? /:443$/ : /:80$/, "");
173
+ const fromOrigin = dropDefaultPort(parsed.host.toLowerCase(), parsed.protocol);
174
+ const fromHost = dropDefaultPort(host.trim().toLowerCase(), parsed.protocol);
175
+ return fromOrigin !== "" && fromOrigin === fromHost;
176
+ }
177
+
178
+ // Mutating routes that ask nothing of the caller beyond the gates above.
179
+ //
180
+ // Only the hook's ingest, and only because hook/hook.js is installed OUTSIDE
181
+ // this package — it lives in the user's ~/.claude and is loaded by whatever
182
+ // Claude Code session is already running. Requiring a credential here would
183
+ // stop every event from every session whose hook predates this change, on a
184
+ // machine where nothing is obviously broken and nothing says why, until each
185
+ // one is reinstalled. It also carries no credential and destroys nothing: the
186
+ // worst a caller does with it is draw a session on the canvas that is not
187
+ // there. See the handshake in hook/hook.js for the authentication that does
188
+ // run on this path, which is the deck proving itself to the hook.
189
+ //
190
+ // That "destroys nothing" is a claim about the whole ingest path and not only
191
+ // about this line, and #625 is what it cost the day it stopped being true: the
192
+ // ring buffer bounded the events it kept by count and not by size, so about 430
193
+ // posts of a maximum-size body — from a local process holding no credential,
194
+ // which is exactly what this set permits — reached the heap limit and aborted
195
+ // the process. Whatever else is added to this set, the same question has to be
196
+ // asked of it: what does an unbounded number of these accumulate in? For this
197
+ // one the answer is now MAX_BUFFER_CHARS.
198
+ //
199
+ // #674 is the same question asked a second time, and the answer was not in the
200
+ // ring at all. The body of this POST carries `transcript_path`, and everything
201
+ // the deck learns about a session's model, cost, name and context is read out
202
+ // of the file it names — so the credential-free route does not stop at the ring
203
+ // buffer, it reaches the filesystem, with a path the caller chose. It was read
204
+ // by allocating the whole file: 700 MB named in one POST took a fresh deck from
205
+ // 52 MB RSS to 753 MB and answered `200 {"ok":true,"seq":1}`, and because a
206
+ // file with no newline in it could never advance the scan cursor, every later
207
+ // POST paid it again. What bounds it now is three things, in the order they
208
+ // were reached for: `isClaudeTranscriptPath` means an unrecognised path is not
209
+ // opened at all, so the caller who holds no credential can name nothing;
210
+ // MAX_SCAN_CHUNK means no single read allocates more than 8 MiB whatever it is
211
+ // pointed at; MAX_SCAN_BYTES_PER_PASS means no single POST walks more than 256
212
+ // MiB of a file.
213
+ //
214
+ // #992 is the same question asked of the Codex half of this route, which the
215
+ // paragraph above never covered. A `provider: "codex"` event carries a
216
+ // `session_id` and no path, and the deck looks that id up by walking
217
+ // $CODEX_HOME/sessions. A miss was never kept, so every id no rollout carries
218
+ // walked the whole history again on every throttled pass, and a burst of fresh
219
+ // ids walked it once each, all at once. What bounds it now is
220
+ // findCodexRolloutPath: at most one walk of the whole tree per id in any
221
+ // CODEX_MISS_TTL_MS, never two in flight, and every other lookup reads the
222
+ // newest two day directories. A caller with an endless supply of fresh ids can
223
+ // keep that one walk busy; it cannot make it two.
224
+ //
225
+ // So the claim above holds again, with its scope written out: the worst a
226
+ // caller does with this route is draw a session on the canvas that is not
227
+ // there. It cannot make the deck open a file of its choosing, and it cannot
228
+ // make the deck's memory a function of anything but the two constants named
229
+ // here.
230
+ export const OPEN_MUTATIONS = new Set(["/api/event"]);
231
+
232
+ // Constant-time comparison of two secrets, and a length test that is not.
233
+ // Lengths differ freely in public — a mismatched one only says "not this
234
+ // token" — but timingSafeEqual throws rather than answering false when they do.
235
+ function secretEquals(given, expected) {
236
+ const a = Buffer.from(String(given ?? ""), "utf8");
237
+ const b = Buffer.from(String(expected ?? ""), "utf8");
238
+ if (a.length === 0 || a.length !== b.length) return false;
239
+ return timingSafeEqual(a, b);
240
+ }
241
+
242
+ // Does this request carry the deck's own token?
243
+ //
244
+ // One spelling, `x-ccdeck-token`, carrying the token itself. That is all a
245
+ // local process needs, because the only way it can hold the token is to have
246
+ // read it out of a file only it can open.
247
+ //
248
+ // The token is HOOK_TOKEN, fresh per deck and written to the discovery file at
249
+ // mode 0600. That mode is the whole of the access control: the answer to "who
250
+ // may do this" is "whoever can read that file", which is the user this deck
251
+ // runs as and nobody else. On Windows the chmod is a best-effort no-op, so
252
+ // there the file's protection is the per-user ACL on the profile directory it
253
+ // sits in rather than a mode bit; the token still works identically.
254
+ //
255
+ // The hashed challengeProof form is deliberately NOT accepted here, and this
256
+ // is the paragraph that must be read before anyone adds it. GET
257
+ // /api/hook-challenge?nonce=… answers challengeProof(HOOK_TOKEN, nonce) to any
258
+ // caller for any nonce, by design — so accepting a proof as a credential means
259
+ // the server hands out its own credentials on request, and two unauthenticated
260
+ // GETs are a complete bypass of everything below. This gate shipped that way
261
+ // for one commit; the regression test in csrf-origin.test.ts pins it shut.
262
+ //
263
+ // The premise that makes the oracle safe is the one this gate rejects, which is
264
+ // exactly why the two cannot share a secret: the challenge is the deck proving
265
+ // itself TO the hook, not the hook proving itself to the deck. hook/hook.js
266
+ // already holds the token — it read the same 0600 file — and challenges the
267
+ // port to find out whether the process listening there is really the deck that
268
+ // wrote it, or something else that inherited the port number from a stale
269
+ // discovery file. Nothing travels in the other direction, so no caller ever
270
+ // needed the hashed form as a way IN, and a client entitled to mutate holds the
271
+ // token itself anyway.
272
+ export function presentsDeckToken(headers = {}) {
273
+ const raw = headers["x-ccdeck-token"];
274
+ return typeof raw === "string" && secretEquals(raw.trim(), HOOK_TOKEN);
275
+ }
276
+
277
+ // Is this the deck's own page in the user's browser?
278
+ //
279
+ // A strict subset of isTrustedMutation, which every request here has already
280
+ // passed: an Origin must actually be present — a browser sends one on every
281
+ // POST, including a same-origin one — it must name this very server, the Host
282
+ // must be a loopback identity, and any fetch metadata must say `same-origin`.
283
+ // `none` is excluded on purpose: that is a top-level navigation the user typed
284
+ // or a form submitted from one, never the UI's own fetch.
285
+ function isDeckUiRequest({ origin, host, secFetchSite } = {}) {
286
+ const site = typeof secFetchSite === "string" ? secFetchSite.trim().toLowerCase() : "";
287
+ if (site !== "" && site !== "same-origin") return false;
288
+ if (typeof origin !== "string" || origin === "") return false;
289
+ if (!isLoopbackHost(host)) return false;
290
+ return originMatchesHost(origin, host);
291
+ }
292
+
293
+ // May this request change something?
294
+ //
295
+ // Everything above this line is about the browser: whether a page chose the
296
+ // request, and whether it was addressed to this machine. None of it asks who
297
+ // the caller is, and for a client that is not a browser at all nothing did —
298
+ // a request carrying no Origin and no fetch metadata was waved through on the
299
+ // reasoning that a process able to POST here can already run anything as the
300
+ // user. That holds on a single-user laptop and fails twice elsewhere. Loopback
301
+ // is not scoped to a UID, so on a shared box or a multi-tenant container every
302
+ // other account on the machine can reach this port; and a sandboxed subprocess
303
+ // denied the credential store but allowed loopback egress — the ordinary shape
304
+ // of an agent's Bash sandbox — reaches the same credentials through the API.
305
+ // `curl -XPOST localhost:4317/api/claude-accounts/admin -d '{"action":"share"}'`
306
+ // answered with the account's live OAuth refresh token in the clear, and the
307
+ // same request reached account remove and import, the live account switch, a
308
+ // global `npm i -g`, the restart and the event log.
309
+ //
310
+ // So a mutation must now be either of two things:
311
+ //
312
+ // - the user's own browser tab, recognised by the headers a page cannot
313
+ // forge and the deck's own loopback address (isDeckUiRequest), or
314
+ // - a client holding the deck's token, which it can only have read from the
315
+ // 0600 discovery file (presentsDeckToken).
316
+ //
317
+ // What this does NOT do, stated plainly so nobody mistakes it for more: the
318
+ // browser clause rests on headers that page script cannot set but any local
319
+ // program can. A local attacker who adds `Origin` and `Sec-Fetch-Site` to the
320
+ // request is back through. That is not a gap left by laziness — there is no
321
+ // fix for it here. Authenticating the tab would mean giving the tab a secret,
322
+ // and every channel from this process to a browser on the same machine is
323
+ // readable by any other process on that machine: a token in the served
324
+ // index.html is read by the same `curl http://localhost:4317/` that the gate
325
+ // is meant to stop, and a token in the URL the deck opens is read out of the
326
+ // browser's argv by `ps`. What is closed is the free pass — a request that
327
+ // presents nothing at all no longer changes anything — and what is opened is
328
+ // the honest door, so a script of the user's own authenticates by reading the
329
+ // token instead of impersonating a page.
330
+ /**
331
+ * May this request read the deck's OWN data — the events, the accounts, the
332
+ * browsing episodes?
333
+ *
334
+ * The mutation gate exists because `curl -XPOST localhost:4317/…/admin` handed
335
+ * a live OAuth refresh token to a sandboxed subprocess with loopback egress.
336
+ * The same caller could still `curl localhost:4317/api/events` and read the
337
+ * whole ring — prompt text, the Bash command lines the agent ran, the paths and
338
+ * contents it wrote, the contents of every file it read back — plus the account
339
+ * roster and the browsing episodes. The threat model had been applied to half
340
+ * the surface.
341
+ *
342
+ * Same shape as isAuthorizedMutation, with one difference forced by the
343
+ * browser: a same-origin GET carries no `Origin` header at all, so the UI
344
+ * cannot be recognised the way a POST is. `Sec-Fetch-Site: same-origin` is what
345
+ * a page's own fetch and its EventSource both send, on every browser new enough
346
+ * to run this bundle, and it is a header no non-browser client sends by
347
+ * accident. A caller that sends neither it nor the token is not a page.
348
+ *
349
+ * WHAT STAYS OPEN, deliberately: /api/health (the hook's readiness probe),
350
+ * /api/hook-challenge (the handshake itself), the static files, and every
351
+ * measurement route — the machine panel's numbers are about the machine, not
352
+ * about what the user is doing on it.
353
+ */
354
+ export function isAuthorizedDataRead(req) {
355
+ const headers = req?.headers ?? {};
356
+ if (presentsDeckToken(headers)) return true;
357
+ // Addressed to this machine by a name that can only be this machine — so a
358
+ // rebound page, which also reports same-origin, does not qualify.
359
+ if (!isLoopbackHost(headers.host)) return false;
360
+
361
+ const site = typeof headers["sec-fetch-site"] === "string"
362
+ ? headers["sec-fetch-site"].trim().toLowerCase() : "";
363
+ if (site === "same-origin") return true;
364
+ // ANY FETCH METADATA AT ALL, AND IT SAID SOMETHING ELSE. `cross-site`,
365
+ // `same-site` and `none` are all a page that is not this one — or a top-level
366
+ // navigation typed into the address bar, which has no business reading the
367
+ // ring.
368
+ if (site !== "") return false;
369
+
370
+ // THE BROWSER THAT SENDS NO FETCH METADATA, and this is the whole reason this
371
+ // branch exists. Sec-Fetch-Site is Safari 16.4 and newer; Safari 16.0-16.3
372
+ // runs this bundle perfectly well (Vite's default target is Safari 16) and
373
+ // sends none of it. Without a fallback those users get an empty canvas and a
374
+ // 401 they cannot act on — an impediment for a browser that is otherwise
375
+ // fine.
376
+ //
377
+ // Referer is what they do send, on a page's own fetches and on its
378
+ // EventSource, and it must name THIS origin. A cross-site page's Referer
379
+ // names its own; a rebound page's names the attacker's host, which is not a
380
+ // loopback identity. It is forgeable by a non-browser client — and so is
381
+ // Sec-Fetch-Site, which curl sets as easily; neither is the control that
382
+ // stops a deliberate local caller. That control is the token, and this only
383
+ // decides which BROWSERS are recognised as the deck's own page.
384
+ return originMatchesHost(headers.referer, headers.host);
385
+ }
386
+
387
+ /** The reads that carry the user's own work, rather than the machine's.
388
+ *
389
+ * `/api/lan` and `/api/prefs` were missing, and they are the same class of
390
+ * secret as the four that were here. Measured against a running deck with a
391
+ * request carrying no headers at all — the sandboxed subprocess with loopback
392
+ * egress that isAuthorizedDataRead names above, or another UID on a shared
393
+ * box — `/api/claude-accounts` answered 401 and `/api/lan` answered 200 with:
394
+ *
395
+ * • `shared`, one `<email>@@<organization uuid>` per account this deck
396
+ * offers — the address of every Claude login on the machine;
397
+ * • `peers[].offers.accounts[]`, the same for every paired deck, plus each
398
+ * one's LAN address, port, hostname, OS and ccdeck version;
399
+ * • `invite`, whenever the owner has one open — and that one is a bearer
400
+ * credential, not a description. readInvite -> connectToPeer({code}) ->
401
+ * onInviteUsed pins the caller as trusted with nobody pressing anything,
402
+ * and a trusted deck may then send `manifest` and `want` and receive the
403
+ * sealed OAuth credentials for every shared account.
404
+ *
405
+ * `/api/prefs` carries the narrower half: publicPrefs already strips
406
+ * `lan.secret` and every `trusted[].pub`, which was the part that had to be
407
+ * right, but it keeps `lan.shared` — the same addresses — and every trusted
408
+ * peer's name and fingerprint.
409
+ *
410
+ * Neither route has a caller outside the deck's own page, so guarding them
411
+ * costs nothing: every fetch of both is in src/web, and a page's own GET
412
+ * carries Sec-Fetch-Site: same-origin. */
413
+ export const GUARDED_READS = new Set([
414
+ "/events",
415
+ "/api/events",
416
+ "/api/claude-accounts",
417
+ "/api/claude-accounts/login",
418
+ "/api/browser-watch",
419
+ "/api/lan",
420
+ "/api/prefs",
421
+ // Per-account, per-project token spend — the user's own work, the same class
422
+ // of secret as the accounts list it hangs off.
423
+ "/api/account-projects",
424
+ // Not a secret, and here for the other reason a read can be dangerous: it is
425
+ // the one route where the caller names what the deck goes and fetches
426
+ // (#1208). fm-station.mjs holds that to YouTube, but a page on another site
427
+ // still had a way to make this process download pages on demand, and the
428
+ // only caller that needs it is the deck's own canvas, which sends
429
+ // Sec-Fetch-Site: same-origin on every fetch.
430
+ "/api/fm-station",
431
+ ]);
432
+
433
+ export function isAuthorizedMutation(req) {
434
+ const headers = req?.headers ?? {};
435
+ if (presentsDeckToken(headers)) return true;
436
+ return isDeckUiRequest({
437
+ origin: headers.origin,
438
+ host: headers.host,
439
+ secFetchSite: headers["sec-fetch-site"],
440
+ });
441
+ }
@@ -0,0 +1,180 @@
1
+ // The note a failed npx relaunch leaves for the worker that comes up instead.
2
+ //
3
+ // This lived in src/server/self-update.mjs, between the install policy and the
4
+ // install itself. It is the one channel between two processes — the supervisor
5
+ // in bin/agent-dag.js writes and clears it, the worker's version report reads
6
+ // it — and it touches neither npm nor the registry, so it moved to a module of
7
+ // its own. It shares npm-latest.mjs's directory and its rule for turning a
8
+ // package name into a file name, so the two kinds of file keep agreeing.
9
+ // self-update.mjs re-exports every name, so the supervisor still imports them
10
+ // from there. The code is unchanged.
11
+ import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
12
+ import { dirname, join } from "node:path";
13
+ // The package's one liveness probe, the Windows errno included. self-update.mjs
14
+ // kept a copy of it, comment and all, from before deck-probe.mjs existed.
15
+ import { isProcessAlive } from "./deck-probe.mjs";
16
+ import { PUBLISHED_NAME } from "./install-layout.mjs";
17
+ import { MARKER_DIR, safeNamePart } from "./npm-latest.mjs";
18
+
19
+ // ── the note a failed npx relaunch leaves behind ─────────────────────────────
20
+ //
21
+ // The npx upgrade is the one path whose failure the server cannot see. It runs
22
+ // in the SUPERVISOR, after this process has already exited: the worker asks to
23
+ // come back through `npx -y <spec>@latest`, npx fails, and the supervisor
24
+ // relaunches the copy on disk. The new worker boots knowing nothing, so
25
+ // /api/version kept answering `upgrade: {state:"idle"}` — the banner still
26
+ // offered "Update & restart", the tab said nothing at all, and a user who
27
+ // clicked the button without watching the terminal saw the deck blink and come
28
+ // back unchanged. Every click then repeated the whole cycle identically.
29
+ //
30
+ // A file is the only channel between the two processes: the supervisor writes
31
+ // one when the relaunch fails, and the worker it starts instead reads it here.
32
+ // Same directory as the update markers, and named after the package for the
33
+ // same reason they are.
34
+ //
35
+ // The package name alone was not enough, and one release was enough to show it.
36
+ // Two `npx ccdeck` decks resolve into the same content-addressed _npx directory,
37
+ // so they run the same package at the same version out of the same home: when
38
+ // one user's upgrade failed, the other deck — which had never asked for
39
+ // anything — read that note as its own, reported `upgrade: {state:"failed"}`
40
+ // and labelled its first ever click "Retry update". The version-staleness rule
41
+ // below cannot catch it, because both decks are the same version.
42
+ //
43
+ // So the name carries WHOSE failure it is as well as which package's: the pid
44
+ // of the supervisor that wrote it. That is unique among the decks alive on the
45
+ // machine, and it crosses the process boundary on its own — the worker that
46
+ // reads the note is a child the supervisor spawns after the failure, and
47
+ // inherits the pid through AGENTS_DECK_SUPERVISOR_PID. A worker with no
48
+ // supervisor to answer for reads nothing rather than falling back to a shared
49
+ // file, which is the bug this whole naming exists to avoid.
50
+ const NOTE_PREFIX = ".restart-failed-";
51
+
52
+ /** Which supervisor a note belongs to, as the file name may spell it. Digits
53
+ * and nothing else: legal on every filesystem, and readable back as the pid
54
+ * the sweep below asks about. Anything else is refused rather than scrubbed
55
+ * into digits, since two keys must never collapse into one file name. */
56
+ function safeOwner(key) {
57
+ const raw = String(key ?? "").trim();
58
+ return /^\d{1,12}$/.test(raw) && Number(raw) > 0 ? raw : null;
59
+ }
60
+
61
+ /** This process's own key, set by the supervisor on itself and inherited by
62
+ * every worker it launches. */
63
+ export function restartFailureKey(env = process.env) {
64
+ return safeOwner(env?.AGENTS_DECK_SUPERVISOR_PID);
65
+ }
66
+
67
+ /** Called once by the supervisor, on its own environment, which every worker it
68
+ * spawns then inherits. Assigned rather than defaulted: a deck launched by
69
+ * another deck's npx relaunch inherits that supervisor's key and must answer
70
+ * for itself, not for the parent whose upgrade it is the result of. */
71
+ export function claimRestartFailureKey(env = process.env, pid = process.pid) {
72
+ env.AGENTS_DECK_SUPERVISOR_PID = String(pid);
73
+ return safeOwner(pid);
74
+ }
75
+
76
+ /** `ccdeck` under supervisor 4821 → `.restart-failed-ccdeck-4821`, or null when
77
+ * there is no supervisor, which is not a deck any note can be about. */
78
+ export function restartFailureFileName(name = PUBLISHED_NAME, key = restartFailureKey()) {
79
+ const owner = safeOwner(key);
80
+ return owner ? `${NOTE_PREFIX}${safeNamePart(name)}-${owner}` : null;
81
+ }
82
+
83
+ function restartFailurePath(name, key) {
84
+ const file = restartFailureFileName(name, key);
85
+ return file ? join(MARKER_DIR, file) : null;
86
+ }
87
+
88
+ /** Called by the supervisor when an upgrade does not happen — because the fetch
89
+ * failed, because the fetched copy never served, or because this target has
90
+ * already failed here and is not being tried again. Best-effort: a read-only
91
+ * home costs the report, not the deck. */
92
+ export function recordRestartFailure({
93
+ name = PUBLISHED_NAME, command = null, error = null, version = null,
94
+ target = null, attempts = 1, at = Date.now(), failedAt = at, key = restartFailureKey(),
95
+ } = {}) {
96
+ const file = restartFailureFileName(name, key);
97
+ if (!file) return;
98
+ const path = join(MARKER_DIR, file);
99
+ try {
100
+ mkdirSync(dirname(path), { recursive: true });
101
+ writeFileSync(path, JSON.stringify({
102
+ command,
103
+ error: error ? String(error).slice(0, 300) : null,
104
+ // The version that failed to leave — see restartFailureNotice.
105
+ version,
106
+ // The version that failed to ARRIVE, and how many attempts it has cost.
107
+ // Together they are the whole of what stops an unattended deck retrying
108
+ // the identical fetch forever; see upgradeAttempt in supervisor.mjs.
109
+ target,
110
+ attempts,
111
+ // When the fetch itself failed, which a refusal re-stating that failure
112
+ // carries forward unchanged. `at` moves on every write because the
113
+ // browser ends its attempt on a note it has not seen before — the
114
+ // cooldown must not be pushed out by the act of asking about it.
115
+ failedAt,
116
+ at,
117
+ }));
118
+ sweepOrphanedNotes(file);
119
+ } catch { /* ignore */ }
120
+ }
121
+
122
+ /** Called before each attempt, so a retry is answered by its own outcome rather
123
+ * than by the last one's. */
124
+ export function clearRestartFailure(name = PUBLISHED_NAME, key = restartFailureKey()) {
125
+ const path = restartFailurePath(name, key);
126
+ if (!path) return;
127
+ try { rmSync(path, { force: true }); } catch { /* ignore */ }
128
+ }
129
+
130
+ export function readRestartFailure(name = PUBLISHED_NAME, key = restartFailureKey()) {
131
+ const path = restartFailurePath(name, key);
132
+ if (!path) return null;
133
+ try {
134
+ const m = JSON.parse(readFileSync(path, "utf8"));
135
+ return m && typeof m === "object" ? m : null;
136
+ } catch {
137
+ return null;
138
+ }
139
+ }
140
+
141
+ // A note is named after a process, so it outlives its deck whenever that
142
+ // supervisor is killed before anyone reads the tab, and nothing would ever
143
+ // delete it: the retry that clears one is exactly the thing that never happened.
144
+ // Swept from the only path that creates notes, and only where the owner is
145
+ // provably gone — another deck's note is another deck's to clear. Names without
146
+ // a pid are the single shared file of v1.33.82, which nothing reads any more.
147
+ function sweepOrphanedNotes(keep) {
148
+ let files;
149
+ try { files = readdirSync(MARKER_DIR); } catch { return; }
150
+ for (const f of files) {
151
+ if (f === keep || !f.startsWith(NOTE_PREFIX)) continue;
152
+ const owner = Number(f.slice(f.lastIndexOf("-") + 1));
153
+ if (Number.isInteger(owner) && owner > 0 && isProcessAlive(owner)) continue;
154
+ try { rmSync(join(MARKER_DIR, f), { force: true }); } catch { /* ignore */ }
155
+ }
156
+ }
157
+
158
+ /**
159
+ * The note as the version report should carry it, or null when it no longer
160
+ * describes this deck. Pure, because the staleness rule is the whole subtlety.
161
+ * WHOSE failure it is was settled by the file name; this decides only whether
162
+ * it is still current.
163
+ *
164
+ * The note names the version that was running when the upgrade failed. While
165
+ * that is still the version on disk, the failure is current: the deck really is
166
+ * stuck where it was. Once the files are a different version the upgrade
167
+ * happened some other way — a `npm i -g`, a fixed npm prefix, a manual npx —
168
+ * and a note about a deck that no longer exists must not keep claiming the
169
+ * update is broken.
170
+ */
171
+ export function restartFailureNotice(record, installed = null) {
172
+ if (!record || typeof record.error !== "string" || !record.error) return null;
173
+ if (record.version && installed && record.version !== installed) return null;
174
+ return {
175
+ state: "failed",
176
+ command: typeof record.command === "string" ? record.command : null,
177
+ error: record.error,
178
+ at: typeof record.at === "number" ? record.at : 0,
179
+ };
180
+ }