ccdeck 3.29.11 → 3.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/README.md +4 -3
  2. package/bin/agent-dag.js +67 -118
  3. package/bin/cli/help.js +67 -0
  4. package/bin/cli/login-item.js +182 -0
  5. package/bin/cli/one-shot.js +254 -0
  6. package/bin/cli/package.js +39 -0
  7. package/bin/cli/pulse.js +98 -0
  8. package/bin/cli/restart.js +179 -0
  9. package/bin/cli/screen.js +166 -0
  10. package/bin/cli/second-start.js +120 -0
  11. package/bin/cli/startup.js +459 -0
  12. package/bin/cli/uninstall.js +169 -0
  13. package/bin/deck.js +112 -1559
  14. package/dist/web/assets/BrowserWatchModal-n63GjLhv.js +1 -0
  15. package/dist/web/assets/UsageHistoryModal-DEwBX4Fk.js +1 -0
  16. package/dist/web/assets/index-CZJf1Cpp.js +299 -0
  17. package/dist/web/assets/index-D4q1ElXU.css +1 -0
  18. package/dist/web/index.html +2 -2
  19. package/hook/hook.js +15 -14
  20. package/package.json +1 -1
  21. package/release-notes.json +40 -0
  22. package/src/server/account-projects.mjs +25 -11
  23. package/src/server/account-routes.mjs +254 -0
  24. package/src/server/activity.mjs +3 -3
  25. package/src/server/append-budget.mjs +185 -0
  26. package/src/server/append-failures.mjs +147 -0
  27. package/src/server/args.mjs +37 -5
  28. package/src/server/atomic-write.mjs +300 -0
  29. package/src/server/block-notify.mjs +0 -5
  30. package/src/server/boot-lock.mjs +1 -1
  31. package/src/server/brand.mjs +7 -6
  32. package/src/server/browser-react.mjs +10 -2
  33. package/src/server/browser-watch-decks.mjs +146 -0
  34. package/src/server/browser-watch-feed.mjs +69 -0
  35. package/src/server/browser-watch-log.mjs +173 -0
  36. package/src/server/browser-watch-mtime.mjs +18 -0
  37. package/src/server/browser-watch-relay.mjs +112 -0
  38. package/src/server/browser-watch-routes.mjs +172 -0
  39. package/src/server/browser-watch-seen.mjs +127 -0
  40. package/src/server/browser-watch-store.mjs +14 -143
  41. package/src/server/browser-watch.mjs +278 -626
  42. package/src/server/canonical-path.mjs +150 -0
  43. package/src/server/ccusage-failure.mjs +64 -0
  44. package/src/server/ccusage-install.mjs +459 -0
  45. package/src/server/ccusage-runner.mjs +271 -0
  46. package/src/server/ccusage.mjs +21 -756
  47. package/src/server/claude-accounts.mjs +220 -464
  48. package/src/server/claude-collector.mjs +189 -0
  49. package/src/server/claude-dir.mjs +7 -3
  50. package/src/server/claude-identity.mjs +5 -4
  51. package/src/server/claude-reset-credits.mjs +130 -0
  52. package/src/server/claude-verdicts.mjs +174 -0
  53. package/src/server/clear-route.mjs +191 -0
  54. package/src/server/codex-auth.mjs +4 -4
  55. package/src/server/codex-dir.mjs +52 -8
  56. package/src/server/codex-enrichment.mjs +310 -0
  57. package/src/server/codex-quota.mjs +5 -3
  58. package/src/server/codex-translate.mjs +407 -0
  59. package/src/server/codex-usage.mjs +8 -5
  60. package/src/server/codex-watch.mjs +496 -0
  61. package/src/server/cswap-auto-loop.mjs +342 -0
  62. package/src/server/cswap-auto-readers.mjs +303 -0
  63. package/src/server/cswap-auto.mjs +51 -609
  64. package/src/server/cswap-install.mjs +69 -265
  65. package/src/server/cswap-layout.mjs +242 -0
  66. package/src/server/deck-home.mjs +2 -2
  67. package/src/server/deck-prefs.mjs +58 -184
  68. package/src/server/deck-scope.mjs +52 -0
  69. package/src/server/desktop-update-routes.mjs +75 -0
  70. package/src/server/detach.mjs +78 -0
  71. package/src/server/discovery.mjs +298 -0
  72. package/src/server/event-log.mjs +328 -0
  73. package/src/server/event-pipeline.mjs +235 -0
  74. package/src/server/event-ring.mjs +214 -0
  75. package/src/server/event-routes.mjs +352 -0
  76. package/src/server/event-sink.mjs +26 -0
  77. package/src/server/exec-children.mjs +128 -0
  78. package/src/server/exec-not-found.mjs +213 -0
  79. package/src/server/exec-output.mjs +23 -0
  80. package/src/server/exec-spec.mjs +341 -0
  81. package/src/server/exec.mjs +74 -661
  82. package/src/server/health-route.mjs +42 -0
  83. package/src/server/hook-challenge.mjs +37 -0
  84. package/src/server/http-io.mjs +104 -0
  85. package/src/server/index.mjs +174 -7620
  86. package/src/server/install-layout.mjs +506 -0
  87. package/src/server/installer.mjs +79 -608
  88. package/src/server/jsonl-chunks.mjs +139 -0
  89. package/src/server/lan-accounts.mjs +27 -0
  90. package/src/server/lan-beacon.mjs +457 -0
  91. package/src/server/lan-call.mjs +239 -0
  92. package/src/server/lan-copies.mjs +270 -0
  93. package/src/server/lan-deck.mjs +583 -0
  94. package/src/server/lan-dials.mjs +161 -0
  95. package/src/server/lan-engine.mjs +475 -1024
  96. package/src/server/lan-hearing.mjs +49 -0
  97. package/src/server/lan-inbound.mjs +100 -0
  98. package/src/server/lan-invite-offer.mjs +77 -0
  99. package/src/server/lan-invite.mjs +207 -0
  100. package/src/server/lan-lines.mjs +73 -0
  101. package/src/server/lan-manifest.mjs +74 -0
  102. package/src/server/lan-reach.mjs +82 -69
  103. package/src/server/lan-requests.mjs +249 -0
  104. package/src/server/lan-round-record.mjs +45 -0
  105. package/src/server/lan-round-timer.mjs +62 -0
  106. package/src/server/lan-routes.mjs +143 -0
  107. package/src/server/lan-serve.mjs +112 -0
  108. package/src/server/lan-socket.mjs +243 -925
  109. package/src/server/lan-sync.mjs +58 -1026
  110. package/src/server/lan-tailnet-poll.mjs +57 -0
  111. package/src/server/lan-turns.mjs +80 -0
  112. package/src/server/lan-wire.mjs +620 -0
  113. package/src/server/lifecycle.mjs +421 -0
  114. package/src/server/listen.mjs +110 -0
  115. package/src/server/live-decks.mjs +138 -0
  116. package/src/server/load-average.mjs +32 -0
  117. package/src/server/log-election.mjs +235 -0
  118. package/src/server/log-replay.mjs +374 -0
  119. package/src/server/log-writer.mjs +72 -557
  120. package/src/server/login-service.mjs +3 -2
  121. package/src/server/macmon.mjs +1 -1
  122. package/src/server/memory-files.mjs +163 -0
  123. package/src/server/memory-metrics.mjs +178 -0
  124. package/src/server/metrics-history.mjs +94 -0
  125. package/src/server/metrics-run.mjs +100 -0
  126. package/src/server/music-routes.mjs +134 -0
  127. package/src/server/network-metrics.mjs +5 -3
  128. package/src/server/network-sampler.mjs +256 -0
  129. package/src/server/npm-latest.mjs +385 -0
  130. package/src/server/npm-upgrade.mjs +352 -0
  131. package/src/server/npx.mjs +41 -7
  132. package/src/server/output-watch.mjs +5 -4
  133. package/src/server/pinned-build.mjs +110 -0
  134. package/src/server/prefs-give-back.mjs +206 -0
  135. package/src/server/prefs-path.mjs +14 -0
  136. package/src/server/prefs-refusal.mjs +86 -0
  137. package/src/server/prefs-routes.mjs +99 -0
  138. package/src/server/prefs-set-aside.mjs +86 -0
  139. package/src/server/prefs-state.mjs +70 -0
  140. package/src/server/process-command.mjs +104 -0
  141. package/src/server/process-list.mjs +547 -0
  142. package/src/server/provider-status.mjs +307 -0
  143. package/src/server/pulse-line.mjs +132 -0
  144. package/src/server/purge-key.mjs +7 -7
  145. package/src/server/quota-cli.mjs +174 -0
  146. package/src/server/quota-oauth.mjs +190 -0
  147. package/src/server/quota-shape.mjs +229 -0
  148. package/src/server/quota-store-resets.mjs +206 -0
  149. package/src/server/quota.mjs +123 -509
  150. package/src/server/relay-guard.mjs +3 -3
  151. package/src/server/request-gates.mjs +441 -0
  152. package/src/server/restart-note.mjs +180 -0
  153. package/src/server/retire-sound-hook.mjs +3 -3
  154. package/src/server/ring-bounds.mjs +215 -0
  155. package/src/server/running-deck.mjs +1 -1
  156. package/src/server/running-version.mjs +24 -0
  157. package/src/server/self-update.mjs +39 -1371
  158. package/src/server/session-enrichment.mjs +622 -0
  159. package/src/server/session-read-gate.mjs +49 -0
  160. package/src/server/session-tracking.mjs +242 -0
  161. package/src/server/sse-clients.mjs +222 -0
  162. package/src/server/stale-discovery.mjs +30 -0
  163. package/src/server/static-serve.mjs +81 -0
  164. package/src/server/supervisor.mjs +73 -12
  165. package/src/server/system-metrics.mjs +169 -1935
  166. package/src/server/term.mjs +11 -249
  167. package/src/server/thermal-metrics.mjs +520 -0
  168. package/src/server/thermal-sampler.mjs +204 -0
  169. package/src/server/token-redact.mjs +123 -0
  170. package/src/server/transcript-gate.mjs +107 -0
  171. package/src/server/transcript-scan.mjs +563 -0
  172. package/src/server/usage-routes.mjs +108 -0
  173. package/src/server/uv-bootstrap.mjs +1 -1
  174. package/src/server/wordmark.mjs +127 -0
  175. package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
  176. package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
  177. package/dist/web/assets/index-Ck2RAla3.css +0 -1
  178. package/dist/web/assets/index-jmrcEsCj.js +0 -298
@@ -0,0 +1,206 @@
1
+ // Giving the deck's own folders back to the person running it, from the panel
2
+ // (#1711).
3
+ //
4
+ // #1335 taught the deck to say, precisely, that its settings folder belongs to
5
+ // another user — which is what a `sudo ccdeck` leaves on a Mac, where sudo keeps
6
+ // HOME. It could not do more: the folder is root's, and only root can give it
7
+ // back. So the way out was a `sudo chown -R` line in a log the person reporting
8
+ // it did not know how to find, and until they ran it every settings write was
9
+ // refused, the share tick included.
10
+ //
11
+ // On macOS the deck can ask for that root itself, through the operating
12
+ // system's own password dialog. That makes this the one place ccdeck acts with
13
+ // administrator rights, and everything below is about keeping it that narrow:
14
+ //
15
+ // THE PATHS ARE THE DECK'S OWN. Computed here, from the same functions that
16
+ // decide where the deck keeps its things. Nothing the request carries reaches
17
+ // the command; the route takes no body at all.
18
+ //
19
+ // EVERY TARGET IS CHECKED BEFORE THE DIALOG. A real directory, not a symlink,
20
+ // named like one of the deck's, strictly inside the user's home both as
21
+ // written and after realpath, and owned by somebody else. A folder that is
22
+ // already the user's is left out, so the dialog never appears for nothing.
23
+ //
24
+ // THE PATHS ARE DATA, NOT SOURCE. They reach AppleScript as argv, after `--`
25
+ // (see notify in browser-react.mjs for what a leading dash did without it),
26
+ // and reach the shell through `quoted form of`. The script text is a
27
+ // constant, and osascript is named by its full path.
28
+ //
29
+ // THE FOLDER ROOT CHANGES IS THE FOLDER THAT WAS CHECKED. The checks run
30
+ // before the dialog and root acts after it, minutes later if the person is
31
+ // slow, and the parent folder is the user's: anything running as the user
32
+ // could put a folder of its own in place of the checked one, holding a hard
33
+ // link to a file root owns, and `chown -R` would hand that file over. So the
34
+ // root step does not trust the path. It enters the folder, compares the
35
+ // device and inode of where it landed with the ones surveyed here, refuses
36
+ // a tree holding any file with a second hard link, and only then changes
37
+ // `.`, which a rename of the path can no longer move.
38
+ //
39
+ // `chown -n -x -R -P`: numeric ids with no name lookup, no mount point
40
+ // crossed, and no symlink inside the tree followed.
41
+ import { lstat, realpath } from "node:fs/promises";
42
+ import { homedir } from "node:os";
43
+ // POSIX, not the host's: every path here is a Mac's, and the suite checks
44
+ // them on Windows too, where the host's `normalize` would turn every slash
45
+ // into a backslash and find nothing inside any home (see deckDataDir).
46
+ import { posix } from "node:path";
47
+ import { PRODUCT } from "./brand.mjs";
48
+ import { deckDataDir, deckLogDir, legacyDeckDir } from "./deck-home.mjs";
49
+ import { run } from "./exec.mjs";
50
+ import { prefsDir } from "./prefs-path.mjs";
51
+
52
+ /** The folder names the deck files itself under. A CCDECK_HOME named anything
53
+ * else is somebody's own choice of place, and the log's command is still there
54
+ * for it; the deck does not take root to change the owner of it. */
55
+ const DECK_FOLDER_NAMES = new Set(["ccdeck", "agent-dag"]);
56
+
57
+ /** How long the password dialog may stay open. It waits on a person, so the
58
+ * exec default of twenty seconds would close it under them. */
59
+ export const GIVE_BACK_TIMEOUT_MS = 180_000;
60
+
61
+ /** What the dialog says it is for, above macOS's own "wants to make changes". */
62
+ export const GIVE_BACK_PROMPT =
63
+ `${PRODUCT} needs your password to make its settings folder yours again. It was left owned by another user, most likely by a run with sudo.`;
64
+
65
+ /** osascript, by its full path: this is the one call that asks for the
66
+ * administrator's password, and a PATH lookup is a way to put a different
67
+ * dialog in front of it. */
68
+ export const OSASCRIPT = "/usr/bin/osascript";
69
+
70
+ /** The whole of what runs as root. Item 1 is `uid:gid`, item 2 the prompt, and
71
+ * after them a folder and its `device:inode` for each folder; every one is
72
+ * quoted by AppleScript, never by hand. A folder that is not the one surveyed,
73
+ * or that holds a file with a second hard link, stops the whole command with
74
+ * status 3 before anything in it changes owner. */
75
+ export const GIVE_BACK_SCRIPT = [
76
+ "on run argv",
77
+ "set owner to quoted form of (item 1 of argv)",
78
+ 'set cmd to "true"',
79
+ "repeat with i from 3 to (count of argv) by 2",
80
+ "set target to quoted form of (item i of argv)",
81
+ "set wanted to quoted form of (item (i + 1) of argv)",
82
+ 'set cmd to cmd & " && { cd -P -- " & target & " && [ \\"$(/usr/bin/stat -f %d:%i .)\\" = " & wanted & " ] && [ -z \\"$(/usr/bin/find -x -P . ! -type d -links +1 -print)\\" ] || exit 3; } && /usr/sbin/chown -n -x -R -P -- " & owner & " ."',
83
+ "end repeat",
84
+ "do shell script cmd with prompt (item 2 of argv) with administrator privileges",
85
+ "end run",
86
+ ].join("\n");
87
+
88
+ /** osascript's "User canceled." — the person closed the dialog. */
89
+ const CANCELLED = /\(-128\)/;
90
+ /** The root step's own refusal: the folder was not the one surveyed, or held a
91
+ * hard link. osascript reports the shell's status in brackets. */
92
+ const GUARD_TRIPPED = /\(3\)\s*$/m;
93
+
94
+ /**
95
+ * Which of the deck's folders belong to somebody else, and whether each one is
96
+ * safe to hand back.
97
+ *
98
+ * The settings folder is the one the refusal was about, so it comes first and
99
+ * its verdict decides the answer. The log folder and the legacy folder are
100
+ * where the same sudo run wrote too — the event log, the hook, the port
101
+ * registry — and are given back in the same dialog when they need it, or
102
+ * skipped when they do not exist or already belong to the user.
103
+ */
104
+ export function deckFolders(platform = process.platform, env = process.env, home = homedir()) {
105
+ return [prefsDir(deckDataDir(platform, env, home)), deckLogDir(platform, env, home), legacyDeckDir(env, home, platform)]
106
+ .filter((path, i, all) => all.indexOf(path) === i);
107
+ }
108
+
109
+ async function survey(path, { uid, home, fs }) {
110
+ let st;
111
+ try {
112
+ // BigInt, because the inode is handed to the root step to compare with
113
+ // stat(1)'s, and a number past 2^53 would compare unequal to itself.
114
+ st = await fs.lstat(path, { bigint: true });
115
+ } catch (err) {
116
+ return { path, state: err?.code === "ENOENT" ? "missing" : "unsafe" };
117
+ }
118
+ if (Number(st.uid) === uid) return { path, state: "yours" };
119
+ if (!st.isDirectory() || st.isSymbolicLink()) return { path, state: "unsafe" };
120
+ if (!DECK_FOLDER_NAMES.has(basename(path))) return { path, state: "unsafe" };
121
+ if (!strictlyInside(path, home)) return { path, state: "unsafe" };
122
+ const real = await fs.realpath(path).catch(() => "");
123
+ const realHome = await fs.realpath(home).catch(() => "");
124
+ if (!real || !realHome || !strictlyInside(real, realHome)) return { path, state: "unsafe" };
125
+ return { path, state: "foreign", id: `${st.dev}:${st.ino}` };
126
+ }
127
+
128
+ const { basename, isAbsolute, normalize, sep } = posix;
129
+
130
+ function strictlyInside(path, home) {
131
+ if (!isAbsolute(path) || !isAbsolute(home) || normalize(path) !== path) return false;
132
+ const root = home.endsWith(sep) ? home : home + sep;
133
+ return path.startsWith(root) && path.length > root.length;
134
+ }
135
+
136
+ let _inFlight = false;
137
+
138
+ /**
139
+ * Ask macOS for the password, and give the deck's folders back to this user.
140
+ *
141
+ * Answers `{ ok: true, changed }` — `changed: false` when nothing needed it,
142
+ * which is also what a folder somebody already fixed by hand looks like — or
143
+ * `{ ok: false, reason }` from a closed set, because the route hands it to a
144
+ * page and a path names the user:
145
+ *
146
+ * unsupported not macOS, or no uid to compare, or running as root already
147
+ * busy a dialog from an earlier press is still open
148
+ * not_eligible the settings folder failed one of the checks above
149
+ * cancelled the person closed the dialog
150
+ * timed_out nobody answered it
151
+ * refused macOS or chown said no; the log has what it said
152
+ * still_foreign chown answered and the folder is still not theirs
153
+ */
154
+ export async function giveBackDeckFolders(deps = {}) {
155
+ const platform = deps.platform ?? process.platform;
156
+ if (platform !== "darwin") return { ok: false, reason: "unsupported" };
157
+ const uid = deps.getuid ? deps.getuid() : process.getuid?.();
158
+ const gid = deps.getgid ? deps.getgid() : process.getgid?.();
159
+ if (uid === undefined || gid === undefined || uid === 0) return { ok: false, reason: "unsupported" };
160
+ if (_inFlight) return { ok: false, reason: "busy" };
161
+ _inFlight = true;
162
+ try {
163
+ return await giveBack({ ...deps, platform, uid, gid });
164
+ } finally {
165
+ _inFlight = false;
166
+ }
167
+ }
168
+
169
+ async function giveBack({ platform, uid, gid, env = process.env, home = homedir(), fs = { lstat, realpath }, exec = run, warn = console.error }) {
170
+ const folders = await Promise.all(deckFolders(platform, env, home).map(path => survey(path, { uid, home, fs })));
171
+ const [settings] = folders;
172
+ if (settings.state === "unsafe") {
173
+ warn(`${PRODUCT}: ${settings.path} is not a folder this deck will change the owner of from the panel — it has to be a real folder named like the deck's, inside your home. Give it back by hand: sudo chown -R "$(id -un)" "${settings.path}"`);
174
+ return { ok: false, reason: "not_eligible" };
175
+ }
176
+ const foreign = folders.filter(f => f.state === "foreign");
177
+ if (!foreign.length) return { ok: true, changed: false };
178
+
179
+ const r = await exec(OSASCRIPT, [
180
+ "-e", GIVE_BACK_SCRIPT, "--", `${uid}:${gid}`, GIVE_BACK_PROMPT, ...foreign.flatMap(f => [f.path, f.id]),
181
+ ], { timeout: GIVE_BACK_TIMEOUT_MS });
182
+ const said = (r?.stderr || r?.code || "osascript failed").toString().trim();
183
+ if (!r?.ok && !r?.timedOut && CANCELLED.test(r?.stderr ?? "")) return { ok: false, reason: "cancelled" };
184
+
185
+ // WHO OWNS THEM NOW, WHATEVER osascript ANSWERED. One command gives every
186
+ // folder back, so a failure on the log folder fails the exit after the
187
+ // settings folder was already changed, and a password typed just before the
188
+ // deadline can land after it. Answering that as a refusal would leave the
189
+ // deck on defaults with its writes now succeeding.
190
+ const owners = await Promise.all(foreign.map(f => fs.lstat(f.path).then(st => Number(st.uid), () => undefined)));
191
+ const left = foreign.filter((_, i) => owners[i] !== uid).map(f => f.path);
192
+ const settingsGiven = settings.state === "foreign" && !left.includes(settings.path);
193
+ if (!left.length || settingsGiven) {
194
+ if (left.length) warn(`${PRODUCT}: the settings folder is yours again, but ${left.join(", ")} still belongs to another user: ${said}`);
195
+ return { ok: true, changed: true };
196
+ }
197
+ if (r?.timedOut) return { ok: false, reason: "timed_out" };
198
+ if (!r?.ok) {
199
+ warn(GUARD_TRIPPED.test(r?.stderr ?? "")
200
+ ? `${PRODUCT}: did not give ${left.join(", ")} back: the folder changed between the check and the password, or holds a file with a second hard link. Nothing in it changed owner.`
201
+ : `${PRODUCT}: could not give ${left.join(", ")} back: ${said}`);
202
+ return { ok: false, reason: "refused" };
203
+ }
204
+ warn(`${PRODUCT}: chown answered, but ${left.join(", ")} still belongs to another user.`);
205
+ return { ok: false, reason: "still_foreign" };
206
+ }
@@ -0,0 +1,14 @@
1
+ // Where prefs.json lives. Its own module because two do the arithmetic:
2
+ // deck-prefs.mjs, which reads and writes the file, and the half that moves a
3
+ // file nobody can use out of its way.
4
+ import { join } from "node:path";
5
+ import { deckDataDir } from "./deck-home.mjs";
6
+
7
+ /* WHERE THIS FILE LIVES, AND WHY IT MOVED. It sat in ~/.claude/agent-dag — the
8
+ directory Claude Code owns — which meant a person clearing Claude Code's
9
+ configuration cleared this deck's private key, and every machine that had
10
+ pinned it had to be told to trust this one again. deck-home.mjs owns the new
11
+ answer and the reasons; what matters here is that the parameter is still a
12
+ DIRECTORY, so every caller that passes one is unchanged. */
13
+ export const prefsDir = (home = deckDataDir()) => home;
14
+ export const prefsPath = (home = deckDataDir()) => join(prefsDir(home), "prefs.json");
@@ -0,0 +1,86 @@
1
+ // How the deck says it will not write prefs.json, and why.
2
+ //
3
+ // Moved out of deck-prefs.mjs whole: the error writePrefs throws rather than
4
+ // merge onto a base it knows is not the user's, and the two readings of it the
5
+ // route hands the panel (#1335) — a reason from a closed set, and the detail,
6
+ // every field of which is picked from a closed set too, because a route's body
7
+ // is readable by a DNS-rebound page and a path names the user.
8
+ import { PRODUCT } from "./brand.mjs";
9
+
10
+ /** The refusal `writePrefs` throws rather than merge onto a base it knows is
11
+ * not the user's. Shaped like installer.mjs's SETTINGS_UNREADABLE, which is
12
+ * the same policy on the other file this deck rewrites: a file we cannot
13
+ * reproduce is never treated as an empty one. */
14
+ export function unreadablePrefs(path, why, blocked) {
15
+ const err = new Error(
16
+ `${path} could not be read (${why}). Refusing to overwrite it — this deck's ` +
17
+ `LAN key and its pairings are in there and cannot be re-derived. Fix the ` +
18
+ `file or move it aside, then restart ${PRODUCT}.`,
19
+ );
20
+ err.code = "PREFS_UNREADABLE";
21
+ err.prefsPath = path;
22
+ err.why = why;
23
+ err.blocked = blocked;
24
+ return err;
25
+ }
26
+
27
+ /** The errors a filesystem raises for a place this user may not write — most
28
+ * often a settings folder a `sudo` run left owned by root (#1335). */
29
+ const NOT_WRITABLE = new Set(["EACCES", "EPERM", "EROFS"]);
30
+
31
+ /**
32
+ * Why a settings write failed, as a reason the panel can name — or null when
33
+ * the failure is not one of the two a person can fix from outside the deck.
34
+ *
35
+ * A code, never the message: the message carries the absolute path, and a
36
+ * route's body is readable by a DNS-rebound page (see sendInternalError). The
37
+ * deck's log keeps the path for the person who has to go and look.
38
+ */
39
+ export function prefsWriteRefusal(err) {
40
+ if (err?.code === "PREFS_UNREADABLE") return "prefs_unreadable";
41
+ if (NOT_WRITABLE.has(err?.code)) return "prefs_not_writable";
42
+ return null;
43
+ }
44
+
45
+ /** What blocked a prefs.json that is not JSON and could not be moved aside —
46
+ * no errno for it, so a name of the deck's own in the same shape. */
47
+ export const NOT_JSON = Object.freeze({ code: "BADJSON", owner: "unknown", on: "file" });
48
+
49
+ const OWNERS = new Set(["you", "other", "unknown"]);
50
+ /** An errno as Node spells one, or the deck's BADJSON. Anything else — which
51
+ * nothing here produces — is dropped rather than echoed to the page. */
52
+ const ERRNO = /^E?[A-Z][A-Z0-9]{1,15}$/;
53
+
54
+ /**
55
+ * The same refusal, told precisely enough to act on from a screenshot (#1335).
56
+ *
57
+ * 3.29.3's panel could only guess — "the file may belong to another user" — and
58
+ * the one Mac it was written for turned out not to be the case 3.29.4 repairs,
59
+ * with nothing on the screen to say which case it was. So the page now gets the
60
+ * errno, who owns what blocked the read (`you`, `other`, `unknown`), and whether
61
+ * that is the `file` or its `folder`.
62
+ *
63
+ * Every field is picked from a closed set. Still no path and no uid: the route's
64
+ * body is readable by a DNS-rebound page (see prefsWriteRefusal), and a path
65
+ * names the user. Null when there is nothing precise to add. `fix` is present
66
+ * only when the panel can offer the repair itself — see givesBack.
67
+ */
68
+ export function prefsRefusalDetail(err, platform = process.platform) {
69
+ const blocked = err?.code === "PREFS_UNREADABLE" ? err.blocked
70
+ : NOT_WRITABLE.has(err?.code) ? { code: err.code, owner: "unknown", on: "folder" }
71
+ : null;
72
+ if (!blocked || typeof blocked !== "object") return null;
73
+ const detail = {
74
+ code: ERRNO.test(blocked.code ?? "") ? blocked.code : "",
75
+ owner: OWNERS.has(blocked.owner) ? blocked.owner : "unknown",
76
+ on: blocked.on === "folder" ? "folder" : "file",
77
+ };
78
+ return givesBack(detail, platform) ? { ...detail, fix: "give_back" } : detail;
79
+ }
80
+
81
+ /** The one refusal the panel can repair with a press (#1711): a settings folder
82
+ * another user owns, on the platform whose password dialog can give it back.
83
+ * Everywhere else the log's command is still the way out. */
84
+ function givesBack({ owner, on }, platform) {
85
+ return platform === "darwin" && owner === "other" && on === "folder";
86
+ }
@@ -0,0 +1,99 @@
1
+ // The deck's settings routes: GET /api/prefs, which answers what a page may see
2
+ // of them and what the machine allows, and POST /api/prefs, which writes a
3
+ // patch and hands the result to the LAN engine.
4
+ //
5
+ // These lived in src/server/index.mjs, beside the desktop notifier that reads
6
+ // the same settings, and moved once the settings had (prefs-state.mjs) and the
7
+ // engine had (lan-deck.mjs): what is left of them is a body read, a write
8
+ // through the one, a push to the other, and an answer. The notifier stays in
9
+ // index.mjs, beside the desktop-app connections it notifies through.
10
+ import { notificationsOn, notificationsVetoed, prefsRefusalDetail, prefsWriteRefusal, publicPrefs } from "./deck-prefs.mjs";
11
+ import { PRODUCT } from "./brand.mjs";
12
+ import { heldPrefs } from "./prefs-state.mjs";
13
+ import { applyLanPrefs, forgetReach, resetLanLoaded } from "./lan-deck.mjs";
14
+ import { giveBackDeckFolders } from "./prefs-give-back.mjs";
15
+ import { readBody, send } from "./http-io.mjs";
16
+
17
+ /**
18
+ * GET the deck's settings, and what the machine is allowing.
19
+ *
20
+ * THREE fields, not two, and the third is the one a first attempt got wrong.
21
+ * `notificationsAllowed` is the effective answer, after the environment
22
+ * variable has had its say — but "false" there means either "the user switched
23
+ * it off" or "the machine forbids it", and the menu has to say a different
24
+ * sentence for each. Deriving the second from the first made the switch read
25
+ * "off — set at launch" the moment anybody turned it off on a deck launched
26
+ * with no variable at all, which is the deck telling the user their own press
27
+ * was somebody else's doing. `notificationsVetoed` answers only the machine's
28
+ * half.
29
+ */
30
+ function prefsPayload() {
31
+ const prefs = heldPrefs.current();
32
+ return {
33
+ ok: true,
34
+ // `publicPrefs`, never the held prefs whole. This route answers anything
35
+ // that can reach the loopback port, and the LAN group passphrase is in the
36
+ // stored object — one field, and sending it here would put it in front of
37
+ // every page on the machine. What goes out is whether one is set.
38
+ prefs: publicPrefs(prefs),
39
+ notificationsAllowed: notificationsOn(prefs),
40
+ notificationsVetoed: notificationsVetoed(),
41
+ };
42
+ }
43
+
44
+ export function handlePrefsRead(req, res) {
45
+ return send(res, 200, prefsPayload());
46
+ }
47
+
48
+ /** POST a patch. Fields nobody sent keep their value — see writePrefs. */
49
+ export async function handlePrefsWrite(req, res) {
50
+ const raw = await readBody(req, res).catch(() => null);
51
+ let body = null;
52
+ try { body = JSON.parse(raw ?? ""); } catch { /* handled below */ }
53
+ if (!body || typeof body !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
54
+ try {
55
+ await heldPrefs.write(body);
56
+ } catch (err) {
57
+ // A settings file or folder this user cannot reach is the machine's to fix,
58
+ // not the deck's, and `guard`'s bare 500 left the panel with nothing to say
59
+ // about it (#1335). Anything else is still a bug and still goes to `guard`.
60
+ const reason = prefsWriteRefusal(err);
61
+ if (!reason) throw err;
62
+ console.error(`${PRODUCT}: settings were not saved:`, err?.message ?? err);
63
+ // What blocked it, from closed sets and without the path, so a screenshot
64
+ // of the panel is enough to tell the cases apart (#1335).
65
+ const detail = prefsRefusalDetail(err);
66
+ return send(res, 500, detail ? { ok: false, reason, detail } : { ok: false, reason });
67
+ }
68
+ // The engine reads its settings from here rather than holding its own copy,
69
+ // so turning the switch off in the panel really does stop the sockets rather
70
+ // than only changing what the panel says.
71
+ await applyLanPrefs();
72
+ // Somebody has just switched this on and is watching the panel for the answer
73
+ // to one question: is anything going to turn up. Whatever the probe said
74
+ // before is about a deck whose sockets were down — see forgetReach.
75
+ if (body.lan?.enabled === true) forgetReach();
76
+ return send(res, 200, prefsPayload());
77
+ }
78
+
79
+ /**
80
+ * POST: give the deck's settings folder back to this user, through macOS's own
81
+ * password dialog (#1711). No body — every path is the deck's own; see
82
+ * prefs-give-back.mjs.
83
+ *
84
+ * AND THEN READ THE FILE AGAIN, AS A BOOT WOULD. A deck that started on a folder
85
+ * it could not open is running on the defaults, and its LAN engine on a key it
86
+ * made up and could not keep. Once the file is readable that is the wrong deck:
87
+ * the settings are re-read, and the engine is handed the fields it authors off
88
+ * the file again — the stored key and pairings — which is what a restart would
89
+ * have done, without the restart.
90
+ */
91
+ export async function handlePrefsGiveBack(req, res) {
92
+ const out = await giveBackDeckFolders();
93
+ if (!out.ok) return send(res, 409, { ok: false, reason: out.reason });
94
+ if ((await heldPrefs.reload()) === "file") {
95
+ resetLanLoaded();
96
+ await applyLanPrefs();
97
+ }
98
+ return send(res, 200, { ...prefsPayload(), changed: out.changed });
99
+ }
@@ -0,0 +1,86 @@
1
+ // Moving a prefs.json nobody can use out of the way, before anything can write
2
+ // over it: the bytes of a file that is not JSON (#1002), and a file another
3
+ // user owns (#1335). Moved out of deck-prefs.mjs unchanged; loadPrefs decides
4
+ // when, and this is how.
5
+ import { stat } from "node:fs/promises";
6
+ import { renameWithRetry } from "./atomic-write.mjs";
7
+ import { deckDataDir } from "./deck-home.mjs";
8
+ import { prefsDir, prefsPath } from "./prefs-path.mjs";
9
+
10
+ /** Where the bytes of a prefs.json nothing could parse are put.
11
+ *
12
+ * `Date.now()` rather than an ISO timestamp because a colon is not a legal
13
+ * filename character on Windows, and a quarantine that cannot be created on
14
+ * the platform it is protecting is not a quarantine. */
15
+ export const quarantinePath = (home = deckDataDir(), at = Date.now()) =>
16
+ `${prefsPath(home)}.corrupt-${at}`;
17
+
18
+ /** The read errors that can mean "not yours" rather than "broken". Only these
19
+ * send a read to setAsideForeign; EISDIR, EIO and EMFILE never do. */
20
+ export const DENIED = new Set(["EACCES", "EPERM"]);
21
+
22
+ /** Where a prefs.json another user owns is put. Beside the file, like
23
+ * quarantinePath, and stamped in milliseconds for the same Windows reason —
24
+ * but a name of its own, because nothing is wrong with these bytes. */
25
+ export const foreignPath = (home = deckDataDir(), at = Date.now()) =>
26
+ `${prefsPath(home)}.foreign-${at}`;
27
+
28
+ /**
29
+ * Move a prefs.json that ANOTHER USER OWNS out of the way, or say why not.
30
+ *
31
+ * #1335. A `sudo ccdeck` that kept the user's HOME writes a root-owned 0600
32
+ * prefs.json into the user's own folder. From then on every
33
+ * deck that user starts is refused the read, refuses every settings write on
34
+ * top of it — rightly — and so can never keep anything: a new LAN identity on
35
+ * every start, a share tick that says "Could not share that account", and no
36
+ * way out from inside the deck.
37
+ *
38
+ * WHY MOVING IT IS SAFE HERE WHEN IT IS NOT FOR "unreadable". The refusal
39
+ * exists so the deck never loses a key it could still have read. A file owned
40
+ * by somebody else is not one this user's deck will ever read — waiting does
41
+ * not help, and the deck is already running on defaults. A rename reads and
42
+ * changes nothing: owner and mode travel with the file, so the key in it stays
43
+ * private and whole, and `sudo chown` plus a move puts it back.
44
+ *
45
+ * Only when the owner is KNOWN to be somebody else. A file this user owns and
46
+ * still cannot read (a mode bit, a lock, a sandbox) is left exactly where it
47
+ * is, and so is anything on Windows, where there is no uid to compare and
48
+ * EPERM is usually another program holding the file.
49
+ *
50
+ * Answers `{ moved, uid }`, `{ gone }` when the file vanished under it, or,
51
+ * when it stayed, `{ why, owner, on }`: a sentence for the log, possibly empty,
52
+ * and who owns what blocked the read — "you", "other" or "unknown" — and
53
+ * whether that is the "file" or its "folder". The last two are what the panel
54
+ * is told (see prefsRefusalDetail); the sentence, which has the path, is not.
55
+ */
56
+ export async function setAsideForeign(path, home, deps) {
57
+ const uid = deps.getuid ? deps.getuid() : process.getuid?.();
58
+ if (uid === undefined) return { why: "", owner: "unknown", on: "file" };
59
+ const look = deps.stat ?? stat;
60
+ let st;
61
+ try {
62
+ st = await look(path);
63
+ } catch (err) {
64
+ if (err?.code === "ENOENT") return { gone: true };
65
+ // The folder itself is out of reach, so nothing in it can be looked at, let
66
+ // alone moved. Name the folder and its owner, which is what a person needs.
67
+ const folder = prefsDir(home);
68
+ const owner = await look(folder).then(s => s.uid, () => undefined);
69
+ if (owner === undefined) return { why: "", owner: "unknown", on: "folder" };
70
+ if (owner === uid) return { why: "", owner: "you", on: "folder" };
71
+ return {
72
+ why: ` The folder ${folder} belongs to another user (uid ${owner}), most likely from a run with sudo; sudo chown -R "$(id -un)" "${folder}" gives it back.`,
73
+ owner: "other",
74
+ on: "folder",
75
+ };
76
+ }
77
+ if (st.uid === uid) return { why: "", owner: "you", on: "file" };
78
+ const to = foreignPath(home);
79
+ try {
80
+ await (deps.rename ?? renameWithRetry)(path, to);
81
+ } catch (err) {
82
+ if (err?.code === "ENOENT") return { gone: true };
83
+ return { why: ` It belongs to another user (uid ${st.uid}) and could not be moved aside either: ${err?.message ?? err}.`, owner: "other", on: "file" };
84
+ }
85
+ return { moved: to, uid: st.uid };
86
+ }
@@ -0,0 +1,70 @@
1
+ // The deck's settings as this process holds them: the one in-memory copy every
2
+ // reader asks, and the one place a write's answer is kept.
3
+ //
4
+ // This was `let _prefs` in src/server/index.mjs, and it is the reason the LAN
5
+ // section could not leave that file. The notifier and the away-update read it;
6
+ // the settings route, the LAN peer route and six of the LAN engine's callbacks
7
+ // each assigned it back with `_prefs = await writePrefs(...)`; and a
8
+ // module-level `let` is one no other module can assign. So the variable moved
9
+ // first, behind the three things its callers actually do with it: read it,
10
+ // write a patch, and compute a patch from the file. Every write now goes
11
+ // through here, which is also what keeps the promise the comment below has
12
+ // always made — that the copy and the file cannot drift within one process —
13
+ // true by construction rather than by every caller remembering the assignment.
14
+ //
15
+ // The disk half is deck-prefs.mjs, unchanged: the queue, the merge and the
16
+ // refusal to write over a file it could not read all live there. This holds
17
+ // what they return and nothing else.
18
+ import { DEFAULTS, loadPrefs, loadPrefsInTurn, updatePrefs, writePrefs } from "./deck-prefs.mjs";
19
+
20
+ /**
21
+ * The deck's own settings, in memory, refreshed whenever they are written.
22
+ *
23
+ * Read once at boot and then kept here rather than read per event: `consider`
24
+ * is on the ingest path every hook event goes through, and a file read there
25
+ * would be a syscall per event to answer a question that changes when somebody
26
+ * presses a switch. The write path below updates this, so the in-memory copy
27
+ * and the file cannot drift within one process — and a second deck writing the
28
+ * file is picked up on ITS next write or this one's next boot, which is the
29
+ * same freshness every other cross-deck setting has.
30
+ */
31
+ let _prefs = { ...DEFAULTS };
32
+ /** Whether the boot read failed on a file that is still there (#1711): the
33
+ * copy here is the defaults, and the file holds settings — the LAN key, the
34
+ * pairings — this process never saw. Only a re-read that finds the file clears
35
+ * it. A write that lands first merges onto the file, but leaves the engine on
36
+ * what it made up, so it does not count. */
37
+ let _unread = false;
38
+
39
+ // Read at import, so `consider` has its answer from the first event. LAN sync
40
+ // used to start from this same read, and it must not: bin/deck.js imports the
41
+ // server module to ASK whether a deck is already up, and a launcher that then
42
+ // attaches and exits had already bound the beacon and the sync port on the way
43
+ // in — which is the `lan sync (listen): EADDRINUSE` line a second `ccdeck`
44
+ // printed above `deck already running`. The engine starts from the listen that
45
+ // succeeds, in startServer, which is the only process that may hold a port.
46
+ export const prefsRead = loadPrefs().then(r => { _prefs = r.prefs; _unread = r.source === "unreadable"; }).catch(() => {});
47
+
48
+ export const heldPrefs = Object.freeze({
49
+ /** What the last read or write left here. Never a disk read — see above. */
50
+ current: () => _prefs,
51
+ /** `writePrefs(patch)`, and keep what it wrote. A write that throws keeps
52
+ * nothing, so the copy is never ahead of the file. */
53
+ write: async patch => (_prefs = await writePrefs(patch)),
54
+ /** `updatePrefs(mutate)`, and keep what it wrote: for a patch that has to be
55
+ * computed from the file rather than from `current()` — see updatePrefs. */
56
+ update: async mutate => (_prefs = await updatePrefs(mutate)),
57
+ /** Read the file again, as a boot would, once something outside the deck has
58
+ * made it readable (#1711) — and only for a deck whose boot read could not.
59
+ * Any other deck is left alone and answers "held": re-reading it would race
60
+ * the engine's own writes for nothing. Otherwise the read waits its turn
61
+ * behind the writes, and is kept only when it really is the user's file; a
62
+ * read that still fails leaves the copy this process was running on.
63
+ * Answers "held", or the read's `source`. */
64
+ reload: async () => {
65
+ if (!_unread) return "held";
66
+ const { prefs, source } = await loadPrefsInTurn();
67
+ if (source === "file") { _prefs = prefs; _unread = false; }
68
+ return source;
69
+ },
70
+ });