ccdeck 3.32.1 → 3.33.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 +14 -12
- package/bin/agent-dag.js +6 -0
- package/bin/cli/help.js +2 -0
- package/bin/cli/one-shot.js +32 -13
- package/bin/cli/second-start.js +1 -0
- package/bin/cli/startup.js +34 -0
- package/bin/cli/uninstall.js +47 -2
- package/bin/deck.js +12 -1
- package/dist/web/assets/BrowserWatchModal-B3UjvY82.js +1 -0
- package/dist/web/assets/UsageHistoryModal-DxNI8gR6.js +1 -0
- package/dist/web/assets/index-BIANlQ9A.css +1 -0
- package/dist/web/assets/index-OLDoLpOW.js +299 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +40 -0
- package/src/server/account-projects.mjs +11 -2
- package/src/server/args.mjs +12 -2
- package/src/server/auto-update.mjs +14 -1
- package/src/server/best-of-nostalgia.mjs +2 -1
- package/src/server/block-notify.mjs +114 -11
- package/src/server/browser-history.mjs +81 -4
- package/src/server/browser-presence.mjs +98 -2
- package/src/server/browser-react.mjs +39 -4
- package/src/server/browser-watch-seen.mjs +35 -1
- package/src/server/browser-watch.mjs +105 -18
- package/src/server/cafe-music-bgm.mjs +2 -1
- package/src/server/ccusage-install.mjs +8 -4
- package/src/server/ccusage.mjs +3 -1
- package/src/server/claude-accounts.mjs +38 -16
- package/src/server/codex-dir.mjs +8 -6
- package/src/server/codex-quota.mjs +4 -2
- package/src/server/codex-usage.mjs +93 -38
- package/src/server/codex-watch.mjs +150 -18
- package/src/server/cswap-auto-loop.mjs +15 -2
- package/src/server/cswap-auto-readers.mjs +10 -4
- package/src/server/cswap-auto.mjs +6 -6
- package/src/server/cswap-install.mjs +118 -3
- package/src/server/deck-prefs.mjs +4 -0
- package/src/server/event-pipeline.mjs +3 -0
- package/src/server/event-ring.mjs +2 -2
- package/src/server/good-life-radio.mjs +2 -1
- package/src/server/index.mjs +3 -2
- package/src/server/installer.mjs +62 -12
- package/src/server/lan-beacon.mjs +6 -4
- package/src/server/lan-call.mjs +4 -0
- package/src/server/lan-dials.mjs +10 -1
- package/src/server/lan-engine.mjs +221 -44
- package/src/server/lan-requests.mjs +88 -5
- package/src/server/lan-round-record.mjs +21 -4
- package/src/server/lan-socket.mjs +105 -15
- package/src/server/lan-sync.mjs +18 -4
- package/src/server/lifecycle.mjs +21 -2
- package/src/server/live-radio-mix.mjs +2 -1
- package/src/server/log-replay.mjs +55 -24
- package/src/server/login-service.mjs +38 -3
- package/src/server/metrics-run.mjs +5 -3
- package/src/server/network-sampler.mjs +25 -2
- package/src/server/npm-upgrade.mjs +8 -1
- package/src/server/npx.mjs +32 -5
- package/src/server/process-list.mjs +103 -13
- package/src/server/purge-key.mjs +52 -24
- package/src/server/request-gates.mjs +47 -14
- package/src/server/retire-sound-hook.mjs +56 -9
- package/src/server/ring-bounds.mjs +10 -0
- package/src/server/running-deck.mjs +72 -2
- package/src/server/session-enrichment.mjs +40 -8
- package/src/server/stop-deck.mjs +13 -4
- package/src/server/system-metrics.mjs +2 -2
- package/src/server/transcript-scan.mjs +27 -6
- package/dist/web/assets/BrowserWatchModal-ohP7oQCN.js +0 -1
- package/dist/web/assets/UsageHistoryModal-CNjMaM59.js +0 -1
- package/dist/web/assets/index-DKn7nc15.css +0 -1
- package/dist/web/assets/index-P2zWn3Hw.js +0 -299
package/src/server/npx.mjs
CHANGED
|
@@ -37,8 +37,9 @@ import { killTree, shimPath, spawnSpec } from "./exec.mjs";
|
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
39
|
* Where npm's own `npx-cli.js` sits relative to the running Node binary, most
|
|
40
|
-
* likely first
|
|
41
|
-
* the
|
|
40
|
+
* likely first — or `npm-cli.js`, which `cli` names for npmCliLaunch below.
|
|
41
|
+
* Pure: the platform and the executable path are parameters, so the Windows
|
|
42
|
+
* layout can be checked from any OS.
|
|
42
43
|
*
|
|
43
44
|
* Windows keeps npm beside node.exe (`C:\Program Files\nodejs\node_modules\npm`,
|
|
44
45
|
* and the same shape under nvm-windows). POSIX puts it in a `lib` next to the
|
|
@@ -55,20 +56,46 @@ import { killTree, shimPath, spawnSpec } from "./exec.mjs";
|
|
|
55
56
|
* platform running the SUITE, so a Windows layout checked from macOS would come
|
|
56
57
|
* back with forward slashes and a `..` nothing resolves.
|
|
57
58
|
*/
|
|
58
|
-
export function npxCliCandidates(execPath = process.execPath, platform = process.platform) {
|
|
59
|
+
export function npxCliCandidates(execPath = process.execPath, platform = process.platform, cli = "npx-cli.js") {
|
|
59
60
|
if (typeof execPath !== "string" || !execPath) return [];
|
|
60
61
|
const sep = platform === "win32" ? "\\" : "/";
|
|
61
62
|
const dir = execPath.split(/[\\/]/);
|
|
62
63
|
dir.pop(); // the binary itself
|
|
63
64
|
if (!dir.length) return [];
|
|
64
|
-
const beside = [...dir, "node_modules", "npm", "bin",
|
|
65
|
+
const beside = [...dir, "node_modules", "npm", "bin", cli].join(sep);
|
|
65
66
|
const above = [];
|
|
66
67
|
for (let up = 1; up <= 4 && dir.length - up > 0; up++) {
|
|
67
|
-
above.push([...dir.slice(0, -up), "lib", "node_modules", "npm", "bin",
|
|
68
|
+
above.push([...dir.slice(0, -up), "lib", "node_modules", "npm", "bin", cli].join(sep));
|
|
68
69
|
}
|
|
69
70
|
return platform === "win32" ? [beside, ...above] : [...above, beside];
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
/**
|
|
74
|
+
* `npm <args>` through the `npm-cli.js` beside the running Node, or null when
|
|
75
|
+
* there is none — the caller then falls back to the name it always used.
|
|
76
|
+
*
|
|
77
|
+
* npxLaunch's rule for npx, and the reason is the same one: nothing here reads
|
|
78
|
+
* PATH. A deck started at login runs with the service manager's PATH, which on
|
|
79
|
+
* a Homebrew, nvm, fnm or Volta install does not name the directory npm is in,
|
|
80
|
+
* so a bare `npm` was `spawn npm ENOENT` for the in-app update and the managed
|
|
81
|
+
* ccusage install alike (#1777). This is also the npm that goes with the node
|
|
82
|
+
* the deck is running on, rather than whichever one PATH meets first.
|
|
83
|
+
*
|
|
84
|
+
* For POSIX callers. Windows already reaches npm by the shim's full path.
|
|
85
|
+
*/
|
|
86
|
+
export function npmCliLaunch(args, {
|
|
87
|
+
platform = process.platform,
|
|
88
|
+
execPath = process.execPath,
|
|
89
|
+
exists = existsSync,
|
|
90
|
+
} = {}) {
|
|
91
|
+
for (const cli of npxCliCandidates(execPath, platform, "npm-cli.js")) {
|
|
92
|
+
let found = false;
|
|
93
|
+
try { found = exists(cli); } catch { found = false; }
|
|
94
|
+
if (found) return { file: execPath, args: [cli, ...args], opts: {} };
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
72
99
|
/**
|
|
73
100
|
* What to hand `spawn` to run `npx <args>`.
|
|
74
101
|
*
|
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
// Moved out of system-metrics.mjs unchanged, because it is the one reading there
|
|
5
5
|
// that never rides the sampler's timers. It is read on demand only — while the
|
|
6
6
|
// process dialog is open — keeps its own one-child-at-a-time guard and its own
|
|
7
|
-
//
|
|
8
|
-
// the command runner. system-metrics.mjs re-exports
|
|
9
|
-
// index.mjs asks for it, and its stopSystemMetrics
|
|
10
|
-
// reset still clears this list along with everything
|
|
7
|
+
// CPU baseline (Windows since #544, Linux since #1769), and shares nothing with
|
|
8
|
+
// the ambient readings but the command runner. system-metrics.mjs re-exports
|
|
9
|
+
// readProcesses, which is where index.mjs asks for it, and its stopSystemMetrics
|
|
10
|
+
// calls resetProcessList so a reset still clears this list along with everything
|
|
11
|
+
// else.
|
|
12
|
+
import { readFile } from "node:fs/promises";
|
|
11
13
|
import os from "node:os";
|
|
12
14
|
import { run } from "./metrics-run.mjs";
|
|
13
15
|
// What of a command line may leave this module: see process-command.mjs.
|
|
@@ -69,7 +71,14 @@ export function pickCandidates(rows, limit = CANDIDATE_N) {
|
|
|
69
71
|
* over time is normally caught in `S`. Nothing errored and nothing was empty,
|
|
70
72
|
* which is why it survived two releases (#492).
|
|
71
73
|
*
|
|
72
|
-
* `--sort=-pcpu` is procps'
|
|
74
|
+
* `--sort=-pcpu` is NOT procps' way to say what `-r` says on BSD, which is what
|
|
75
|
+
* this comment used to claim. procps defines `%cpu` as CPU time over the
|
|
76
|
+
* process's whole lifetime, so a process that idled for hours and is pinning a
|
|
77
|
+
* core now reads a few percent, and one that was busy at start-up keeps its
|
|
78
|
+
* figure long after it stopped (#1769). The sort stays because it costs `ps`
|
|
79
|
+
* nothing and orders the text sensibly for anyone reading it, but the Linux
|
|
80
|
+
* column is not this one any more: readProcessesNow replaces it with a rate
|
|
81
|
+
* taken from /proc/<pid>/stat between two readings (linuxCpuSec). The column
|
|
73
82
|
* order is deliberately identical on both so one parser reads both, and `comm`
|
|
74
83
|
* stays last so a name containing a space survives intact.
|
|
75
84
|
*
|
|
@@ -203,7 +212,9 @@ export { CMD_MAX, commandTail, redactCommand };
|
|
|
203
212
|
*
|
|
204
213
|
* `pcpu` is a percentage of ONE core on both, so a multi-threaded process runs
|
|
205
214
|
* past 100 and that is information rather than an error: 157 is one and a half
|
|
206
|
-
* cores. cpuFromDeltas puts the Windows
|
|
215
|
+
* cores. cpuFromDeltas puts the Windows and Linux columns on this same scale —
|
|
216
|
+
* on Linux the figure parsed here is a lifetime average and is replaced before
|
|
217
|
+
* it leaves this module (see psArgs).
|
|
207
218
|
*
|
|
208
219
|
* There is no row limit in the query because neither `ps` has one and `run`
|
|
209
220
|
* deliberately never inherits a shell, so there is no `| head` to pipe into.
|
|
@@ -376,8 +387,60 @@ export function cpuFromDeltas(rows, prev, elapsedMs, limit = Infinity) {
|
|
|
376
387
|
return out.slice(0, limit);
|
|
377
388
|
}
|
|
378
389
|
|
|
379
|
-
/**
|
|
380
|
-
*
|
|
390
|
+
/**
|
|
391
|
+
* Cumulative CPU seconds out of one `/proc/<pid>/stat` line, or null.
|
|
392
|
+
*
|
|
393
|
+
* utime and stime are fields 14 and 15, in USER_HZ ticks. The name in field 2
|
|
394
|
+
* is the one field that can hold anything — spaces, parentheses — so the count
|
|
395
|
+
* starts after the LAST `)` rather than at a split on spaces: `(tmux: server)`
|
|
396
|
+
* would otherwise shift every field after it.
|
|
397
|
+
*
|
|
398
|
+
* USER_HZ is the kernel's fixed unit for these files, not the scheduler's tick
|
|
399
|
+
* rate, and it is 100 on every architecture Node runs on; Node has no sysconf to
|
|
400
|
+
* ask, and a `getconf CLK_TCK` child per poll to confirm a constant would cost
|
|
401
|
+
* more than the reading it serves.
|
|
402
|
+
*/
|
|
403
|
+
const USER_HZ = 100;
|
|
404
|
+
export function cpuSecFromProcStat(text) {
|
|
405
|
+
const s = String(text ?? "");
|
|
406
|
+
const close = s.lastIndexOf(")");
|
|
407
|
+
if (close < 0) return null;
|
|
408
|
+
// Field 3 (the state) is the first after the name, so field N is at N - 3.
|
|
409
|
+
const f = s.slice(close + 1).trim().split(/\s+/);
|
|
410
|
+
const utime = Number(f[11]), stime = Number(f[12]);
|
|
411
|
+
if (f.length < 13 || !Number.isFinite(utime) || !Number.isFinite(stime)) return null;
|
|
412
|
+
return (utime + stime) / USER_HZ;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* The CPU seconds each Linux row has used so far, for cpuFromDeltas to turn
|
|
417
|
+
* into a rate the way it already does for Windows (#1769).
|
|
418
|
+
*
|
|
419
|
+
* One file read per process, and no child: this is the same file `ps` itself
|
|
420
|
+
* read a moment ago to print the lifetime figure, so the cost is a few hundred
|
|
421
|
+
* small reads of a pseudo-filesystem once every four seconds, only while the
|
|
422
|
+
* process dialog is open. Reads run together rather than one after another.
|
|
423
|
+
*
|
|
424
|
+
* A read that fails — the process exited between `ps` and here, or /proc hides
|
|
425
|
+
* other users' processes — leaves `cpuSec` null, which cpuFromDeltas reports as
|
|
426
|
+
* an unknown CPU rather than as an idle one. `deps.readFile` is the seam the
|
|
427
|
+
* suite uses, so no test depends on the machine's real process table.
|
|
428
|
+
*/
|
|
429
|
+
async function linuxCpuSec(rows, deps = {}) {
|
|
430
|
+
const file = deps.readFile ?? readFile;
|
|
431
|
+
const secs = await Promise.all(rows.map(r =>
|
|
432
|
+
Promise.resolve()
|
|
433
|
+
.then(() => file(`/proc/${r.pid}/stat`, "utf8"))
|
|
434
|
+
.then(cpuSecFromProcStat, () => null)));
|
|
435
|
+
return rows.map((r, i) => {
|
|
436
|
+
const { cpu: _lifetime, ...rest } = r;
|
|
437
|
+
return { ...rest, cpuSec: secs[i] };
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** The previous reading's CPU seconds per pid, on the two platforms whose
|
|
442
|
+
* column is a rate derived here (Windows and Linux), so the next reading can
|
|
443
|
+
* be one. Cleared with the rest of the sampler state — see resetProcessList. */
|
|
381
444
|
let prevProcCpu = null;
|
|
382
445
|
let prevProcAt = 0;
|
|
383
446
|
|
|
@@ -416,7 +479,7 @@ const PROC_MIN_GAP_MS = 1_500;
|
|
|
416
479
|
* that arrive while a run is going share its promise; callers that arrive just
|
|
417
480
|
* after one finished are served that reading.
|
|
418
481
|
*/
|
|
419
|
-
export async function readProcesses(platform = process.platform, detail = false) {
|
|
482
|
+
export async function readProcesses(platform = process.platform, detail = false, deps = {}) {
|
|
420
483
|
const now = Date.now();
|
|
421
484
|
// A cached reading serves a caller that wants LESS than it holds, never one
|
|
422
485
|
// that wants more: the panel is happy with a detailed reading, and the modal
|
|
@@ -431,7 +494,7 @@ export async function readProcesses(platform = process.platform, detail = false)
|
|
|
431
494
|
// instead. The in-flight share still applies, so a burst arriving during a
|
|
432
495
|
// failing read is one failing child, not a burst of them.
|
|
433
496
|
procInFlightDetail = detail;
|
|
434
|
-
procInFlight = readProcessesNow(platform, detail)
|
|
497
|
+
procInFlight = readProcessesNow(platform, detail, deps)
|
|
435
498
|
.then(read => {
|
|
436
499
|
if (read.procs.length) procLast = { at: Date.now(), read, detail };
|
|
437
500
|
return read;
|
|
@@ -440,6 +503,22 @@ export async function readProcesses(platform = process.platform, detail = false)
|
|
|
440
503
|
return procInFlight;
|
|
441
504
|
}
|
|
442
505
|
|
|
506
|
+
/**
|
|
507
|
+
* What /api/system/processes answers for one reading.
|
|
508
|
+
*
|
|
509
|
+
* An empty reading is a failed one, by the rule readProcesses already keeps: no
|
|
510
|
+
* machine has nothing running on it, and every failure inside the reader — a
|
|
511
|
+
* spawn that never started, a non-zero exit, the deadline — resolves to an
|
|
512
|
+
* empty list. It went out as `{ ok: true, procs: [] }`, which is a claim that
|
|
513
|
+
* the machine was read and found empty, and the dialog believed it: one slow
|
|
514
|
+
* `ps` swapped a working table for a sentence blaming the platform (#1770).
|
|
515
|
+
* So a failure says it is one, and the dialog keeps what it was showing.
|
|
516
|
+
*/
|
|
517
|
+
export function processesReply(read) {
|
|
518
|
+
if (!read?.procs?.length) return { ok: false, reason: "read_failed" };
|
|
519
|
+
return { ok: true, ...read };
|
|
520
|
+
}
|
|
521
|
+
|
|
443
522
|
/**
|
|
444
523
|
* The one-liner the Windows process list is read with.
|
|
445
524
|
*
|
|
@@ -465,7 +544,7 @@ export async function readProcesses(platform = process.platform, detail = false)
|
|
|
465
544
|
export const WIN_PROCESS_PS =
|
|
466
545
|
"Get-Process | Select-Object Id,ProcessName,CPU,@{n='Threads';e={$_.Threads.Count}},@{n='StartedAt';e={if($_.StartTime){[int]((Get-Date)-$_.StartTime).TotalSeconds}else{$null}}},@{n='WorkingSet';e={$_.WorkingSet64}},@{n='WorkingSetPrivate';e={$_.PrivateMemorySize64}} | ConvertTo-Json -Compress";
|
|
467
546
|
|
|
468
|
-
async function readProcessesNow(platform, detail = false) {
|
|
547
|
+
async function readProcessesNow(platform, detail = false, deps = {}) {
|
|
469
548
|
if (platform === "win32") {
|
|
470
549
|
const out = await run("powershell.exe", [
|
|
471
550
|
"-NoProfile", "-NonInteractive", "-Command", WIN_PROCESS_PS,
|
|
@@ -487,7 +566,18 @@ async function readProcessesNow(platform, detail = false) {
|
|
|
487
566
|
}
|
|
488
567
|
const out = await run("ps", psArgs(platform), 4_000);
|
|
489
568
|
if (!out) return { procs: [], total: 0 };
|
|
490
|
-
|
|
569
|
+
let all = parsePsProcesses(out);
|
|
570
|
+
if (platform === "linux") {
|
|
571
|
+
// procps' %cpu is a lifetime average (see psArgs), so the Linux column is
|
|
572
|
+
// derived here from two readings of the kernel's own counters, exactly as
|
|
573
|
+
// the Windows one is from two Get-Process readings: null on the first,
|
|
574
|
+
// then the share of one core used since the one before.
|
|
575
|
+
const rows = await linuxCpuSec(all, deps);
|
|
576
|
+
const now = Date.now();
|
|
577
|
+
all = cpuFromDeltas(rows, prevProcCpu, now - prevProcAt);
|
|
578
|
+
prevProcCpu = new Map(rows.filter(r => r.cpuSec != null).map(r => [r.pid, r.cpuSec]));
|
|
579
|
+
prevProcAt = now;
|
|
580
|
+
}
|
|
491
581
|
const procs = pickCandidates(all);
|
|
492
582
|
if (detail) await attachDetail(procs, platform);
|
|
493
583
|
return { procs, total: all.length };
|
|
@@ -534,7 +624,7 @@ async function attachDetail(procs, platform) {
|
|
|
534
624
|
}
|
|
535
625
|
|
|
536
626
|
/**
|
|
537
|
-
* Forget the
|
|
627
|
+
* Forget the CPU baseline and the last list, for stopSystemMetrics.
|
|
538
628
|
*
|
|
539
629
|
* The last list goes with the baseline it was computed against. A sampler that
|
|
540
630
|
* stopped and started again must not answer the first caller with a reading
|
package/src/server/purge-key.mjs
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
// strip the field: a partial rewrite of the file that holds the pairings is a
|
|
29
29
|
// worse thing to get wrong than an unlink, and after an uninstall there is
|
|
30
30
|
// nothing left the rest of the file is for.
|
|
31
|
-
import { readFile, rm } from "node:fs/promises";
|
|
31
|
+
import { readdir, readFile, rm } from "node:fs/promises";
|
|
32
32
|
import { homedir } from "node:os";
|
|
33
33
|
import { join } from "node:path";
|
|
34
34
|
import { deckDataDir, legacyDeckDir } from "./deck-home.mjs";
|
|
@@ -38,6 +38,23 @@ import { stripBom } from "./atomic-write.mjs";
|
|
|
38
38
|
* two must not drift — see `prefsPath`, which is the same join. */
|
|
39
39
|
export const KEY_FILE = "prefs.json";
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* The other names a copy of that file can have in the same folder, and every
|
|
43
|
+
* one of them can hold the key.
|
|
44
|
+
*
|
|
45
|
+
* `.corrupt-<ms>` and `.foreign-<ms>` are prefs-set-aside.mjs's: a file the
|
|
46
|
+
* deck could not parse, or one another user owns, is moved aside rather than
|
|
47
|
+
* written over — and the deck tells the user the key is in the moved file.
|
|
48
|
+
* `.agent-dag-<pid>-<n>.tmp` is atomic-write.mjs's temp file and
|
|
49
|
+
* `.<pid>.migrating` is migrateDeckFiles', both left by a process killed
|
|
50
|
+
* before the copy was moved into place. The boot sweeps those two, but
|
|
51
|
+
* after an uninstall there is no next boot.
|
|
52
|
+
*
|
|
53
|
+
* Exact shapes, anchored, so nothing else in the folder — which on some
|
|
54
|
+
* machines is also the discovery folder — is ever listed or removed.
|
|
55
|
+
*/
|
|
56
|
+
const KEY_COPY = /^prefs\.json\.(?:corrupt-\d+|foreign-\d+|agent-dag-\d+-\d+\.tmp|\d+\.migrating)$/;
|
|
57
|
+
|
|
41
58
|
/**
|
|
42
59
|
* Every directory on this machine that may still hold one.
|
|
43
60
|
*
|
|
@@ -74,36 +91,47 @@ export function keyDirs({ env = process.env, home = homedir(), platform = proces
|
|
|
74
91
|
*/
|
|
75
92
|
export async function findKeyFiles(dirs = keyDirs(), deps = {}) {
|
|
76
93
|
const read = deps.readFile ?? readFile;
|
|
94
|
+
const list = deps.readdir ?? readdir;
|
|
77
95
|
const out = [];
|
|
78
96
|
for (const dir of dirs) {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
// failed rather than the parse.
|
|
87
|
-
if (err?.code === "ENOENT") continue;
|
|
88
|
-
out.push({ path, holds: "unknown", why: err?.message ?? String(err) });
|
|
89
|
-
continue;
|
|
90
|
-
}
|
|
91
|
-
try {
|
|
92
|
-
// The BOM, stripped by the rule settings.json, prefs.json and the
|
|
93
|
-
// browser-watch state are all read with: `JSON.parse` throws on one and
|
|
94
|
-
// a file Notepad saved is not damaged. This was a copy of it, spelled out
|
|
95
|
-
// while stripBom was private; it is exported now (atomic-write.mjs), so
|
|
96
|
-
// the rule has one spelling.
|
|
97
|
-
const parsed = JSON.parse(stripBom(String(raw)));
|
|
98
|
-
const secret = parsed?.lan?.secret;
|
|
99
|
-
out.push({ path, holds: typeof secret === "string" && secret !== "" ? "key" : "no-key", why: "" });
|
|
100
|
-
} catch (err) {
|
|
101
|
-
out.push({ path, holds: "unknown", why: err?.message ?? String(err) });
|
|
97
|
+
// prefs.json first, then its copies by name. A folder that cannot be
|
|
98
|
+
// listed still has prefs.json asked about, which reports anything but
|
|
99
|
+
// ENOENT as "unknown" below.
|
|
100
|
+
const copies = await list(dir).then(names => names.filter(n => KEY_COPY.test(n)).sort(), () => []);
|
|
101
|
+
for (const name of [KEY_FILE, ...copies]) {
|
|
102
|
+
const found = await keyFile(join(dir, name), read);
|
|
103
|
+
if (found) out.push(found);
|
|
102
104
|
}
|
|
103
105
|
}
|
|
104
106
|
return out;
|
|
105
107
|
}
|
|
106
108
|
|
|
109
|
+
/** One candidate, as findKeyFiles lists it, or null when it is not there. */
|
|
110
|
+
async function keyFile(path, read) {
|
|
111
|
+
let raw;
|
|
112
|
+
try {
|
|
113
|
+
raw = await read(path, "utf8");
|
|
114
|
+
} catch (err) {
|
|
115
|
+
// Anything but "no such file" means there IS something there and this
|
|
116
|
+
// could not see into it, which is the "unknown" case with the read having
|
|
117
|
+
// failed rather than the parse.
|
|
118
|
+
if (err?.code === "ENOENT") return null;
|
|
119
|
+
return { path, holds: "unknown", why: err?.message ?? String(err) };
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
// The BOM, stripped by the rule settings.json, prefs.json and the
|
|
123
|
+
// browser-watch state are all read with: `JSON.parse` throws on one and
|
|
124
|
+
// a file Notepad saved is not damaged. This was a copy of it, spelled out
|
|
125
|
+
// while stripBom was private; it is exported now (atomic-write.mjs), so
|
|
126
|
+
// the rule has one spelling.
|
|
127
|
+
const parsed = JSON.parse(stripBom(String(raw)));
|
|
128
|
+
const secret = parsed?.lan?.secret;
|
|
129
|
+
return { path, holds: typeof secret === "string" && secret !== "" ? "key" : "no-key", why: "" };
|
|
130
|
+
} catch (err) {
|
|
131
|
+
return { path, holds: "unknown", why: err?.message ?? String(err) };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
107
135
|
/**
|
|
108
136
|
* Take those files off the disk, and never let one failure hide another.
|
|
109
137
|
*
|
|
@@ -47,11 +47,11 @@ export const HOOK_TOKEN = randomBytes(32).toString("hex");
|
|
|
47
47
|
//
|
|
48
48
|
// So the Host check runs for every method now. What it asks is only the
|
|
49
49
|
// rebinding question — did this request arrive addressed to a name that can
|
|
50
|
-
// only ever be this machine
|
|
51
|
-
// meaning anything carrying an Origin, fetch metadata or a Referer
|
|
52
|
-
// sending none of them is not a page
|
|
53
|
-
//
|
|
54
|
-
//
|
|
50
|
+
// only ever be this machine. It was first asked of browser-shaped requests
|
|
51
|
+
// alone, meaning anything carrying an Origin, fetch metadata or a Referer, on
|
|
52
|
+
// the reasoning that a client sending none of them is not a page: that is
|
|
53
|
+
// hook/hook.js, a plain Node http.request from the user's own machine. The
|
|
54
|
+
// paragraph after next says why that is no longer the whole test.
|
|
55
55
|
//
|
|
56
56
|
// THE REFERER IS ON THAT LIST because on one browser it is the only mark a page
|
|
57
57
|
// leaves (#1168). A same-origin GET carries no Origin, and Safari 16.0-16.3
|
|
@@ -68,18 +68,35 @@ export const HOOK_TOKEN = randomBytes(32).toString("hex");
|
|
|
68
68
|
// bin/ send none — so nothing that reached the deck before is turned away by
|
|
69
69
|
// this, and the deck's own page names a loopback Host whatever it sends.
|
|
70
70
|
//
|
|
71
|
+
// THE HOST IS NOW READ ON EVERY REQUEST THAT CARRIES ONE. Whether a request
|
|
72
|
+
// is browser-shaped is the sending page's choice as much as the browser's — a
|
|
73
|
+
// page's own GET need carry none of the three marks — so it no longer decides
|
|
74
|
+
// whether the Host is asked about. Every client of this server that is not a
|
|
75
|
+
// browser dials 127.0.0.1 — hook.js, the desktop app, bin/ and deck-probe.mjs
|
|
76
|
+
// — so the Host Node fills in for them is a loopback one, and they are
|
|
77
|
+
// answered exactly as before. Two shapes still pass under a name that is not
|
|
78
|
+
// loopback:
|
|
79
|
+
//
|
|
80
|
+
// - no Host at all, which is HTTP/1.0 tooling and never a browser — every
|
|
81
|
+
// browser sends one;
|
|
82
|
+
// - the deck's token (`token`, the raw x-ccdeck-token header), which only a
|
|
83
|
+
// process that read the 0600 discovery file can present, and only on a
|
|
84
|
+
// request that is not browser-shaped. A request a page chose is refused
|
|
85
|
+
// whoever else may be behind it, as isTrustedMutation's is.
|
|
86
|
+
//
|
|
71
87
|
// Deliberately not part of this: the Sec-Fetch-Site test that isTrustedMutation
|
|
72
88
|
// applies. `cross-site` on a read is an ordinary top-level navigation — a link
|
|
73
89
|
// to http://localhost:4317 clicked on any page — and the document it loads is
|
|
74
90
|
// the deck's own UI on the deck's own origin, which is not an attack and used
|
|
75
91
|
// to work. Rebinding does not need that test either: a rebound page's requests
|
|
76
92
|
// report `same-origin`, and it is the Host that gives it away.
|
|
77
|
-
export function isTrustedRead({ origin, host, secFetchSite, referer } = {}) {
|
|
93
|
+
export function isTrustedRead({ origin, host, secFetchSite, referer, token } = {}) {
|
|
78
94
|
const browserShaped = (typeof origin === "string" && origin !== "")
|
|
79
95
|
|| (typeof secFetchSite === "string" && secFetchSite.trim() !== "")
|
|
80
96
|
|| (typeof referer === "string" && referer.trim() !== "");
|
|
81
|
-
if (
|
|
82
|
-
|
|
97
|
+
if (browserShaped) return isLoopbackHost(host);
|
|
98
|
+
if (typeof host !== "string" || host.trim() === "") return true;
|
|
99
|
+
return isLoopbackHost(host) || presentsDeckToken({ "x-ccdeck-token": token });
|
|
83
100
|
}
|
|
84
101
|
|
|
85
102
|
// Is this mutating request allowed to be acted on?
|
|
@@ -113,12 +130,14 @@ export function isTrustedRead({ origin, host, secFetchSite, referer } = {}) {
|
|
|
113
130
|
// loopback identity. See isLoopbackHost for the attack that gets through
|
|
114
131
|
// without it.
|
|
115
132
|
//
|
|
116
|
-
// A request carrying neither header is not a browser request and
|
|
117
|
-
// whatever Host it names: that is hook/hook.js, a plain Node
|
|
118
|
-
// the user's own machine that sends no Origin at all, plus
|
|
119
|
-
// own tooling. Ambient browser authority is the whole
|
|
120
|
-
// clients have none — a process that can POST here can
|
|
121
|
-
// the user, and nothing it sends is chosen by a page.
|
|
133
|
+
// A request carrying neither header is not a browser request and this gate
|
|
134
|
+
// allows it whatever Host it names: that is hook/hook.js, a plain Node
|
|
135
|
+
// http.request from the user's own machine that sends no Origin at all, plus
|
|
136
|
+
// curl and the deck's own tooling. Ambient browser authority is the whole
|
|
137
|
+
// threat here, and those clients have none — a process that can POST here can
|
|
138
|
+
// already run anything as the user, and nothing it sends is chosen by a page.
|
|
139
|
+
// The name such a request is addressed to is isTrustedRead's question, which
|
|
140
|
+
// the router asks first, of every method.
|
|
122
141
|
export function isTrustedMutation({ origin, host, secFetchSite } = {}) {
|
|
123
142
|
const site = typeof secFetchSite === "string" ? secFetchSite.trim().toLowerCase() : "";
|
|
124
143
|
if (site && site !== "same-origin" && site !== "none") return false;
|
|
@@ -421,6 +440,20 @@ export const GUARDED_READS = new Set([
|
|
|
421
440
|
// Per-account, per-project token spend — the user's own work, the same class
|
|
422
441
|
// of secret as the accounts list it hangs off.
|
|
423
442
|
"/api/account-projects",
|
|
443
|
+
// The signed-in Codex account's email, plan type and credit balance ride on
|
|
444
|
+
// this answer (codex-quota.mjs), which is the account identity the roster
|
|
445
|
+
// route above is guarded for.
|
|
446
|
+
"/api/codex-quota",
|
|
447
|
+
// And the rest of the usage panel, by the rule /api/account-projects is here
|
|
448
|
+
// under: the active account's quota windows, Claude's spend by day and by
|
|
449
|
+
// session, Codex's token spend, and the auto-switch settings with the last
|
|
450
|
+
// account it moved to. Each is about what the user is doing rather than about
|
|
451
|
+
// the machine, and each has one caller, the deck's own page in src/web —
|
|
452
|
+
// the desktop app, bin/ and the hook never read them.
|
|
453
|
+
"/api/quota",
|
|
454
|
+
"/api/ccusage",
|
|
455
|
+
"/api/codex-usage",
|
|
456
|
+
"/api/cswap-auto",
|
|
424
457
|
// Not a secret, and here for the other reason a read can be dangerous: it is
|
|
425
458
|
// the one route where the caller names what the deck goes and fetches
|
|
426
459
|
// (#1208). fm-station.mjs holds that to YouTube, but a page on another site
|
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
// switch anywhere that could stop it, or crashing once the script was swept.
|
|
34
34
|
//
|
|
35
35
|
// Everything else in the file is the user's and is not touched. `afplay …` stays
|
|
36
|
-
// exactly where they put it, and this module has no idea what it does
|
|
36
|
+
// exactly where they put it, and this module has no idea what it does — even
|
|
37
|
+
// when it sits in the same group as ours (see withoutOurs).
|
|
37
38
|
//
|
|
38
39
|
// EXACTLY ONCE, without a stamp. Retirement is triggered by the state it
|
|
39
40
|
// removes: our entry in settings.json, a parked file, or one of our scripts on
|
|
@@ -137,12 +138,52 @@ export function namesOurScript(cmd, platform = process.platform) {
|
|
|
137
138
|
return SCRIPT_TAILS.some(tail => hay.includes(tail));
|
|
138
139
|
}
|
|
139
140
|
|
|
140
|
-
/**
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
141
|
+
/**
|
|
142
|
+
* Whether one command is our sound script. In a group we marked, under either
|
|
143
|
+
* separator and in any case: a settings.json synced from another platform
|
|
144
|
+
* spells the path that platform's way, and the mark already says whose it is.
|
|
145
|
+
*/
|
|
146
|
+
function isOurCommand(h, marked) {
|
|
147
|
+
const cmd = typeof h?.command === "string" ? h.command : "";
|
|
148
|
+
return namesOurScript(cmd) || (marked && namesOurScript(cmd, "win32"));
|
|
144
149
|
}
|
|
145
150
|
|
|
151
|
+
/** What withoutOurs answers for an entry with nothing left in it. */
|
|
152
|
+
const DROP = Symbol("drop");
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* One entry with our sound command taken out of it: `entry` itself when nothing
|
|
156
|
+
* in it was ours, DROP when nothing is left, and otherwise a copy holding the
|
|
157
|
+
* rest with its matcher and without our mark.
|
|
158
|
+
*
|
|
159
|
+
* PER COMMAND, NOT PER ENTRY — installer.mjs's rule for its own forwarder
|
|
160
|
+
* (#1734), for the same reason. The toggle's group had no matcher, and Claude
|
|
161
|
+
* Code's hook editor files a new matcher-less Stop hook into the first Stop
|
|
162
|
+
* group whose matcher is missing or empty, which while the toggle was on was
|
|
163
|
+
* often ours. Deciding for the whole group took the user's hook out with it, on
|
|
164
|
+
* the boot that retired the toggle and on `--uninstall`, and said nothing.
|
|
165
|
+
*
|
|
166
|
+
* A marked entry whose `hooks` is not a list is dropped, as it always was; an
|
|
167
|
+
* unmarked one is left exactly where it is.
|
|
168
|
+
*/
|
|
169
|
+
function withoutOurs(entry) {
|
|
170
|
+
if (!entry || typeof entry !== "object") return entry;
|
|
171
|
+
const marked = entry[MARK] === true;
|
|
172
|
+
if (!Array.isArray(entry.hooks)) return marked ? DROP : entry;
|
|
173
|
+
const kept = entry.hooks.filter(h => !isOurCommand(h, marked));
|
|
174
|
+
if (!marked && kept.length === entry.hooks.length) return entry;
|
|
175
|
+
if (kept.length === 0) return DROP;
|
|
176
|
+
const rest = {};
|
|
177
|
+
for (const [k, v] of Object.entries(entry)) {
|
|
178
|
+
if (k === MARK) continue;
|
|
179
|
+
rest[k] = k === "hooks" ? kept : v;
|
|
180
|
+
}
|
|
181
|
+
return rest;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** An entry holding something this deck put there: its mark, or its script. */
|
|
185
|
+
const isOurs = (entry) => withoutOurs(entry) !== entry;
|
|
186
|
+
|
|
146
187
|
/** Anywhere in the file — not just `Stop` — that still runs one of our scripts.
|
|
147
188
|
* The sweep below asks this before deleting them: a stale sound is survivable
|
|
148
189
|
* and a hook pointing at nothing is not. */
|
|
@@ -237,9 +278,12 @@ export async function retireSoundHookIn(settings) {
|
|
|
237
278
|
parkError = { reason: "parked_unreadable", parkedPath: PARKED_PATH, message: err.message };
|
|
238
279
|
}
|
|
239
280
|
|
|
281
|
+
// `removed` counts the entries ours was taken out of, whether or not the
|
|
282
|
+
// user's own hooks were left behind in them.
|
|
240
283
|
const group = Array.isArray(settings?.hooks?.[EVENT]) ? settings.hooks[EVENT] : [];
|
|
241
|
-
const
|
|
242
|
-
const
|
|
284
|
+
const trimmed = group.map(withoutOurs);
|
|
285
|
+
const theirs = trimmed.filter(g => g !== DROP);
|
|
286
|
+
const removed = trimmed.filter((g, i) => g !== group[i]).length;
|
|
243
287
|
|
|
244
288
|
// Identical entries are not restored twice — see the note on two decks at the
|
|
245
289
|
// top. `theirs` is what will be in the file, so a hook already back from an
|
|
@@ -253,10 +297,13 @@ export async function retireSoundHookIn(settings) {
|
|
|
253
297
|
// exactly the shape a hand-written-hook filter would have swept up. Restoring
|
|
254
298
|
// one would put back the hook this whole module exists to remove, pointing at
|
|
255
299
|
// a script this release deletes, on the boot that was supposed to repair it.
|
|
300
|
+
// Per command here too: the toggle parked whole groups, so a hook of theirs
|
|
301
|
+
// that sat beside our script went into the park with it and comes back alone.
|
|
256
302
|
const seen = new Set(theirs.map(g => JSON.stringify(g)));
|
|
257
303
|
const putBack = [];
|
|
258
|
-
for (const
|
|
259
|
-
|
|
304
|
+
for (const parkedEntry of parked) {
|
|
305
|
+
const entry = withoutOurs(parkedEntry);
|
|
306
|
+
if (entry === DROP) continue;
|
|
260
307
|
const key = JSON.stringify(entry);
|
|
261
308
|
if (seen.has(key)) continue;
|
|
262
309
|
seen.add(key);
|
|
@@ -76,6 +76,16 @@ export const MAX_BUFFER = 2000; // recent HOOK events kept for late SSE subs
|
|
|
76
76
|
*/
|
|
77
77
|
const LAST_VALUE_WINS = new Set(["ModelObserved", "UsageObserved", "ContextObserved", "SessionNamed"]);
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Whether this payload is enrichment MAX_BUFFER does not count — see
|
|
81
|
+
* LAST_VALUE_WINS. One test, asked by the ring's eviction and by the boot
|
|
82
|
+
* replay's staging alike, so the replay reads back the window the ring keeps
|
|
83
|
+
* rather than a window of log lines, most of which are enrichment (#1750).
|
|
84
|
+
*/
|
|
85
|
+
export function isEnrichment(raw) {
|
|
86
|
+
return raw != null && typeof raw === "object" && LAST_VALUE_WINS.has(raw.hook_event_name);
|
|
87
|
+
}
|
|
88
|
+
|
|
79
89
|
/**
|
|
80
90
|
* The ring's array-length backstop, distinct from its event budget.
|
|
81
91
|
*
|