ccdeck 3.36.8 → 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/leaving.js +54 -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 +45 -0
- 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 +3 -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 +704 -63
- 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-CeVGLslQ.js +0 -1
- package/dist/web/assets/UsageHistoryModal-BcUzGS2-.js +0 -1
- package/dist/web/assets/index-4ecbC_Cy.js +0 -306
- package/dist/web/assets/index-BkXTN4dZ.css +0 -1
package/src/server/lan-sync.mjs
CHANGED
|
@@ -101,8 +101,11 @@
|
|
|
101
101
|
// downloads a week and four dependencies of its own; running unmaintained
|
|
102
102
|
// cryptography to protect live credentials is worse than the plain construction
|
|
103
103
|
// in lan-wire.mjs.
|
|
104
|
-
import {
|
|
104
|
+
import { execFileSync } from "node:child_process";
|
|
105
|
+
import { createHash, createHmac } from "node:crypto";
|
|
106
|
+
import { readFileSync } from "node:fs";
|
|
105
107
|
import os from "node:os";
|
|
108
|
+
import { win32 as winPath } from "node:path";
|
|
106
109
|
// What two decks say once one has dialled the other — the keys, the proofs and
|
|
107
110
|
// the seals — is lan-wire.mjs's, and so is PROTOCOL, which the beacon below
|
|
108
111
|
// carries: every label there is derived under it, and that file cannot import
|
|
@@ -211,9 +214,74 @@ export function cleanName(raw, fallback = "unnamed deck") {
|
|
|
211
214
|
* The home directory is in it so that a copied `~/.claude` — which is how two
|
|
212
215
|
* real machines end up holding one key, and the reason `id-clash` exists —
|
|
213
216
|
* still reads as two machines when the hostnames differ, which they do.
|
|
217
|
+
*
|
|
218
|
+
* AND KEYED, because a plain hash of those two hid neither. The hostname rides
|
|
219
|
+
* in the same packet as the deck's name on a deck nobody renamed, which left
|
|
220
|
+
* only the home directory to guess — and /Users/<name>, /home/<name> and
|
|
221
|
+
* C:\Users\<name> over a list of first names is a guess that lands. The key is
|
|
222
|
+
* a secret this machine already has and never sends (see machineSecret): the
|
|
223
|
+
* same for every deck on it, so they still agree without writing anything, and
|
|
224
|
+
* another machine's own, so a copied config reads as another machine even under
|
|
225
|
+
* the same hostname. A machine with no such secret to read keys with nothing
|
|
226
|
+
* and sends the plain hash every deck sent before (see formerHostId).
|
|
227
|
+
*/
|
|
228
|
+
export function hostId({ hostname = os.hostname(), home = os.homedir(), key = machineKey() } = {}) {
|
|
229
|
+
const input = `${machineName(hostname)}\u0000${home}`;
|
|
230
|
+
if (!key) return createHash("sha256").update(input).digest("hex").slice(0, 12);
|
|
231
|
+
return createHmac("sha256", key).update(`ccdeck host id\u0000${input}`).digest("hex").slice(0, 12);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* This machine's id as a deck from before the key sends it — the plain hash.
|
|
236
|
+
*
|
|
237
|
+
* Never sent by this one. Read so that a beacon carrying it is known for what
|
|
238
|
+
* it is: an older deck on this same computer, which a newer one must not take
|
|
239
|
+
* for a stranger to pair with or for a copy of its key (see beaconVerdict). It
|
|
240
|
+
* names this machine and no other, because its inputs are this machine's.
|
|
241
|
+
*/
|
|
242
|
+
export function formerHostId({ hostname = os.hostname(), home = os.homedir() } = {}) {
|
|
243
|
+
return hostId({ hostname, home, key: "" });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The secret hostId is keyed with: an id the operating system keeps for this
|
|
248
|
+
* machine, readable by any user on it and never sent anywhere by this deck —
|
|
249
|
+
* systemd's (or D-Bus's) machine id on Linux, the platform UUID on a Mac, the
|
|
250
|
+
* machine GUID on Windows. Null where none can be read: a container without a
|
|
251
|
+
* machine id, a tool that would not run.
|
|
252
|
+
*
|
|
253
|
+
* Only ever the key of an HMAC, never the message and never on the wire, which
|
|
254
|
+
* is how systemd asks for its machine id to be used by an application.
|
|
214
255
|
*/
|
|
215
|
-
export function
|
|
216
|
-
|
|
256
|
+
export function machineSecret({ platform = process.platform, env = process.env, readFile = readFileSync, run = execFileSync } = {}) {
|
|
257
|
+
const tool = (file, args, pattern) => {
|
|
258
|
+
try {
|
|
259
|
+
const out = run(file, args, { encoding: "utf8", timeout: 3_000, windowsHide: true, stdio: ["ignore", "pipe", "ignore"] });
|
|
260
|
+
return pattern.exec(String(out ?? ""))?.[1] ?? null;
|
|
261
|
+
} catch { return null; }
|
|
262
|
+
};
|
|
263
|
+
if (platform === "darwin") {
|
|
264
|
+
return tool("/usr/sbin/ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"], /"IOPlatformUUID"\s*=\s*"([0-9A-Fa-f-]{16,})"/);
|
|
265
|
+
}
|
|
266
|
+
if (platform === "win32") {
|
|
267
|
+
const reg = winPath.join(env.SystemRoot || env.windir || "C:\\Windows", "System32", "reg.exe");
|
|
268
|
+
return tool(reg, ["query", "HKLM\\SOFTWARE\\Microsoft\\Cryptography", "/v", "MachineGuid"], /MachineGuid\s+REG_SZ\s+([0-9A-Fa-f-]{16,})/);
|
|
269
|
+
}
|
|
270
|
+
for (const file of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
|
|
271
|
+
try {
|
|
272
|
+
const id = String(readFile(file, "utf8") ?? "").trim();
|
|
273
|
+
if (/^[0-9A-Fa-f-]{16,}$/.test(id)) return id;
|
|
274
|
+
} catch { /* the next place, or none */ }
|
|
275
|
+
}
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** machineSecret, read once per process: it cannot change while one runs, and
|
|
280
|
+
* on a Mac or Windows it is a process to start. Empty when there is none. */
|
|
281
|
+
let machineKeyRead = null;
|
|
282
|
+
function machineKey() {
|
|
283
|
+
machineKeyRead ??= machineSecret() ?? "";
|
|
284
|
+
return machineKeyRead;
|
|
217
285
|
}
|
|
218
286
|
|
|
219
287
|
/**
|
|
@@ -325,15 +393,18 @@ export function readBeacon(buf, { maxBytes = MAX_BEACON_BYTES } = {}) {
|
|
|
325
393
|
* broadcast on every interface it owns, and filtering by address would need a
|
|
326
394
|
* list of them that changes when a VPN comes up.
|
|
327
395
|
*/
|
|
328
|
-
export function beaconVerdict(beacon, { selfFp, selfInstance, selfHost, trusted } = {}) {
|
|
396
|
+
export function beaconVerdict(beacon, { selfFp, selfInstance, selfHost, formerHost, trusted } = {}) {
|
|
329
397
|
if (!beacon) return "unreadable";
|
|
398
|
+
// THIS COMPUTER'S ID, as this deck sends it or as a deck from before the key
|
|
399
|
+
// did (see formerHostId) — both name this machine and no other.
|
|
400
|
+
const here = !!selfHost && !!beacon.host && (beacon.host === selfHost || (!!formerHost && beacon.host === formerHost));
|
|
330
401
|
// ANOTHER DECK ON THIS COMPUTER. Not this process — a different key, honestly
|
|
331
402
|
// its own — and still nothing to pair with: both read one claude-swap store,
|
|
332
403
|
// so neither holds a login the other could heal. It used to be filtered by
|
|
333
404
|
// the address the packet came from, which is true and needs the list of this
|
|
334
405
|
// machine's addresses to be current; the machine's own id needs nothing and
|
|
335
406
|
// is the same answer.
|
|
336
|
-
if (
|
|
407
|
+
if (here && beacon.fp !== selfFp) return "self";
|
|
337
408
|
if (beacon.fp === selfFp) {
|
|
338
409
|
// OUR OWN NAME, FROM SOMEBODY ELSE'S PROCESS. Two decks sharing a config
|
|
339
410
|
// directory hold the same key — and so does the second machine when
|
|
@@ -363,7 +434,7 @@ export function beaconVerdict(beacon, { selfFp, selfInstance, selfHost, trusted
|
|
|
363
434
|
// the OTHER one: a `~/.claude` copied to a second machine, where two real
|
|
364
435
|
// decks would otherwise be permanently invisible to each other.
|
|
365
436
|
if (selfInstance && beacon.instance !== selfInstance) {
|
|
366
|
-
return
|
|
437
|
+
return here ? "self" : "id-clash";
|
|
367
438
|
}
|
|
368
439
|
return "self";
|
|
369
440
|
}
|
|
@@ -430,6 +501,26 @@ export function dropTrusted(trusted, fp) {
|
|
|
430
501
|
return (Array.isArray(trusted) ? trusted : []).filter(t => t && t.fp !== fp);
|
|
431
502
|
}
|
|
432
503
|
|
|
504
|
+
/**
|
|
505
|
+
* The accounts this deck offers the paired deck `fp`: everything ticked here,
|
|
506
|
+
* less what an arrival ticked when that deck is one the accept switch paired.
|
|
507
|
+
*
|
|
508
|
+
* An account that arrives from a deck somebody here chose is ticked on arrival
|
|
509
|
+
* (#1188), and `onward` keeps which ticks those were. The reasoning for that
|
|
510
|
+
* tick is that the group already has the login, and the group is the decks a
|
|
511
|
+
* person here chose. A deck the switch paired (`auto`, see addTrusted) is
|
|
512
|
+
* offered what a person ticked and nothing else, which is what keeps the
|
|
513
|
+
* switch's promise: a deck that pairs is offered nothing until somebody ticks
|
|
514
|
+
* a login here. A person who unticks and ticks the account again takes the
|
|
515
|
+
* mark off, and from then on it is offered like any other tick.
|
|
516
|
+
*/
|
|
517
|
+
export function sharedWith(cfg, fp) {
|
|
518
|
+
const shared = Array.isArray(cfg?.shared) ? cfg.shared : [];
|
|
519
|
+
if (trustedPeer(cfg?.trusted, fp)?.auto !== true) return shared;
|
|
520
|
+
const onward = Array.isArray(cfg?.onward) ? cfg.onward : [];
|
|
521
|
+
return shared.filter(key => !onward.includes(key));
|
|
522
|
+
}
|
|
523
|
+
|
|
433
524
|
/**
|
|
434
525
|
* The decks worth offering to pair with, out of everything that has ever been
|
|
435
526
|
* heard.
|
package/src/server/lifecycle.mjs
CHANGED
|
@@ -47,6 +47,11 @@ let _onRestart = null;
|
|
|
47
47
|
// directly, or an embedder — and /api/shutdown then answers 501 rather than
|
|
48
48
|
// pretending.
|
|
49
49
|
let _onStop = null;
|
|
50
|
+
// Told when the deck starts updating itself — the Upgrade press, the npx
|
|
51
|
+
// relaunch onto the latest, the away-update — so the reporter can write down
|
|
52
|
+
// that the next version came from the deck (index.mjs hands it in). Absent,
|
|
53
|
+
// nothing is told.
|
|
54
|
+
let _onSelfUpdate = null;
|
|
50
55
|
// A stop is in flight. Two sockets asking at once must not both start one, and
|
|
51
56
|
// the response's 'finish' and 'close' can both fire for the same request.
|
|
52
57
|
let _stopping = false;
|
|
@@ -66,9 +71,10 @@ let _deckReady = false;
|
|
|
66
71
|
* and the stop it may hand to, whether a restart would have a log to replay,
|
|
67
72
|
* and the two in-flight flags and the ready flag, all back to a fresh boot's.
|
|
68
73
|
*/
|
|
69
|
-
function armLifecycle({ onRestart, onStop, persist }) {
|
|
74
|
+
function armLifecycle({ onRestart, onStop, persist, onSelfUpdate = null }) {
|
|
70
75
|
_onRestart = typeof onRestart === "function" ? onRestart : null;
|
|
71
76
|
_onStop = typeof onStop === "function" ? onStop : null;
|
|
77
|
+
_onSelfUpdate = typeof onSelfUpdate === "function" ? onSelfUpdate : null;
|
|
72
78
|
_stopping = false;
|
|
73
79
|
_canRestart = _onRestart != null && persist != null;
|
|
74
80
|
// A new listener is a new boot, whatever a previous one had got as far as
|
|
@@ -155,9 +161,16 @@ async function handleUpgrade(_req, res) {
|
|
|
155
161
|
// lands in the first moments of a boot, or on a server nothing marked ready.
|
|
156
162
|
await pinRunningBuild();
|
|
157
163
|
const out = startUpgrade({ pkgRoot: PKG_ROOT });
|
|
164
|
+
if (out.ok) selfUpdateStarted();
|
|
158
165
|
send(res, out.ok ? 200 : 409, out);
|
|
159
166
|
}
|
|
160
167
|
|
|
168
|
+
/** The deck is updating itself: say so to whoever armed it (the reporter's
|
|
169
|
+
* note, so the next version's "update" can tell). Never throws. */
|
|
170
|
+
function selfUpdateStarted() {
|
|
171
|
+
try { void Promise.resolve(_onSelfUpdate?.()).catch(() => {}); } catch { /* a note, nothing more */ }
|
|
172
|
+
}
|
|
173
|
+
|
|
161
174
|
// Restart is a two-party act: this half answers before it stops listening, so
|
|
162
175
|
// the caller learns it was accepted rather than losing the socket mid-reply.
|
|
163
176
|
//
|
|
@@ -214,6 +227,7 @@ async function handleRestart(req, res) {
|
|
|
214
227
|
}
|
|
215
228
|
}
|
|
216
229
|
_restarting = true;
|
|
230
|
+
if (mode === "npx") selfUpdateStarted();
|
|
217
231
|
// Accepted either way — the ask is good and the launcher holds on to it — but
|
|
218
232
|
// the two are not the same event and answering 200 to both would be this
|
|
219
233
|
// window's second untruth rather than its first. Between this listener
|
|
@@ -394,13 +408,15 @@ async function awayUpdateTick() {
|
|
|
394
408
|
// Pinned before npm is spawned, never after it — see pinned-build.mjs. On a
|
|
395
409
|
// deck bin/deck.js has marked ready this is a promise that settled long ago.
|
|
396
410
|
await pinRunningBuild();
|
|
397
|
-
su.startUpgrade({ pkgRoot: PKG_ROOT });
|
|
411
|
+
const started = su.startUpgrade({ pkgRoot: PKG_ROOT });
|
|
412
|
+
if (started?.ok) selfUpdateStarted();
|
|
398
413
|
return step.act;
|
|
399
414
|
}
|
|
400
415
|
// handleRestart's own check for the npx path, for the same reason: the mode
|
|
401
416
|
// comes from the install on disk, and a spec it cannot name is no relaunch.
|
|
402
417
|
if (step.act === "npx" && !su.npxRestartSpec(PKG_ROOT)) return null;
|
|
403
418
|
_restarting = true;
|
|
419
|
+
if (step.act === "npx") selfUpdateStarted();
|
|
404
420
|
handOffRestart(step.act === "npx" ? "npx" : null);
|
|
405
421
|
return step.act;
|
|
406
422
|
}
|
|
@@ -11,7 +11,7 @@ import { existsSync } from "node:fs";
|
|
|
11
11
|
import { PRODUCT } from "./brand.mjs";
|
|
12
12
|
import { codexCwdInWorkspace } from "./log-election.mjs";
|
|
13
13
|
import { linesFromEnd, linesFromStart } from "./log-tail.mjs";
|
|
14
|
-
import { ENVELOPE_CHARS, MAX_BUFFER, MAX_BUFFER_CHARS, MAX_RING_ENTRIES, isEnrichment, payloadChars } from "./ring-bounds.mjs";
|
|
14
|
+
import { ENVELOPE_CHARS, MAX_BUFFER, MAX_BUFFER_CHARS, MAX_RING_ENTRIES, isEnrichment, isReservedEventName, payloadChars } from "./ring-bounds.mjs";
|
|
15
15
|
// event-pipeline.mjs's pushEvent, reached without importing it — see
|
|
16
16
|
// event-sink.mjs.
|
|
17
17
|
import { pushEvent } from "./event-sink.mjs";
|
|
@@ -60,7 +60,8 @@ import { pushEvent } from "./event-sink.mjs";
|
|
|
60
60
|
* everything before it. It has not been written to the log since #698, but
|
|
61
61
|
* logs the decks before that wrote still carry it, and dropping it on a
|
|
62
62
|
* scoped deck would replay the state a user had explicitly cleared, so it
|
|
63
|
-
* is always admitted
|
|
63
|
+
* is always admitted — when the server recorded it, which replayLog
|
|
64
|
+
* checks before asking this (see `usable` there).
|
|
64
65
|
* * a payload with no cwd and no session the map has seen — refused on a
|
|
65
66
|
* scoped deck, which is exactly what `capturesSession` decides live for a
|
|
66
67
|
* session that never said where it runs.
|
|
@@ -324,7 +325,12 @@ export async function replayLog(filePath, workspace = "", {
|
|
|
324
325
|
return null;
|
|
325
326
|
}
|
|
326
327
|
};
|
|
327
|
-
|
|
328
|
+
// A control marker counts only when the server itself recorded it: the one
|
|
329
|
+
// writer of `__clear` has always stamped it `internal`, and since #698 it is
|
|
330
|
+
// not written at all. A line under a reserved name from anywhere else is not
|
|
331
|
+
// the deck's instruction, and replaying it would hide everything before it.
|
|
332
|
+
const usable = (evt) => evt && typeof evt === "object" && evt.payload
|
|
333
|
+
&& (evt.source === "internal" || !isReservedEventName(evt.payload.hook_event_name));
|
|
328
334
|
|
|
329
335
|
let count = 0;
|
|
330
336
|
if (admits.orderDependent) {
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
// tested. Two policies over one process is how `ccdeck --stop` becomes a
|
|
33
33
|
// suggestion the machine overrules a second later.
|
|
34
34
|
import { inApp } from "./app-host.mjs";
|
|
35
|
+
import { stableNodePath } from "./stable-node.mjs";
|
|
35
36
|
import { spawnSync } from "node:child_process";
|
|
36
37
|
import { homedir } from "node:os";
|
|
37
38
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
@@ -123,6 +124,10 @@ ${vars}
|
|
|
123
124
|
`;
|
|
124
125
|
}
|
|
125
126
|
|
|
127
|
+
/** A value as systemd will read it back: `%` starts a specifier anywhere in a
|
|
128
|
+
* unit, and `%%` is the literal. */
|
|
129
|
+
const sdEscape = (s) => String(s).replace(/%/g, "%%");
|
|
130
|
+
|
|
126
131
|
/**
|
|
127
132
|
* The systemd --user unit.
|
|
128
133
|
*
|
|
@@ -130,7 +135,19 @@ ${vars}
|
|
|
130
135
|
* still receiving updates. No Restart=, for the reason in the header.
|
|
131
136
|
*/
|
|
132
137
|
export function unitFor({ execPath, script, logPath, args = [], env = {}, product = "ccdeck" } = {}) {
|
|
133
|
-
|
|
138
|
+
// EVERY VALUE, not only the Environment= lines below. systemd expands `%`
|
|
139
|
+
// in StandardOutput=/StandardError= and in ExecStart as well, and `$NAME` in
|
|
140
|
+
// ExecStart; those were written raw, so a deck home like the
|
|
141
|
+
// `/home/ana/100%backup` below had its log path rewritten — systemd 259
|
|
142
|
+
// drops the setting with "Failed to resolve unit specifiers … ignoring" for
|
|
143
|
+
// an unknown one and substitutes a known one (`%b`, the boot ID) silently —
|
|
144
|
+
// and the same `%` in an install path made ExecStart a fatal error.
|
|
145
|
+
// An argument with whitespace, a quote or a backslash is quoted, with the
|
|
146
|
+
// C escapes systemd's word splitting reads inside the quotes.
|
|
147
|
+
const cmd = [execPath, script, ...args].map(a => {
|
|
148
|
+
const v = sdEscape(a).replace(/\$/g, "$$$$");
|
|
149
|
+
return /[\s"'\\]/.test(v) ? `"${v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` : v;
|
|
150
|
+
}).join(" ");
|
|
134
151
|
// QUOTED, AND `%` ESCAPED. systemd.exec(5) splits an Environment= assignment
|
|
135
152
|
// on whitespace unless the whole thing is double-quoted, and it expands `%`
|
|
136
153
|
// specifiers in the value. ExecStart above quotes its own arguments; this
|
|
@@ -146,10 +163,15 @@ export function unitFor({ execPath, script, logPath, args = [], env = {}, produc
|
|
|
146
163
|
// decks and two LAN keys on one machine". `%b` is the boot-ID specifier, so
|
|
147
164
|
// `/home/ana/100%backup` was rewritten just as quietly.
|
|
148
165
|
//
|
|
166
|
+
// AND `"` AND `\` ESCAPED INSIDE THOSE QUOTES, which systemd reads with C
|
|
167
|
+
// escapes: a `"` in the value closed them early and a `\` began an escape,
|
|
168
|
+
// so `/home/ana/a "quote` and a value ending in `\` were dropped as
|
|
169
|
+
// "Invalid syntax, ignoring" and `back\slash` came back with a space in it.
|
|
170
|
+
//
|
|
149
171
|
// The macOS branch was always safe: plistFor puts each value in its own
|
|
150
172
|
// <string> and XML-escapes it. Windows carries none of them.
|
|
151
173
|
const vars = Object.entries({ AGENTS_DECK_DETACHED: "1", ...env })
|
|
152
|
-
.map(([k, v]) => `Environment="${k}=${
|
|
174
|
+
.map(([k, v]) => `Environment="${sdEscape(`${k}=${v}`).replace(/[\\"]/g, "\\$&")}"`).join("\n");
|
|
153
175
|
return `[Unit]
|
|
154
176
|
Description=${product} — live deck of Claude Code + Codex agents
|
|
155
177
|
After=default.target
|
|
@@ -158,8 +180,8 @@ After=default.target
|
|
|
158
180
|
Type=simple
|
|
159
181
|
${vars}
|
|
160
182
|
ExecStart=${cmd}
|
|
161
|
-
StandardOutput=append:${logPath}
|
|
162
|
-
StandardError=append:${logPath}
|
|
183
|
+
StandardOutput=append:${sdEscape(logPath)}
|
|
184
|
+
StandardError=append:${sdEscape(logPath)}
|
|
163
185
|
|
|
164
186
|
[Install]
|
|
165
187
|
WantedBy=default.target
|
|
@@ -250,7 +272,9 @@ export function taskXmlFor({ execPath, script, args = [], product = "ccdeck" } =
|
|
|
250
272
|
*
|
|
251
273
|
* `npx` runs out of ~/.npm/_npx/<hash>/, which npm deletes whenever it feels
|
|
252
274
|
* like it. A login item pointing at nothing, forever, on a machine where the
|
|
253
|
-
* user never installed anything.
|
|
275
|
+
* user never installed anything. pnpm dlx, bunx and yarn dlx are the same
|
|
276
|
+
* case in another cache or temp folder, and callers pass `npx` for all four
|
|
277
|
+
* (isOneOffRun).
|
|
254
278
|
*
|
|
255
279
|
* A CHECKOUT is somebody's working tree. Found by running this: a test boot
|
|
256
280
|
* from the repo on the author's own machine wrote a LaunchAgent naming
|
|
@@ -354,8 +378,40 @@ export function lingerState({ platform = process.platform, run = spawnSync, user
|
|
|
354
378
|
export const SCOPE_VARS = Object.freeze(["CLAUDE_CONFIG_DIR", "CCDECK_HOME", "CODEX_HOME"]);
|
|
355
379
|
|
|
356
380
|
export function scopeEnv(env = process.env) {
|
|
381
|
+
return setOnes(SCOPE_VARS, env);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* …and the variables that decide what that deck DOES: every other row of
|
|
386
|
+
* README's environment table, by name.
|
|
387
|
+
*
|
|
388
|
+
* Lost the same way the scope directories were, and with a cost the user had
|
|
389
|
+
* already said no to. `AGENTS_DECK_NO_REPORTS=1 ccdeck`, or the variable in
|
|
390
|
+
* ~/.zshrc, installed an item on the first start whose deck ran at the next
|
|
391
|
+
* login with no veto: an install event with the device fingerprint, a daily
|
|
392
|
+
* "active", and — AGENTS_DECK_NO_LAN gone too — the beacon on the local
|
|
393
|
+
* network. A terminal exporting the variable afterwards attached to that deck
|
|
394
|
+
* rather than starting one of its own, so not even it was spared.
|
|
395
|
+
*
|
|
396
|
+
* BY NAME, NOT BY PREFIX. A shell holds tokens and keys a login item has no
|
|
397
|
+
* business copying into a file in ~/Library/LaunchAgents; the list is what the
|
|
398
|
+
* deck documents and reads, and a test holds it to README's table. Only the
|
|
399
|
+
* ones that are set, for the reason scopeEnv gives.
|
|
400
|
+
*/
|
|
401
|
+
export const SETTING_VARS = Object.freeze([
|
|
402
|
+
"AGENT_DAG_PORT",
|
|
403
|
+
"AGENTS_DECK_NO_INSTALL", "AGENTS_DECK_NO_DOWNLOAD", "AGENTS_DECK_NO_UPDATE_CHECK", "AGENTS_DECK_NO_STATUS",
|
|
404
|
+
"AGENTS_DECK_NO_FRESHEN", "AGENTS_DECK_NO_NOTIFY", "AGENTS_DECK_NO_LAN", "AGENTS_DECK_NO_REPORTS",
|
|
405
|
+
"AGENTS_DECK_CSWAP", "AGENTS_DECK_CLAUDE", "AGENTS_DECK_CCUSAGE", "CLAUDE_SWAP_BACKUP", "AGENTS_DECK_LHM_PORT",
|
|
406
|
+
]);
|
|
407
|
+
|
|
408
|
+
export function settingsEnv(env = process.env) {
|
|
409
|
+
return setOnes(SETTING_VARS, env);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function setOnes(names, env) {
|
|
357
413
|
return Object.fromEntries(
|
|
358
|
-
|
|
414
|
+
names.filter(k => typeof env?.[k] === "string" && env[k].trim() !== "").map(k => [k, env[k]]),
|
|
359
415
|
);
|
|
360
416
|
}
|
|
361
417
|
|
|
@@ -394,7 +450,11 @@ export function installService({
|
|
|
394
450
|
platform = process.platform,
|
|
395
451
|
home = homedir(),
|
|
396
452
|
env = process.env,
|
|
397
|
-
|
|
453
|
+
// The node an upgrade of the same install does not delete, which is not
|
|
454
|
+
// `process.execPath` on Homebrew or a linked tarball — see stable-node.mjs.
|
|
455
|
+
// Looked up on THIS machine whatever `platform` asks about: it is this
|
|
456
|
+
// machine's node that the job will run.
|
|
457
|
+
execPath = stableNodePath({ env }),
|
|
398
458
|
script,
|
|
399
459
|
logPath,
|
|
400
460
|
// `--at-login` because registering the job also STARTS it, on Linux and
|
|
@@ -422,12 +482,29 @@ export function installService({
|
|
|
422
482
|
// genuinely wants a different job environment — the sandboxed end-to-end
|
|
423
483
|
// test, which passes HOME and the deck's own directories — still says so,
|
|
424
484
|
// and replaces this outright rather than adding to it.
|
|
425
|
-
|
|
485
|
+
//
|
|
486
|
+
// The opt-outs ride along for the same reason (settingsEnv): an item that
|
|
487
|
+
// starts a deck without the shell's AGENTS_DECK_NO_REPORTS is a deck that
|
|
488
|
+
// reports from the next login on.
|
|
489
|
+
serviceEnv = { ...scopeEnv(env), ...settingsEnv(env) },
|
|
426
490
|
product = "ccdeck",
|
|
427
491
|
fs = { mkdirSync, writeFileSync },
|
|
428
492
|
run = spawnSync,
|
|
493
|
+
// Whether systemd is this machine's init — sd_booted(3)'s own test. Only
|
|
494
|
+
// this machine can be asked, so a Linux job written from anywhere else is
|
|
495
|
+
// taken to have one.
|
|
496
|
+
systemd = () => process.platform !== "linux" || existsSync("/run/systemd/system"),
|
|
429
497
|
} = {}) {
|
|
430
498
|
const path = servicePath(platform, home, env);
|
|
499
|
+
// NO SYSTEMD, NO LOGIN ITEM — Alpine and Gentoo on OpenRC, Void, Devuan, a
|
|
500
|
+
// container, WSL with systemd off. A unit written there is read by nothing,
|
|
501
|
+
// and `systemctl` is missing or refuses; the refusal used to read as
|
|
502
|
+
// "file-only", so the first start said the deck would start at login, saved
|
|
503
|
+
// the item as installed and never offered again. Asked before anything is
|
|
504
|
+
// written, so nothing is left behind in a directory nobody reads.
|
|
505
|
+
if (platform === "linux" && !systemd()) {
|
|
506
|
+
return { ok: false, path, reason: "this machine does not run systemd" };
|
|
507
|
+
}
|
|
431
508
|
try {
|
|
432
509
|
// BEFORE THE SERVICE MANAGER IS TOLD ANYTHING. launchd opens
|
|
433
510
|
// StandardOutPath when it starts the job and systemd opens
|
|
@@ -464,11 +541,17 @@ export function installService({
|
|
|
464
541
|
fs.writeFileSync(path, body, { mode: 0o644 });
|
|
465
542
|
const cmd = registerCommand(platform, path);
|
|
466
543
|
const out = run(cmd.file, cmd.args, { encoding: "utf8" });
|
|
467
|
-
// A registration that failed still leaves the file
|
|
468
|
-
//
|
|
469
|
-
// from then on
|
|
544
|
+
// A registration that failed still leaves the file. launchd loads every
|
|
545
|
+
// plist in ~/Library/LaunchAgents at the next login, so on a Mac the item
|
|
546
|
+
// works from then on. systemd does not: it starts at login only a user
|
|
547
|
+
// unit that is ENABLED, which is the link the failed `enable` never made,
|
|
548
|
+
// so the verdict carries the command that makes it. Said, not hidden, and
|
|
549
|
+
// not treated as fatal.
|
|
470
550
|
if (out?.status !== 0) {
|
|
471
|
-
|
|
551
|
+
const reason = oneLine(out?.stderr || out?.stdout) || `${cmd.file} refused`;
|
|
552
|
+
return platform === "darwin"
|
|
553
|
+
? { ok: true, path, how: "file-only", reason }
|
|
554
|
+
: { ok: true, path, how: "file-only", reason, enable: `systemctl --user enable ${SERVICE_LABEL}.service` };
|
|
472
555
|
}
|
|
473
556
|
return { ok: true, path, how: cmd.file };
|
|
474
557
|
} catch (err) {
|
|
@@ -16,7 +16,7 @@ import { join, resolve } from "node:path";
|
|
|
16
16
|
import { killTree, shimPath, spawnSpec } from "./exec.mjs";
|
|
17
17
|
import { npmCliLaunch } from "./npx.mjs";
|
|
18
18
|
import {
|
|
19
|
-
frozenNameInstall, hostPackage, installedName, isGitCheckout, isNpxInstall,
|
|
19
|
+
frozenNameInstall, hostPackage, installedName, isGitCheckout, isNpxInstall, isOneOffRun,
|
|
20
20
|
PUBLISHED_NAME, successorRoot, upgradeCommand, upgradeName,
|
|
21
21
|
} from "./install-layout.mjs";
|
|
22
22
|
|
|
@@ -78,7 +78,7 @@ export function upgradeSpec(target, platform = process.platform, deps) {
|
|
|
78
78
|
* Pure so the policy can be read and tested on its own — it is the part that
|
|
79
79
|
* decides whether we are allowed to write to the user's machine.
|
|
80
80
|
*/
|
|
81
|
-
export function upgradeBlockedReason({ git, npx, writable, optedOut, frozen }) {
|
|
81
|
+
export function upgradeBlockedReason({ git, npx, oneOff, writable, optedOut, frozen }) {
|
|
82
82
|
if (optedOut) return "opted_out";
|
|
83
83
|
// The maintainer's own tree. Its version leads npm's, and installing over it
|
|
84
84
|
// would replace a working copy with a published tarball.
|
|
@@ -87,6 +87,12 @@ export function upgradeBlockedReason({ git, npx, writable, optedOut, frozen }) {
|
|
|
87
87
|
// place — `npx agents-deck@latest` fetches a DIFFERENT directory, which this
|
|
88
88
|
// process could not switch to even after restarting.
|
|
89
89
|
if (npx) return "npx";
|
|
90
|
+
// pnpm dlx, bunx and yarn dlx: a cache or temp folder as well, but with no
|
|
91
|
+
// spec recorded to re-run the way npx's is. Writable, so it looked like a
|
|
92
|
+
// global install, and `npm i -g` from here installs a copy this process
|
|
93
|
+
// never runs — the running version never moves, and the away-update tried
|
|
94
|
+
// again every thirty minutes. The command is shown instead.
|
|
95
|
+
if (oneOff) return "one_off";
|
|
90
96
|
// A flat install of a retired name: the one shape where `npm i -g <us>@latest`
|
|
91
97
|
// runs cleanly, reports success, and leaves no deck behind — the registry
|
|
92
98
|
// serves a 5 KB pointer for those names now, and npm's reify removes bin/,
|
|
@@ -108,8 +114,9 @@ export function upgradeBlockedReason({ git, npx, writable, optedOut, frozen }) {
|
|
|
108
114
|
* "install" — `npm i -g` here and restart into the new files.
|
|
109
115
|
* "npx" — nothing to install: the supervisor re-runs `npx -y <spec>`,
|
|
110
116
|
* which fetches a NEW cache directory and hands the port to it.
|
|
111
|
-
* null — a checkout, an unwritable prefix, or
|
|
112
|
-
* user gets the command and does it
|
|
117
|
+
* null — a checkout, a one-off runner's cache, an unwritable prefix, or
|
|
118
|
+
* an explicit opt-out; the user gets the command and does it
|
|
119
|
+
* themselves.
|
|
113
120
|
*
|
|
114
121
|
* Pure, so the policy is one readable expression rather than three conditions
|
|
115
122
|
* spread across the server and the UI.
|
|
@@ -182,6 +189,7 @@ export function upgradeBlock(pkgRoot) {
|
|
|
182
189
|
return upgradeBlockedReason({
|
|
183
190
|
git: isGitCheckout(pkgRoot),
|
|
184
191
|
npx: isNpxInstall(pkgRoot),
|
|
192
|
+
oneOff: isOneOffRun(pkgRoot),
|
|
185
193
|
// npm -g rewrites the package directory and its parent (the global
|
|
186
194
|
// node_modules), so both have to be ours to write.
|
|
187
195
|
writable: dirWritable(target) && dirWritable(resolve(target, "..")),
|
|
@@ -89,6 +89,9 @@ const PINNED_MODULES = [
|
|
|
89
89
|
"cli-versions.mjs",
|
|
90
90
|
"session-tracking.mjs",
|
|
91
91
|
"session-enrichment.mjs",
|
|
92
|
+
// The plans the daily report says, read through import() by depth-facts.mjs.
|
|
93
|
+
"quota-oauth.mjs",
|
|
94
|
+
"codex-auth.mjs",
|
|
92
95
|
];
|
|
93
96
|
|
|
94
97
|
let _pinned = null;
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// engine had (lan-deck.mjs): what is left of them is a body read, a write
|
|
8
8
|
// through the one, a push to the other, and an answer. The notifier stays in
|
|
9
9
|
// index.mjs, beside the desktop-app connections it notifies through.
|
|
10
|
-
import { notificationsOn, notificationsVetoed, prefsRefusalDetail, prefsWriteRefusal, publicPrefs, reportsVetoed } from "./deck-prefs.mjs";
|
|
10
|
+
import { notificationsOn, notificationsVetoed, pagePatch, prefsRefusalDetail, prefsWriteRefusal, publicPrefs, reportsVetoed } from "./deck-prefs.mjs";
|
|
11
11
|
import { PRODUCT } from "./brand.mjs";
|
|
12
12
|
import { heldPrefs } from "./prefs-state.mjs";
|
|
13
13
|
import { applyLanPrefs, forgetReach, resetLanLoaded } from "./lan-deck.mjs";
|
|
@@ -52,13 +52,10 @@ export async function handlePrefsWrite(req, res) {
|
|
|
52
52
|
let body = null;
|
|
53
53
|
try { body = JSON.parse(raw ?? ""); } catch { /* handled below */ }
|
|
54
54
|
if (!body || typeof body !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
// ran itself (#1893); a page that could write it could give any account a
|
|
60
|
-
// provenance it never had.
|
|
61
|
-
const { reports: _reports, report: _report, accounts: _accounts, ...patch } = body;
|
|
55
|
+
// Only the fields the page edits. The rest — the LAN key and pairings, the
|
|
56
|
+
// reports switch (#1853), the reporter's state, the accounts the deck signed
|
|
57
|
+
// in (#1893) — are the deck's own and keep what is on disk; see pagePatch.
|
|
58
|
+
const patch = pagePatch(body);
|
|
62
59
|
try {
|
|
63
60
|
await heldPrefs.write(patch);
|
|
64
61
|
} catch (err) {
|
|
@@ -35,6 +35,10 @@ let _prefs = { ...DEFAULTS };
|
|
|
35
35
|
* it. A write that lands first merges onto the file, but leaves the engine on
|
|
36
36
|
* what it made up, so it does not count. */
|
|
37
37
|
let _unread = false;
|
|
38
|
+
/** Whether the boot read found no file there at all. A file it could not read
|
|
39
|
+
* or make sense of was still a deck that ran here, so only "missing" counts.
|
|
40
|
+
* False until the read lands. */
|
|
41
|
+
let _noFile = false;
|
|
38
42
|
|
|
39
43
|
// Read at import, so `consider` has its answer from the first event. LAN sync
|
|
40
44
|
// used to start from this same read, and it must not: bin/deck.js imports the
|
|
@@ -43,7 +47,11 @@ let _unread = false;
|
|
|
43
47
|
// in — which is the `lan sync (listen): EADDRINUSE` line a second `ccdeck`
|
|
44
48
|
// printed above `deck already running`. The engine starts from the listen that
|
|
45
49
|
// 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(() => {});
|
|
50
|
+
export const prefsRead = loadPrefs().then(r => { _prefs = r.prefs; _unread = r.source === "unreadable"; _noFile = r.source === "missing"; }).catch(() => {});
|
|
51
|
+
|
|
52
|
+
/** Whether the boot read found no settings file at all: the first run on this
|
|
53
|
+
* machine, as far as the deck can tell (reports.mjs times only that one). */
|
|
54
|
+
export const bootFoundNoPrefs = () => _noFile;
|
|
47
55
|
|
|
48
56
|
export const heldPrefs = Object.freeze({
|
|
49
57
|
/** What the last read or write left here. Never a disk read — see above. */
|