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.
- package/README.md +15 -7
- package/bin/agent-dag.js +19 -6
- package/bin/cli/help.js +1 -0
- package/bin/cli/login-item.js +44 -20
- package/bin/cli/one-shot.js +15 -5
- package/bin/cli/screen.js +10 -5
- package/bin/cli/startup.js +6 -2
- package/bin/cli/uninstall.js +41 -5
- package/bin/deck.js +50 -8
- package/dist/web/assets/BrowserWatchModal-DCHV09J8.js +1 -0
- package/dist/web/assets/UsageHistoryModal-oDjpHw4z.js +1 -0
- package/dist/web/assets/index-B0SOa-XB.css +1 -0
- package/dist/web/assets/index-wCbqcRJd.js +306 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +80 -9
- package/package.json +1 -1
- package/release-notes.json +2 -1
- package/src/server/account-origins.mjs +8 -1
- package/src/server/account-projects.mjs +34 -9
- package/src/server/activation.mjs +39 -10
- package/src/server/app-host.mjs +7 -2
- package/src/server/args.mjs +3 -0
- package/src/server/browser-watch-routes.mjs +23 -16
- package/src/server/browser-watch.mjs +9 -3
- package/src/server/claude-accounts.mjs +27 -7
- package/src/server/claude-verdicts.mjs +12 -1
- package/src/server/cli-versions.mjs +6 -4
- package/src/server/codex-auth.mjs +29 -0
- package/src/server/codex-quota.mjs +57 -7
- package/src/server/deck-home.mjs +23 -1
- package/src/server/deck-prefs.mjs +70 -7
- package/src/server/depth-facts.mjs +178 -0
- package/src/server/detach.mjs +61 -33
- package/src/server/event-log.mjs +15 -0
- package/src/server/event-pipeline.mjs +5 -2
- package/src/server/event-routes.mjs +22 -0
- package/src/server/feedback-form.mjs +36 -13
- package/src/server/index.mjs +6 -2
- package/src/server/install-layout.mjs +35 -0
- package/src/server/install-ref.mjs +39 -0
- package/src/server/installer.mjs +20 -2
- package/src/server/lan-beacon.mjs +18 -3
- package/src/server/lan-deck.mjs +7 -1
- package/src/server/lan-engine.mjs +10 -1
- package/src/server/lan-manifest.mjs +9 -6
- package/src/server/lan-requests.mjs +18 -2
- package/src/server/lan-serve.mjs +4 -2
- package/src/server/lan-sync.mjs +97 -6
- package/src/server/lifecycle.mjs +18 -2
- package/src/server/log-replay.mjs +9 -3
- package/src/server/login-service.mjs +95 -12
- package/src/server/npm-upgrade.mjs +12 -4
- package/src/server/pinned-build.mjs +3 -0
- package/src/server/prefs-routes.mjs +5 -8
- package/src/server/prefs-state.mjs +9 -1
- package/src/server/quota-oauth.mjs +88 -13
- package/src/server/quota-shape.mjs +28 -0
- package/src/server/quota.mjs +68 -17
- package/src/server/rating.mjs +87 -0
- package/src/server/reports-routes.mjs +21 -2
- package/src/server/reports.mjs +672 -64
- package/src/server/ring-bounds.mjs +11 -0
- package/src/server/running-deck.mjs +48 -0
- package/src/server/self-update.mjs +1 -1
- package/src/server/session-enrichment.mjs +14 -6
- package/src/server/stable-node.mjs +68 -0
- package/src/server/static-serve.mjs +5 -0
- package/src/server/supervisor.mjs +21 -15
- package/src/server/tailscale.mjs +8 -4
- package/src/server/transcript-scan.mjs +7 -4
- package/src/server/usage-day.mjs +76 -11
- package/dist/web/assets/BrowserWatchModal-BCrDvuug.js +0 -1
- package/dist/web/assets/UsageHistoryModal-BtRCuo33.js +0 -1
- package/dist/web/assets/index-BkXTN4dZ.css +0 -1
- package/dist/web/assets/index-tVBMXvDv.js +0 -306
package/src/server/event-log.mjs
CHANGED
|
@@ -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
|
|
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
|
|
20
|
-
// this costs no dependency.
|
|
21
|
-
// (undici's multipart reader shipped inside it);
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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")
|
|
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)
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
144
|
+
parts.push(`${head(IMAGES_FIELD, `; filename="image-${index + 1}.${extension}"`)}Content-Type: ${type}\r\n\r\n`, data, "\r\n");
|
|
123
145
|
}
|
|
124
|
-
|
|
146
|
+
parts.push(`--${boundary}--\r\n`);
|
|
147
|
+
return new Blob(parts, { type: `multipart/form-data; boundary=${boundary}` });
|
|
125
148
|
}
|
package/src/server/index.mjs
CHANGED
|
@@ -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 {
|
|
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
|
+
}
|
package/src/server/installer.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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] : []);
|
package/src/server/lan-deck.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
39
|
-
* it travels — the question a round asks, and
|
|
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,
|
|
44
|
-
...currentFor(accounts,
|
|
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
|
|
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" ?
|
|
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.
|
package/src/server/lan-serve.mjs
CHANGED
|
@@ -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().
|
|
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
|