ccdeck 3.36.9 → 3.37.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 (75) hide show
  1. package/README.md +15 -7
  2. package/bin/agent-dag.js +19 -6
  3. package/bin/cli/help.js +1 -0
  4. package/bin/cli/login-item.js +44 -20
  5. package/bin/cli/one-shot.js +15 -5
  6. package/bin/cli/screen.js +10 -5
  7. package/bin/cli/startup.js +6 -2
  8. package/bin/cli/uninstall.js +41 -5
  9. package/bin/deck.js +50 -8
  10. package/dist/web/assets/BrowserWatchModal-DCHV09J8.js +1 -0
  11. package/dist/web/assets/UsageHistoryModal-oDjpHw4z.js +1 -0
  12. package/dist/web/assets/index-B0SOa-XB.css +1 -0
  13. package/dist/web/assets/index-wCbqcRJd.js +306 -0
  14. package/dist/web/index.html +2 -2
  15. package/hook/hook.js +80 -9
  16. package/package.json +1 -1
  17. package/release-notes.json +2 -1
  18. package/src/server/account-origins.mjs +8 -1
  19. package/src/server/account-projects.mjs +34 -9
  20. package/src/server/activation.mjs +39 -10
  21. package/src/server/app-host.mjs +7 -2
  22. package/src/server/args.mjs +3 -0
  23. package/src/server/browser-watch-routes.mjs +23 -16
  24. package/src/server/browser-watch.mjs +9 -3
  25. package/src/server/claude-accounts.mjs +27 -7
  26. package/src/server/claude-verdicts.mjs +12 -1
  27. package/src/server/cli-versions.mjs +6 -4
  28. package/src/server/codex-auth.mjs +29 -0
  29. package/src/server/codex-quota.mjs +57 -7
  30. package/src/server/deck-home.mjs +23 -1
  31. package/src/server/deck-prefs.mjs +70 -7
  32. package/src/server/depth-facts.mjs +178 -0
  33. package/src/server/detach.mjs +61 -33
  34. package/src/server/event-log.mjs +15 -0
  35. package/src/server/event-pipeline.mjs +5 -2
  36. package/src/server/event-routes.mjs +22 -0
  37. package/src/server/feedback-form.mjs +36 -13
  38. package/src/server/index.mjs +6 -2
  39. package/src/server/install-layout.mjs +35 -0
  40. package/src/server/install-ref.mjs +39 -0
  41. package/src/server/installer.mjs +20 -2
  42. package/src/server/lan-beacon.mjs +18 -3
  43. package/src/server/lan-deck.mjs +7 -1
  44. package/src/server/lan-engine.mjs +10 -1
  45. package/src/server/lan-manifest.mjs +9 -6
  46. package/src/server/lan-requests.mjs +18 -2
  47. package/src/server/lan-serve.mjs +4 -2
  48. package/src/server/lan-sync.mjs +97 -6
  49. package/src/server/lifecycle.mjs +18 -2
  50. package/src/server/log-replay.mjs +9 -3
  51. package/src/server/login-service.mjs +95 -12
  52. package/src/server/npm-upgrade.mjs +12 -4
  53. package/src/server/pinned-build.mjs +3 -0
  54. package/src/server/prefs-routes.mjs +5 -8
  55. package/src/server/prefs-state.mjs +9 -1
  56. package/src/server/quota-oauth.mjs +88 -13
  57. package/src/server/quota-shape.mjs +28 -0
  58. package/src/server/quota.mjs +68 -17
  59. package/src/server/rating.mjs +87 -0
  60. package/src/server/reports-routes.mjs +21 -2
  61. package/src/server/reports.mjs +672 -64
  62. package/src/server/ring-bounds.mjs +11 -0
  63. package/src/server/running-deck.mjs +48 -0
  64. package/src/server/self-update.mjs +1 -1
  65. package/src/server/session-enrichment.mjs +14 -6
  66. package/src/server/stable-node.mjs +68 -0
  67. package/src/server/static-serve.mjs +5 -0
  68. package/src/server/supervisor.mjs +21 -15
  69. package/src/server/tailscale.mjs +8 -4
  70. package/src/server/transcript-scan.mjs +7 -4
  71. package/src/server/usage-day.mjs +76 -11
  72. package/dist/web/assets/BrowserWatchModal-BCrDvuug.js +0 -1
  73. package/dist/web/assets/UsageHistoryModal-BtRCuo33.js +0 -1
  74. package/dist/web/assets/index-BkXTN4dZ.css +0 -1
  75. package/dist/web/assets/index-tVBMXvDv.js +0 -306
@@ -147,6 +147,8 @@ export async function logSharing() {
147
147
  const ROTATE_AT_BYTES = 50 * 1024 * 1024;
148
148
  let lastRotateCheckAt = 0;
149
149
  let rotateInProgress = false;
150
+ /** The idle look's timer, armed once by openEventLog. */
151
+ let rotateTimer = null;
150
152
  /** Bytes handed to appendLogLine since the last time we looked at the file.
151
153
  *
152
154
  * THE 30-SECOND CLOCK MADE THE THRESHOLD ADVISORY. The stat was throttled to
@@ -320,6 +322,19 @@ async function openEventLog(persist) {
320
322
  // Taken the moment the probe answers, so that a line landing from here on
321
323
  // is evidence newer than it. See logWritableNow.
322
324
  _landedAtProbe = appendsLanded(persistPath);
325
+ // THE IDLE FLOOR, RUN. ROTATE_CHECK_EVERY_MS was described as the look that
326
+ // notices "another deck appending to a log they share", and the push path
327
+ // was its one caller — so the deck that owns a log and writes nothing to it,
328
+ // a scoped deck on a quiet tree beside a machine-wide one, never looked, and
329
+ // the deck appending every line was not the owner and returned at the
330
+ // ownership gate. The file grew past the cap for as long as both ran. A
331
+ // timer makes the owner look whether or not it writes; the gate and
332
+ // `rotateInProgress` already make a look from here safe. One per process,
333
+ // and it never holds the process open.
334
+ if (!rotateTimer) {
335
+ rotateTimer = setInterval(() => { maybeRotatePersistFile(0); }, ROTATE_CHECK_EVERY_MS);
336
+ rotateTimer.unref?.();
337
+ }
323
338
  }
324
339
 
325
340
  // What index.mjs calls besides the three exported above. Listed rather than
@@ -88,9 +88,12 @@ export function pushEvent(raw, source, opts = {}) {
88
88
  raw = redactDeckToken(raw);
89
89
 
90
90
  // Synchronous enrichment: if we already know this session's model, stamp
91
- // it on the payload so the client's recursive scanner picks it up.
91
+ // it on the payload so the client's recursive scanner picks it up — the
92
+ // subagent's own model on a subagent's own event, never the root's. See
93
+ // knownModelId.
92
94
  if (raw && typeof raw === "object" && raw.session_id && !raw.model) {
93
- const modelId = knownModelId(raw.session_id);
95
+ const subagentKey = [raw.agent_id, raw.parent_tool_use_id].find(k => typeof k === "string" && k) ?? null;
96
+ const modelId = knownModelId(raw.session_id, subagentKey);
94
97
  if (modelId) raw.model = modelId;
95
98
  }
96
99
 
@@ -18,6 +18,10 @@ import { SEQ_EPOCH, ringHoldsNewerThan, ringSnapshot } from "./event-ring.mjs";
18
18
  // The one door every event comes through — see event-pipeline.mjs.
19
19
  import { pushEvent } from "./event-pipeline.mjs";
20
20
  import { noteLogWriter } from "./event-log.mjs";
21
+ // Which CLIs this deck watches — see deck-scope.mjs.
22
+ import { deckProviders } from "./deck-scope.mjs";
23
+ // The names only the server may send — see ring-bounds.mjs.
24
+ import { isReservedEventName } from "./ring-bounds.mjs";
21
25
  // The SSE subscribers and the backpressure every frame to them is written
22
26
  // under — see sse-clients.mjs.
23
27
  import { dropSse, sseClients, trayClients, writeResume, writeSse } from "./sse-clients.mjs";
@@ -176,6 +180,24 @@ export function handleEventIngest(req, res, persist = true) {
176
180
  let parsed;
177
181
  try { parsed = JSON.parse(body); }
178
182
  catch { return send(res, 400, { error: "invalid json" }); }
183
+ // Names beginning `__` are the deck's own control markers — `__clear` is
184
+ // the one handleClear sends — and only the server may send them. This is
185
+ // the one mutating route open without the deck's token, so a reserved name
186
+ // arriving here is refused before it can reach the ring, the pages or the
187
+ // log. No hook event is named that way.
188
+ if (isReservedEventName(parsed?.hook_event_name)) {
189
+ return send(res, 400, { error: "reserved event name" });
190
+ }
191
+ // A deck started with --no-claude takes no Claude hook event, which is what
192
+ // its replay already decides (see replayScope) and what a current hook no
193
+ // longer sends it. A hook installed before that still does, so the refusal
194
+ // is an answer rather than a silent drop: a hook that elected this deck to
195
+ // write the log hands it on to the next deck instead of losing the line.
196
+ // Read off the provider every Claude hook has stamped since Codex support
197
+ // arrived, so it is the payload saying whose it is.
198
+ if (!deckProviders().claude && parsed?.provider === "claude") {
199
+ return send(res, 409, { error: "this deck is not watching Claude" });
200
+ }
179
201
  // Everything past the parse is inside one net, because this listener is the
180
202
  // one place in the route table `guard` cannot reach. The route does wrap the
181
203
  // call — `guard(handleEventIngest(req, res, …), res)` — but this function
@@ -16,10 +16,12 @@
16
16
  // can say whose machine it was on — "alice-desk.png" — and the API keeps no
17
17
  // name, so none is sent. The API strips EXIF, GPS and text chunks itself.
18
18
  //
19
- // Node's own Request parses the form and its FormData builds the next one, so
20
- // this costs no dependency. Both are there on the Node 18 the package promises
21
- // (undici's multipart reader shipped inside it); `File` is not a global before
22
- // Node 20 and is never named here.
19
+ // Node's own Request parses the form and the next one is written out by hand
20
+ // (upstreamForm says why), so this costs no dependency. Request is there on the
21
+ // Node 18 the package promises (undici's multipart reader shipped inside it);
22
+ // `File` is not a global before Node 20 and is never named here.
23
+
24
+ import { randomBytes } from "node:crypto";
23
25
 
24
26
  export const IMAGES_FIELD = "images";
25
27
  export const MAX_IMAGES = 3;
@@ -80,7 +82,9 @@ export async function readFeedbackForm(bytes, contentType) {
80
82
  } else if (isFile) {
81
83
  fileElsewhere = true;
82
84
  } else if (TEXT_FIELDS.includes(name) && !(name in fields)) {
83
- fields[name] = value;
85
+ // Back to the LF the box held. A textarea's value never has a CR in it,
86
+ // so every CRLF here is the form encoding's, not the person's.
87
+ fields[name] = value.replace(/\r\n/g, "\n");
84
88
  }
85
89
  }
86
90
  // Refused rather than ignored, as the API does: a part named "image" would
@@ -107,19 +111,38 @@ export async function readFeedbackForm(bytes, contentType) {
107
111
  return { fields, images, problems };
108
112
  }
109
113
 
110
- /** The form this server posts on: the page's text, this deck's facts (feedbackFacts in reports-routes.mjs), and the images under names of its own. */
114
+ /**
115
+ * The form this server posts on: the page's text, this deck's facts
116
+ * (feedbackFacts in reports-routes.mjs), and the images under names of its own.
117
+ *
118
+ * WRITTEN OUT HERE rather than through FormData, for the line breaks. A form's
119
+ * encoding turns every line break in a text field into CRLF, FormData's too,
120
+ * and the API counts a CRLF as two characters of the message's 10,000. A pasted
121
+ * log of a thousand short lines that fitted the box went over with an image
122
+ * attached, while the same words went through as JSON without one. So the text
123
+ * goes on as the box held it, LF for LF, and the limit means the same on both
124
+ * paths.
125
+ *
126
+ * A Blob whose type carries the boundary, so fetch still writes the content
127
+ * type itself. The boundary is lower case because a Blob's type is, and random
128
+ * so no message can contain it.
129
+ */
111
130
  export function upstreamForm({ fields, images }, facts) {
112
- const form = new FormData();
131
+ const boundary = `ccdeck-${randomBytes(16).toString("hex")}`;
132
+ const parts = [];
133
+ const head = (name, file = "") => `--${boundary}\r\nContent-Disposition: form-data; name="${name}"${file}\r\n`;
134
+ const text = (name, value) => parts.push(`${head(name)}\r\n${value}\r\n`);
113
135
  for (const name of ["kind", "title", "body"]) {
114
- if (typeof fields[name] === "string") form.append(name, fields[name]);
136
+ if (typeof fields[name] === "string") text(name, fields[name]);
115
137
  }
116
138
  const contact = typeof fields.contact === "string" ? fields.contact.trim() : "";
117
- if (contact) form.append("contact", contact);
118
- form.append("appVersion", facts.appVersion);
119
- form.append("platform", facts.platform);
139
+ if (contact) text("contact", contact);
140
+ text("appVersion", facts.appVersion);
141
+ text("platform", facts.platform);
120
142
  for (const [index, { data, format }] of images.entries()) {
121
143
  const { type, extension } = FORMAT[format];
122
- form.append(IMAGES_FIELD, new Blob([data], { type }), `image-${index + 1}.${extension}`);
144
+ parts.push(`${head(IMAGES_FIELD, `; filename="image-${index + 1}.${extension}"`)}Content-Type: ${type}\r\n\r\n`, data, "\r\n");
123
145
  }
124
- return form;
146
+ parts.push(`--${boundary}--\r\n`);
147
+ return new Blob(parts, { type: `multipart/form-data; boundary=${boundary}` });
125
148
  }
@@ -139,7 +139,9 @@ import { handleLanInvite, handleLanPeer, handleLanStatus, handleLanSync } from "
139
139
  import { handlePrefsGiveBack, handlePrefsRead, handlePrefsWrite } from "./prefs-routes.mjs";
140
140
  // Usage reports and the feedback dialog (#1853). The requests to
141
141
  // api.ccdeck.dev are made in reports.mjs, never in this file.
142
- import { handleClientError, handleFeedback, handleFeedbackFacts, handleReportsWrite } from "./reports-routes.mjs";
142
+ import {
143
+ handleClientError, handleFeedback, handleFeedbackFacts, handleRatingRead, handleRatingWrite, handleReportsWrite,
144
+ } from "./reports-routes.mjs";
143
145
  import { reporter } from "./reports.mjs";
144
146
  import { MANIFEST_PATH, offerManifest } from "./app-manifest.mjs";
145
147
  import { historySnapshot, processesReply, readProcesses, startSystemMetrics, systemSnapshot } from "./system-metrics.mjs";
@@ -182,7 +184,7 @@ export function requestUrl(rawUrl) {
182
184
  }
183
185
 
184
186
  export async function startServer({ port = 4317, host = "127.0.0.1", persist = null, portRange = [4318, 4400], workspace = "", codex = true, claude = true, onRestart = null, onStop = null, cswapQuiet = null } = {}) {
185
- armLifecycle({ onRestart, onStop, persist });
187
+ armLifecycle({ onRestart, onStop, persist, onSelfUpdate: () => reporter.noteSelfUpdate() });
186
188
  // Which tree and which CLIs, normalised once — see setDeckScope.
187
189
  setDeckScope({ workspace, claude, codex });
188
190
  // A boot is the one moment the file, and not the engine, is the authority on
@@ -322,6 +324,8 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
322
324
  if (req.method === "GET" && url.pathname === "/api/feedback") return handleFeedbackFacts(req, res);
323
325
  if (req.method === "POST" && url.pathname === "/api/feedback") return guard(handleFeedback(req, res), res);
324
326
  if (req.method === "POST" && url.pathname === "/api/client-error") return guard(handleClientError(req, res), res);
327
+ if (req.method === "GET" && url.pathname === "/api/rating") return handleRatingRead(req, res);
328
+ if (req.method === "POST" && url.pathname === "/api/rating") return guard(handleRatingWrite(req, res), res);
325
329
  if (req.method === "GET" && url.pathname === "/api/system") return send(res, 200, systemSnapshot());
326
330
  // On demand only — the process list costs a subprocess on every platform,
327
331
  // so it is fetched while the detail panel is open and never on the timer.
@@ -67,6 +67,41 @@ export function isNpxInstall(pkgRoot) {
67
67
  return typeof pkgRoot === "string" && pkgRoot.split(/[\\/]/).includes("_npx");
68
68
  }
69
69
 
70
+ /** Any one-off runner's copy: npx's, and the three that are not npm's —
71
+ *
72
+ * pnpm dlx <cache>/pnpm/dlx/<hash>/…, and dlx-<pid>/ in a temp folder before pnpm cached them
73
+ * bunx <tmp>/bunx-<uid>-<spec>/, cleared at reboot
74
+ * yarn dlx <tmp>/xfs-<id>/dlx-<pid>/
75
+ *
76
+ * Only `_npx` used to be recognised, so the other three looked like a global
77
+ * install: the first start wrote a login item naming a file inside the cache,
78
+ * and the writable folder made the update an `npm i -g` the away-update ran by
79
+ * itself, every thirty minutes, without ever moving the running copy. Neither
80
+ * has a path anybody promised to keep. Segment-wise and separator-agnostic,
81
+ * for the reasons isNpxInstall gives. */
82
+ export function isOneOffRun(pkgRoot) {
83
+ return oneOffRunner(pkgRoot) !== null;
84
+ }
85
+
86
+ /** Which of those four unpacked this copy — "npx", "pnpm dlx", "bunx" or
87
+ * "yarn dlx" — or null for a copy that stays. None of them leaves the
88
+ * package's command on PATH, so a command this deck tells somebody to type
89
+ * goes through the runner they used. yarn's `dlx-<pid>` sits in an `xfs-`
90
+ * folder; pnpm's, from before it cached them, does not. */
91
+ export function oneOffRunner(pkgRoot) {
92
+ if (typeof pkgRoot !== "string") return null;
93
+ if (isNpxInstall(pkgRoot)) return "npx";
94
+ // `dlx` alone only under pnpm's cache directory (pnpm/, pnpm-cache/), so a
95
+ // project folder that happens to be called that is not taken for one.
96
+ const parts = pkgRoot.split(/[\\/]/);
97
+ for (const [i, p] of parts.entries()) {
98
+ if (p === "dlx" && /pnpm/i.test(parts[i - 1] ?? "")) return "pnpm dlx";
99
+ if (/^bunx-\d+-/.test(p)) return "bunx";
100
+ if (/^dlx-\d+$/.test(p)) return /^xfs-/.test(parts[i - 1] ?? "") ? "yarn dlx" : "pnpm dlx";
101
+ }
102
+ return null;
103
+ }
104
+
70
105
  /** A git checkout is the maintainer's own tree. Its version routinely sits
71
106
  * ahead of npm, and telling someone to `npm i -g` over their working copy is
72
107
  * actively wrong, so the registry side of the check is skipped there.
@@ -0,0 +1,39 @@
1
+ // Which page of ccdeck.dev an install came from — the `--ref` its command
2
+ // carried, for the usage reports (reports.mjs).
3
+ //
4
+ // WHAT IT ANSWERS. The site offers a command to copy on most of its pages, and
5
+ // puts a ref naming the page in it: `npx ccdeck --ref=guides-first-run`. The
6
+ // first "install" report carries that ref, and nothing else ever does, so the
7
+ // people who make ccdeck can tell which page a new install came from rather than
8
+ // guess it from site visits. A ref names a page of the site and nothing about the
9
+ // person: lowercase letters, digits and dashes, at most REF_MAX of them, and
10
+ // anything else is dropped here rather than sent.
11
+ //
12
+ // ONCE. A ref on any later run — a reinstall over an install that already
13
+ // reported, a respawn whose argv still carries it — is ignored: only the install
14
+ // it arrived with is new. If that install's report cannot get out, the ref waits
15
+ // in the prefs beside the install id and goes with the next try, then is cleared.
16
+ //
17
+ // The boot (bin/deck.js) is what reads the command line, and it reaches this
18
+ // module through noteRef. This file imports nothing, so the boot and the prefs
19
+ // can both load it early.
20
+
21
+ /** The longest ref. The API takes the same. */
22
+ export const REF_MAX = 32;
23
+
24
+ /** The ref if it is one, else undefined. */
25
+ export function refSlug(raw) {
26
+ return typeof raw === "string" && raw.length <= REF_MAX && /^[a-z0-9-]+$/.test(raw) ? raw : undefined;
27
+ }
28
+
29
+ let noted;
30
+
31
+ /** What this run's command line said, if it said anything that is a ref. */
32
+ export function noteRef(raw) {
33
+ noted = refSlug(raw);
34
+ }
35
+
36
+ /** This run's ref, or undefined. */
37
+ export function notedRef() {
38
+ return noted;
39
+ }
@@ -81,7 +81,8 @@ const LEGACY_DIRS = ["ccgraph", "agent-flow", "agent-dag"];
81
81
  * It used to wrap both paths in double quotes, which on POSIX escapes nothing:
82
82
  * `$(…)`, a backtick and `\` are all still live inside them. Both paths come
83
83
  * from outside — `installedHookPath` is built from $CLAUDE_CONFIG_DIR (resolved,
84
- * never validated) or homedir(), and `node` is process.execPath — so a config
84
+ * never validated) or homedir(), and `node` is process.execPath or the PATH
85
+ * entry that links to it (stable-node.mjs) — so a config
85
86
  * dir called `/tmp/a$(id)b` was shell code, written into the user's own settings
86
87
  * file and executed on every hook fire for as long as it stayed there. The
87
88
  * quieter half of the same bug cost nothing but the feature: an ordinary `$` in
@@ -99,11 +100,28 @@ const LEGACY_DIRS = ["ccgraph", "agent-flow", "agent-dag"];
99
100
  * double quotes), so without the second parameter the only assertion a test can
100
101
  * make is the one its own OS happens to produce, which is how this went five
101
102
  * releases with the Windows half of it never once executed.
103
+ *
104
+ * WHEN THE NODE IT NAMES IS GONE. stable-node.mjs finds a spelling of the node
105
+ * that outlives an upgrade where there is one, and nvm, fnm and Volta have
106
+ * none: the command named ~/.nvm/versions/node/<v>/bin/node, `nvm uninstall`
107
+ * took it away, and every tool call of every session showed a hook error from
108
+ * then on. So on POSIX the arguments go to a fixed /bin/sh script that runs the
109
+ * recorded node while it is executable, the node on Claude Code's own PATH once
110
+ * it is not, and nothing when there is no node at all — exit 0 and no output,
111
+ * which Claude Code does not report. The script is a constant and the paths
112
+ * reach it as positional arguments, quoted once by the same escaper, so none of
113
+ * them is ever spliced into shell code; and it is /bin/sh by its path, the
114
+ * shell Claude Code itself runs the line with, so neither a PATH without it
115
+ * nor a login shell that is not POSIX changes what the script means. The cost
116
+ * is one `sh` exec before node's own start. Windows keeps the direct command.
102
117
  */
118
+ const NODE_FALLBACK = 'n=$1; shift; [ -x "$n" ] || n=$(command -v node) || exit 0; exec "$n" "$@"';
119
+
103
120
  export function hookCommand(installedHookPath, provider, node = process.execPath,
104
121
  platform = process.platform) {
105
122
  const q = (s) => shellQuoteArg(s, platform);
106
- return `${q(node)} ${q(installedHookPath)} --provider ${q(provider)}`;
123
+ const args = `${q(node)} ${q(installedHookPath)} --provider ${q(provider)}`;
124
+ return platform === "win32" ? args : `/bin/sh -c ${q(NODE_FALLBACK)} ccdeck-hook ${args}`;
107
125
  }
108
126
 
109
127
  function isMarked(g) {
@@ -13,8 +13,9 @@ import dgram from "node:dgram";
13
13
  import { randomBytes } from "node:crypto";
14
14
  import { networkInterfaces } from "node:os";
15
15
  import { looksLikeTunnel } from "./route-via.mjs";
16
+ import { MAX_STRANGERS } from "./lan-requests.mjs";
16
17
  import {
17
- beaconPayload, beaconVerdict, hostId, notePeer, readBeacon,
18
+ beaconPayload, beaconVerdict, formerHostId, hostId, notePeer, readBeacon,
18
19
  ANNOUNCE_MS, MAX_BEACON_BYTES,
19
20
  } from "./lan-sync.mjs";
20
21
 
@@ -166,6 +167,10 @@ export function createBeacon({
166
167
  * is running, and hashing a hostname thirty seconds apart forever is work
167
168
  * nobody asked for. See hostId. */
168
169
  const host = hostId();
170
+ /** The id a deck from before the key sends from this computer, which is
171
+ * never sent from here and only ever read: a beacon carrying it is an older
172
+ * deck on this same machine. See formerHostId. */
173
+ const formerHost = formerHostId();
169
174
  const peers = new Map();
170
175
  let sock = null;
171
176
  /**
@@ -202,7 +207,13 @@ export function createBeacon({
202
207
  /** When this deck last answered a deck it had not heard, so answering cannot
203
208
  * become a storm, and which decks it has already answered — without the
204
209
  * second, a deck that is never accepted is answered again on every packet
205
- * for as long as both are running. */
210
+ * for as long as both are running.
211
+ *
212
+ * NEVER MORE OF THEM THAN THE HEARD LIST KEEPS (MAX_STRANGERS, #1738). A
213
+ * fingerprint is sixteen hex characters anybody on the network can make up,
214
+ * and each new one was an entry kept for the life of the process. The one
215
+ * answered longest ago goes first; heard again, it is answered once more,
216
+ * which is one packet. */
206
217
  let repliedAt = 0;
207
218
  const answered = new Set();
208
219
 
@@ -280,7 +291,7 @@ export function createBeacon({
280
291
  try { via = routeFor(rinfo.address); } catch { via = "lan"; }
281
292
  if (!via) return;
282
293
  const beacon = readBeacon(msg);
283
- const verdict = beaconVerdict(beacon, { selfFp: fp, selfInstance: instance, selfHost: host, trusted: trusted() });
294
+ const verdict = beaconVerdict(beacon, { selfFp: fp, selfInstance: instance, selfHost: host, formerHost, trusted: trusted() });
284
295
  // ANSWER A DECK WE HAVE NEVER HEARD, once, WHOEVER IT IS — and that last
285
296
  // part is the change. It used to answer only a deck already in the group,
286
297
  // which was fine when a group existed. Now the first thing a new deck has
@@ -300,6 +311,10 @@ export function createBeacon({
300
311
  if (newToUs && now() - repliedAt > REPLY_COOLDOWN_MS) {
301
312
  repliedAt = now();
302
313
  answered.add(beacon.fp);
314
+ for (const old of answered) {
315
+ if (answered.size <= MAX_STRANGERS) break;
316
+ answered.delete(old);
317
+ }
303
318
  // A deck that reached this one over the tailnet is answered there too:
304
319
  // a broadcast never gets back down its tunnel.
305
320
  announce(via === "tailscale" ? [rinfo.address] : []);
@@ -234,7 +234,9 @@ export const lanEngine = createEngine({
234
234
  const before = heldPrefs.current();
235
235
  const after = await heldPrefs.update(withShared(key));
236
236
  if (after?.lan?.shared === before?.lan?.shared) return; // it was already ticked
237
- await lanEngine.apply({ shared: after.lan.shared });
237
+ // With the mark that says an arrival ticked it, which decides who it is
238
+ // offered to — see sharedWith in lan-sync.mjs.
239
+ await lanEngine.apply({ shared: after.lan.shared, onward: after.lan.onward });
238
240
  } catch (err) {
239
241
  console.error(`${PRODUCT}: lan sync could not share the account it just received:`, err?.message ?? err);
240
242
  }
@@ -341,6 +343,10 @@ export function lanApplyFields(prefs, { load = false, env = process.env } = {})
341
343
  enabled: lanEnabled(prefs, env),
342
344
  name: lan.name || defaultName(),
343
345
  shared: Array.isArray(lan.shared) ? lan.shared : [],
346
+ // Which of those an arrival ticked, so a deck the accept switch paired is
347
+ // not offered them — see sharedWith in lan-sync.mjs. Read with `shared`,
348
+ // from the same copy, so the two always agree.
349
+ onward: Array.isArray(lan.onward) ? lan.onward : [],
344
350
  autoAsk: lan.autoAsk !== false,
345
351
  autoAccept: lan.autoAccept !== false,
346
352
  pairingMode: lan.pairingMode === "invite" ? "invite" : "automatic",
@@ -296,7 +296,9 @@ export function createEngine({
296
296
  routes = null,
297
297
  } = {}) {
298
298
  let cfg = {
299
- enabled: false, name: defaultName(), secret: "", shared: [], trusted: [], unpaired: [], port: 0,
299
+ // `onward`: which of `shared` an arrival ticked rather than a person — see
300
+ // sharedWith in lan-sync.mjs.
301
+ enabled: false, name: defaultName(), secret: "", shared: [], onward: [], trusted: [], unpaired: [], port: 0,
300
302
  autoAsk: true, autoAccept: true, pairingMode: "automatic", aliases: {},
301
303
  // Tell paired decks which shared account this one is on — see currentFor.
302
304
  shareActive: true,
@@ -822,6 +824,13 @@ export function createEngine({
822
824
  // revoked this peer. Keep the imported slot, but do not turn it into
823
825
  // a newly shared credential on behalf of an obsolete transfer.
824
826
  if (ok && stillPaired() && ticksOnArrival(step, viaOf(peer), trustedPeer(cfg.trusted, conn.peerFp))) {
827
+ // MARKED AS THIS ARRIVAL'S TICK, here and in what onShared keeps, so
828
+ // it is offered to the decks somebody here chose and not to one the
829
+ // accept switch paired — see sharedWith. Only while nobody has
830
+ // ticked the account already: a person's tick stays a person's.
831
+ if (!cfg.shared.includes(step.key) && !cfg.onward?.includes(step.key)) {
832
+ cfg = { ...cfg, onward: [...(cfg.onward ?? []), step.key] };
833
+ }
825
834
  try { await onShared?.(step.key); }
826
835
  catch { /* the account is here; the tick is retried the next time one arrives */ }
827
836
  }
@@ -4,14 +4,15 @@
4
4
  // two maps only these fill, so saying and hearing sit in one file as they sat
5
5
  // side by side in the engine. The rules for the lists themselves are
6
6
  // lan-copies.mjs's, and the card's are lan-about.mjs's.
7
- import { currentFor, heardCurrent, manifestFor, offered } from "./lan-sync.mjs";
7
+ import { currentFor, heardCurrent, manifestFor, offered, sharedWith } from "./lan-sync.mjs";
8
8
  import { openAbout, sealAbout } from "./lan-about.mjs";
9
9
 
10
10
  /**
11
11
  * `about` is this deck's own card, or null for a deck built without one. `now`
12
12
  * is the engine's clock. `myFp` answers this deck's fingerprint as it is at
13
13
  * the moment of asking — a restart for a new key changes it — and `settings`
14
- * the settings in force, of which a manifest reads `shared` and `shareActive`.
14
+ * the settings in force, of which a manifest reads `shared`, `onward`,
15
+ * `trusted` and `shareActive`.
15
16
  */
16
17
  export function createManifests({ about, now, myFp, settings }) {
17
18
  /** What each paired deck said about itself — version, operating system,
@@ -35,13 +36,15 @@ export function createManifests({ about, now, myFp, settings }) {
35
36
  };
36
37
 
37
38
  /** This deck's manifest for the deck on the other end of `key`: the accounts
38
- * it shares, which of them it is on, and its card. One frame whichever way
39
- * it travels — the question a round asks, and the answer `serve` gives. */
39
+ * it shares with that deck (see sharedWith), which of them it is on, and its
40
+ * card. One frame whichever way it travels — the question a round asks, and
41
+ * the answer `serve` gives. */
40
42
  const manifestFrame = (accounts, key, toFp) => {
41
43
  const cfg = settings();
44
+ const shared = sharedWith(cfg, toFp);
42
45
  return {
43
- t: "manifest", accounts: manifestFor(accounts, cfg.shared),
44
- ...currentFor(accounts, cfg.shared, cfg.shareActive),
46
+ t: "manifest", accounts: manifestFor(accounts, shared),
47
+ ...currentFor(accounts, shared, cfg.shareActive),
45
48
  ...cardFor(key, toFp),
46
49
  };
47
50
  };
@@ -6,6 +6,7 @@
6
6
  // this decides who is on which list and when that accept is pressed.
7
7
 
8
8
  import { pairable, PRESENT_MS } from "./lan-sync.mjs";
9
+ import { inTailnetBlock } from "./tailscale.mjs";
9
10
 
10
11
  /**
11
12
  * The most decks the heard list keeps at once (#1738).
@@ -57,6 +58,21 @@ export const REQUEST_MS = 10 * 60_000;
57
58
  export const asksOn = (cfg, via) => cfg.pairingMode !== "invite" && (via === "tailscale" ? !!cfg.tailscale && cfg.tailscaleAsk !== false : !!cfg.autoAsk);
58
59
  export const saysYesOn = (cfg, via) => cfg.pairingMode !== "invite" && (via === "tailscale" ? !!cfg.tailscale && cfg.tailscaleAccept !== false : !!cfg.autoAccept);
59
60
 
61
+ /**
62
+ * Whether a switch may press accept on a request at all, by where it came from:
63
+ * the Tailscale pair only for the owner's own machine, and the local pair only
64
+ * for an address that is plainly on the local network.
65
+ *
66
+ * AN ADDRESS IN TAILSCALE'S BLOCK IS NOT PLAINLY LOCAL. Once Tailscale has
67
+ * been read and is running, routeOf places every such address on the tailnet,
68
+ * and this asks the first question of it. One that still reads as the local
69
+ * network is one nothing here has placed — no read yet, a read that failed, a
70
+ * read from before Tailscale came up — and the block is also what carrier NAT
71
+ * and other overlay networks hand out. Such a deck is a row for somebody to
72
+ * accept, as an unknown tailnet machine is, rather than a local one by default.
73
+ */
74
+ const switchMayAnswer = (via, own, addr) => (via === "tailscale" ? own : !inTailnetBlock(addr));
75
+
60
76
  /**
61
77
  * `settings` answers the engine's settings in force at the moment of asking,
62
78
  * and `engineNow` the engine itself, whose accept is what a switch presses.
@@ -164,7 +180,7 @@ export function createRequests({ now, settings, routeTo, wasUnpaired, engineNow,
164
180
  // An accept the switch pressed that did not pin — the switch's own budget
165
181
  // is spent, see MAX_AUTO_PINS in lan-engine.mjs — leaves the request as it
166
182
  // would be with the switch off: a row, drawn, for somebody to answer.
167
- if (!wasUnpaired(entry.fp) && saysYesOn(cfg, via) && (via === "lan" || own)) {
183
+ if (!wasUnpaired(entry.fp) && saysYesOn(cfg, via) && switchMayAnswer(via, own, entry.addr)) {
168
184
  if (engineNow()?.accept(entry.fp, { byHand: false })) return;
169
185
  }
170
186
  if (!had) onChange?.();
@@ -234,7 +250,7 @@ export function createRequests({ now, settings, routeTo, wasUnpaired, engineNow,
234
250
  // the local one on does not answer a tailnet request, and turning the
235
251
  // Tailscale one on answers only the owner's own machines.
236
252
  const turnedOn = (f, via) => !f(was, via) && f(settings(), via);
237
- const mayAnswer = p => (p.via === "tailscale" ? turnedOn(saysYesOn, "tailscale") && p.own : turnedOn(saysYesOn, "lan"));
253
+ const mayAnswer = p => turnedOn(saysYesOn, p.via === "tailscale" ? "tailscale" : "lan") && switchMayAnswer(p.via, p.own, p.addr);
238
254
  for (const [fp, p] of [...pending]) if (!wasUnpaired(fp) && mayAnswer(p)) engineNow().accept(fp, { byHand: false });
239
255
  // The same for the other direction: switching `ask` on with four machines
240
256
  // already listed asks those four.
@@ -5,7 +5,7 @@
5
5
  // answered at all. It keeps nothing of its own: every check reads the
6
6
  // engine's settings, listener and key at the moment it is made, because any
7
7
  // of them can change while the store is read.
8
- import { credentialAad, seal, slotFor, transferChallenge, trustedPeer } from "./lan-sync.mjs";
8
+ import { credentialAad, seal, sharedWith, slotFor, transferChallenge, trustedPeer } from "./lan-sync.mjs";
9
9
  import { liveLoginIs } from "./account-health.mjs";
10
10
 
11
11
  /**
@@ -58,8 +58,10 @@ export function createServe({
58
58
  // A listener may have authenticated this socket before its owner
59
59
  // unpaired the caller or switched sharing off. Recheck at the moment
60
60
  // a credential is requested and again after every asynchronous read.
61
+ // What this deck offers THAT deck, which for one the accept switch
62
+ // paired is less than everything ticked — see sharedWith.
61
63
  const maySend = () => settings().enabled && !!serverNow() && !!trustedPeer(settings().trusted, ctx.peerFp)
62
- && settings().shared.includes(msg.key);
64
+ && sharedWith(settings(), ctx.peerFp).includes(msg.key);
63
65
  if (!maySend()) return ctx.send({ t: "no", why: "not shared" });
64
66
  // A SECOND PROOF, for the one operation that moves a credential. The
65
67
  // session says who connected; this says they are asking for this