ccdeck 3.29.11 → 3.30.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 +1 -1
- 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-DP3YMtEi.js +1 -0
- package/dist/web/assets/UsageHistoryModal-CcdPVXAw.js +1 -0
- package/dist/web/assets/index-3vS6A0vt.css +1 -0
- package/dist/web/assets/index-BVTWHDta.js +298 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +15 -14
- package/package.json +1 -1
- package/release-notes.json +22 -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 +2 -2
- 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 +1 -1
- package/src/server/deck-prefs.mjs +51 -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 +172 -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 +108 -0
- package/src/server/prefs-path.mjs +14 -0
- package/src/server/prefs-refusal.mjs +77 -0
- package/src/server/prefs-routes.mjs +76 -0
- package/src/server/prefs-set-aside.mjs +86 -0
- package/src/server/prefs-state.mjs +51 -0
- package/src/server/process-command.mjs +104 -0
- package/src/server/process-list.mjs +547 -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 +93 -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
|
@@ -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`
|
|
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`
|
|
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
|
+
}
|