agent-dag 3.2.1 → 3.4.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 +18 -6
- package/bin/deck.js +9 -1
- package/dist/web/assets/index-3Q9bZKVe.js +171 -0
- package/dist/web/assets/index-D0YH-emg.css +1 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +60 -14
- package/package.json +6 -3
- package/release-notes.json +36 -0
- package/src/server/agent-activity.mjs +27 -11
- package/src/server/browser-history.mjs +31 -5
- package/src/server/browser-presence.mjs +57 -12
- package/src/server/browser-react.mjs +31 -9
- package/src/server/browser-watch-store.mjs +56 -2
- package/src/server/browser-watch.mjs +90 -10
- package/src/server/ccusage.mjs +100 -3
- package/src/server/codex-quota.mjs +6 -2
- package/src/server/codex-usage.mjs +24 -1
- package/src/server/cswap-admin.mjs +0 -14
- package/src/server/cswap-auto.mjs +39 -1
- package/src/server/cswap-install.mjs +6 -1
- package/src/server/exec.mjs +44 -0
- package/src/server/index.mjs +127 -11
- package/src/server/installer.mjs +58 -3
- package/src/server/macmon.mjs +17 -6
- package/src/server/npx.mjs +6 -1
- package/src/server/open-url.mjs +25 -4
- package/src/server/quota.mjs +88 -26
- package/src/server/retire-sound-hook.mjs +36 -2
- package/src/server/self-update.mjs +17 -5
- package/src/server/system-metrics.mjs +28 -36
- package/src/server/uv-bootstrap.mjs +39 -3
- package/dist/web/assets/index-Bnn7d7u8.css +0 -1
- package/dist/web/assets/index-COdHVcLu.js +0 -149
|
@@ -201,11 +201,33 @@ async function readCompressedTokenSeries(filePath) {
|
|
|
201
201
|
}
|
|
202
202
|
return null;
|
|
203
203
|
}
|
|
204
|
+
// THE SOURCE IS HELD, and both halves of that matter.
|
|
205
|
+
//
|
|
206
|
+
// `Readable.pipe` attaches its error handling to the DESTINATION. The source
|
|
207
|
+
// got neither an 'error' listener nor a destroy, so a read that failed — the
|
|
208
|
+
// file removed between the listing and this call, EACCES, EMFILE — emitted
|
|
209
|
+
// an unhandled 'error' and took the process down, in a reader whose
|
|
210
|
+
// uncompressed twin deliberately survives those same errnos. A torn archive
|
|
211
|
+
// errors on the destination instead, which the catch below swallows while the
|
|
212
|
+
// source stays open: twenty reads of one corrupt file left twenty handles,
|
|
213
|
+
// against a sixty-second poll. On Windows a held handle also blocks the
|
|
214
|
+
// unlink, which is the case the plain path's `finally { await fd?.close() }`
|
|
215
|
+
// names.
|
|
216
|
+
let source = null;
|
|
204
217
|
try {
|
|
205
218
|
const series = [];
|
|
206
219
|
const decoder = new StringDecoder("utf8");
|
|
207
220
|
let pending = "";
|
|
208
|
-
|
|
221
|
+
source = createReadStream(filePath);
|
|
222
|
+
const stream = source.pipe(createZstdDecompress());
|
|
223
|
+
// THE ERROR HAS TO REACH THE LOOP, not merely be caught. A bare
|
|
224
|
+
// `on("error", () => {})` here stops the process dying and hangs the read
|
|
225
|
+
// instead: `pipe` does not forward a source failure to the destination, so
|
|
226
|
+
// the `for await` below waits forever for an 'end' that cannot come — which
|
|
227
|
+
// is what a missing file did on the first Windows run of this code, at 30
|
|
228
|
+
// seconds per poll. Destroying the destination WITH the error makes the
|
|
229
|
+
// iteration reject, which is what the catch is for.
|
|
230
|
+
source.on("error", err => { stream.destroy(err); });
|
|
209
231
|
for await (const chunk of stream) {
|
|
210
232
|
pending += decoder.write(chunk);
|
|
211
233
|
let from = 0;
|
|
@@ -220,6 +242,7 @@ async function readCompressedTokenSeries(filePath) {
|
|
|
220
242
|
if (pending) foldTokenLine(series, pending);
|
|
221
243
|
return series.length ? series : null;
|
|
222
244
|
} catch { return null; }
|
|
245
|
+
finally { source?.destroy(); }
|
|
223
246
|
}
|
|
224
247
|
|
|
225
248
|
/** The reader, exported under a test-only name. The compressed path cannot be
|
|
@@ -986,20 +986,6 @@ export async function shareAccounts(nums) {
|
|
|
986
986
|
};
|
|
987
987
|
}
|
|
988
988
|
|
|
989
|
-
/**
|
|
990
|
-
* The one-account case, which is a bundle of one and takes the same path.
|
|
991
|
-
*
|
|
992
|
-
* That path PARSES what cswap wrote, where this used to hand its stdout on
|
|
993
|
-
* opaquely — the cost of `shared` being able to promise that the count on the
|
|
994
|
-
* copy button is the count in the blob. So an export shape the fold cannot read
|
|
995
|
-
* now fails the single share too, not just the bundle. Deliberate: one path
|
|
996
|
-
* means the two cannot drift into two envelope shapes, and a share that
|
|
997
|
-
* silently carried something this deck could not account for is the failure
|
|
998
|
-
* the count exists to prevent.
|
|
999
|
-
*/
|
|
1000
|
-
export async function shareAccount(num) {
|
|
1001
|
-
return shareAccounts([num]);
|
|
1002
|
-
}
|
|
1003
989
|
|
|
1004
990
|
/**
|
|
1005
991
|
* The identities a bundle carries, or `[]` when it cannot be read.
|
|
@@ -229,7 +229,13 @@ const MODEL_LIST_OK = /^(?!-)[A-Za-z0-9 ,._-]{1,120}$/;
|
|
|
229
229
|
|
|
230
230
|
/** Validate against SETTINGS, then hand to `cswap config set`. */
|
|
231
231
|
export async function setCswapConfig(key, value) {
|
|
232
|
-
|
|
232
|
+
// `Object.hasOwn`, not a bare read. `SETTINGS["constructor"]` is truthy and
|
|
233
|
+
// its `.type` is undefined, so a prototype member passed the allowlist and
|
|
234
|
+
// fell through to the free-text branch — reaching `cswap config set
|
|
235
|
+
// constructor <value>` and skipping the type-specific range check on the way.
|
|
236
|
+
// Nothing reachable that way was dangerous; an allowlist that does not hold
|
|
237
|
+
// is.
|
|
238
|
+
const spec = Object.hasOwn(SETTINGS, key) ? SETTINGS[key] : null;
|
|
233
239
|
if (!spec) return { ok: false, reason: "unknown_setting" };
|
|
234
240
|
|
|
235
241
|
let str;
|
|
@@ -253,6 +259,17 @@ export async function setCswapConfig(key, value) {
|
|
|
253
259
|
// nothing. The panel reloads this route immediately afterwards and gets a real
|
|
254
260
|
// read; see invalidateCswapAutoCache.
|
|
255
261
|
invalidateCswapAutoCache();
|
|
262
|
+
// A NEW INTERVAL HAS TO REACH THE TIMER. `tickInterval()` is read once, at
|
|
263
|
+
// startLoop, so changing this setting used to update what the panel reports
|
|
264
|
+
// and nothing else: set 3600 with auto-switch on and the panel read back an
|
|
265
|
+
// hour while the loop kept firing every sixty seconds for the life of the
|
|
266
|
+
// process — sixty `cswap auto --once` spawns an hour instead of one, against
|
|
267
|
+
// the shared per-account request budget this subsystem exists to protect.
|
|
268
|
+
// Lowering it was equally inert.
|
|
269
|
+
if (r.ok && key === "autoswitch.intervalSeconds" && _enabled) {
|
|
270
|
+
stopLoop();
|
|
271
|
+
await startLoop();
|
|
272
|
+
}
|
|
256
273
|
return r.ok ? { ok: true } : { ok: false, reason: "set_failed", detail: (r.stderr || r.stdout).trim().slice(0, 300) };
|
|
257
274
|
}
|
|
258
275
|
|
|
@@ -291,6 +308,15 @@ function summarise(stdout) {
|
|
|
291
308
|
/** Evaluate a tick for real. May switch the active account. */
|
|
292
309
|
async function runAutoTick() {
|
|
293
310
|
const r = await run(await cswapBin(), ["auto", "--once", "--json"], { timeout: TICK_TIMEOUT_MS });
|
|
311
|
+
// A KILLED RUN IS NOT A QUIET ONE. `run`'s timeout path deliberately keeps an
|
|
312
|
+
// 8 KB tail of whatever the child managed to print, so `!r.ok && !r.stdout`
|
|
313
|
+
// is false for a tick that emitted its `{"event":"poll"}` line and then
|
|
314
|
+
// stalled — and the killed run fell through to `ok: true`. The panel then
|
|
315
|
+
// showed a healthy `no-switch` every two minutes, forever, while the engine
|
|
316
|
+
// did nothing at all: exactly the trap exec.mjs's own header names.
|
|
317
|
+
if (r.timedOut) {
|
|
318
|
+
return { ok: false, reason: "tick_timeout", detail: `cswap auto --once did not finish within ${TICK_TIMEOUT_MS / 1000}s` };
|
|
319
|
+
}
|
|
294
320
|
if (!r.ok && !r.stdout) {
|
|
295
321
|
return { ok: false, reason: "tick_failed", detail: (r.stderr || "").trim().slice(0, 300) };
|
|
296
322
|
}
|
|
@@ -477,6 +503,18 @@ async function runTick() {
|
|
|
477
503
|
_lastTick = { at: Date.now(), event: "skipped", reason: "external-engine" };
|
|
478
504
|
return;
|
|
479
505
|
}
|
|
506
|
+
// AND RE-CHECK THE SWITCH ITSELF, after that await. `stopLoop` clears the
|
|
507
|
+
// interval and nothing else, so a tick already running went on to move the
|
|
508
|
+
// user's live account seconds after the panel had drawn itself as off. The
|
|
509
|
+
// await above is not short: ticks are at least fifteen seconds apart against
|
|
510
|
+
// a ten-second floor, so every one pays a real process-table read — on
|
|
511
|
+
// Windows a PowerShell Get-CimInstance with an eight-second deadline. The
|
|
512
|
+
// panel then showed `enabled: false` beside a `lastTick` of
|
|
513
|
+
// `{event: "switch", from, to}` stamped after the user turned it off.
|
|
514
|
+
if (!_enabled) {
|
|
515
|
+
_lastTick = { at: Date.now(), event: "skipped", reason: "disabled" };
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
480
518
|
const result = await runAutoTick();
|
|
481
519
|
// Before `_lastTick`, not after. This is the only path in the deck that moves
|
|
482
520
|
// the live account without a click behind it, so nothing else is in a position
|
|
@@ -597,8 +597,13 @@ export async function ensureCswap({ onInstalling = null } = {}) {
|
|
|
597
597
|
// Installed — the only question left is whether it's stale. One PyPI
|
|
598
598
|
// request a day, and the upgrade itself never blocks startup.
|
|
599
599
|
if (!updateCheckDue()) return { state: "present", version: existing };
|
|
600
|
-
|
|
600
|
+
// The marker is stamped AFTER the request, not before it. Stamped first, a
|
|
601
|
+
// boot with no network yet — a laptop opened on a train, the ten seconds
|
|
602
|
+
// before Wi-Fi associates — burned the whole shared 24-hour window on a
|
|
603
|
+
// check that never reached PyPI, and the next real chance was the day
|
|
604
|
+
// after. self-update.mjs states this rule for itself in as many words.
|
|
601
605
|
const latest = await latestOnPypi();
|
|
606
|
+
touchMarker();
|
|
602
607
|
if (latest && existing !== "installed" && isOlder(existing, latest)) {
|
|
603
608
|
// Who owns it, not what is installed on the machine: an upgrade aimed at
|
|
604
609
|
// a tool that never installed this package is refused where runDetached
|
package/src/server/exec.mjs
CHANGED
|
@@ -28,6 +28,30 @@
|
|
|
28
28
|
import { execFile, spawn } from "node:child_process";
|
|
29
29
|
import { existsSync } from "node:fs";
|
|
30
30
|
|
|
31
|
+
// WINDOWS SEARCHES THE WORKING DIRECTORY FIRST, and this turns that off.
|
|
32
|
+
//
|
|
33
|
+
// libuv's PATH search calls `NeedCurrentDirectoryForExePathW("")`, which is
|
|
34
|
+
// true unless this variable is set — so `spawn("cswap", …)` on Windows tries
|
|
35
|
+
// `.\cswap.exe` before anything on PATH, and the deck's working directory is
|
|
36
|
+
// wherever `npx ccdeck` was run: normally the user's project, often a repo they
|
|
37
|
+
// just cloned. `cswapBin()` probes the bare name before any known install path
|
|
38
|
+
// and memoises whatever answered, so a planted binary would then receive every
|
|
39
|
+
// later `cswap switch` and `cswap export -` — the commands that carry account
|
|
40
|
+
// credentials. The same search reaches py.exe, where.exe, claude.exe and
|
|
41
|
+
// powershell.exe.
|
|
42
|
+
//
|
|
43
|
+
// Set on this process rather than per spawn, because the search is done by the
|
|
44
|
+
// PARENT: one assignment covers every child this deck ever starts, including
|
|
45
|
+
// the ones spawned outside this module. An explicit value in the environment is
|
|
46
|
+
// left alone — someone who set it meant it.
|
|
47
|
+
//
|
|
48
|
+
// POSIX never had this behaviour: execvp does not search `.` unless PATH says
|
|
49
|
+
// so, so this is a no-op there and is skipped rather than written.
|
|
50
|
+
if (process.platform === "win32" && !("NoDefaultCurrentDirectoryInExePath" in process.env)) {
|
|
51
|
+
process.env.NoDefaultCurrentDirectoryInExePath = "1";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
31
55
|
// Extensions Windows will execute, most specific first. `.com` is omitted —
|
|
32
56
|
// nothing ships one, and every extra candidate costs a failed spawn.
|
|
33
57
|
const WIN_EXTS = [".exe", ".cmd", ".bat", ""];
|
|
@@ -682,6 +706,12 @@ export function run(cmd, args, { timeout = 20_000, maxBuffer = 4 << 20, env } =
|
|
|
682
706
|
// the shell command it replaced had to end in `< /dev/null`. Closing
|
|
683
707
|
// the pipe is that redirection without a shell to parse it.
|
|
684
708
|
try { cp.stdin?.on("error", () => {}); cp.stdin?.end(); } catch { /* no stdin to close */ }
|
|
709
|
+
// Decoded as a stream rather than per chunk: a chunk boundary falls
|
|
710
|
+
// wherever the pipe broke, and a multi-byte character split across two
|
|
711
|
+
// of them becomes two replacement characters in the tail this keeps for
|
|
712
|
+
// the timeout message.
|
|
713
|
+
cp.stdout?.setEncoding?.("utf8");
|
|
714
|
+
cp.stderr?.setEncoding?.("utf8");
|
|
685
715
|
cp.stdout?.on("data", (d) => { sawOut = (sawOut + d).slice(-TIMEOUT_TAIL); });
|
|
686
716
|
cp.stderr?.on("data", (d) => { sawErr = (sawErr + d).slice(-TIMEOUT_TAIL); });
|
|
687
717
|
// The deadline states the outcome itself and only then kills, which is
|
|
@@ -814,6 +844,15 @@ export function runInteractive(cmd, args, { timeout = 300_000, maxOutput = 256 <
|
|
|
814
844
|
return tryNext(err) ? attempt(i + 1) : finish(-1, err);
|
|
815
845
|
}
|
|
816
846
|
child = proc;
|
|
847
|
+
// EPIPE ON STDIN IS NOT A CRASH. `write` below is wrapped in a try/catch,
|
|
848
|
+
// and that catch can never fire for the case that matters: a broken pipe
|
|
849
|
+
// arrives asynchronously, as an 'error' event on the Writable, and an
|
|
850
|
+
// unhandled 'error' on a stream is an uncaught exception with no
|
|
851
|
+
// process-level net anywhere in this deck. `cswap import -` reading a
|
|
852
|
+
// prefix of a bad bundle and exiting before it drains is exactly that
|
|
853
|
+
// shape, so a rejected paste in the import dialog took the dashboard down
|
|
854
|
+
// with it. `run` has carried this same line since it was written.
|
|
855
|
+
proc.stdin?.on("error", () => {});
|
|
817
856
|
// A spelling that fails to spawn emits 'error' AND THEN 'close' — with code
|
|
818
857
|
// -2 after an ENOENT. Once the error handler has moved on to the next
|
|
819
858
|
// candidate, that trailing 'close' is news about a child nobody is waiting
|
|
@@ -845,6 +884,11 @@ export function runInteractive(cmd, args, { timeout = 300_000, maxOutput = 256 <
|
|
|
845
884
|
// Capped so a runaway child cannot grow the heap without bound; the tail is
|
|
846
885
|
// what carries the error, so the head is what gets dropped.
|
|
847
886
|
const keep = (buf, text) => (buf + text).slice(-maxOutput);
|
|
887
|
+
// Same reason as `run`'s tails: the login prompt this reader is waiting for
|
|
888
|
+
// arrives mid-chunk, and a UTF-8 sequence cut by a pipe boundary must not
|
|
889
|
+
// become two replacement characters in the line it emits.
|
|
890
|
+
proc.stdout?.setEncoding?.("utf8");
|
|
891
|
+
proc.stderr?.setEncoding?.("utf8");
|
|
848
892
|
proc.stdout?.on("data", (d) => { if (stale()) return; const t = String(d); stdout = keep(stdout, t); emitLines(t); });
|
|
849
893
|
proc.stderr?.on("data", (d) => { if (stale()) return; const t = String(d); stderr = keep(stderr, t); emitLines(t); });
|
|
850
894
|
proc.on("close", (code) => {
|
package/src/server/index.mjs
CHANGED
|
@@ -662,7 +662,7 @@ export const MAX_SCAN_CHUNK = 8 * 1024 * 1024;
|
|
|
662
662
|
// pointing one POST at something arbitrarily large: the read still terminates,
|
|
663
663
|
// the cursor keeps whatever it reached, and the next throttled pass continues
|
|
664
664
|
// from there.
|
|
665
|
-
|
|
665
|
+
const MAX_SCAN_BYTES_PER_PASS = 256 * 1024 * 1024;
|
|
666
666
|
|
|
667
667
|
const NEWLINE = 0x0a;
|
|
668
668
|
|
|
@@ -1458,7 +1458,7 @@ const pendingNameReads = new Set(); // sid currently being read
|
|
|
1458
1458
|
/** The naming the cursor has folded so far, or null when the scan has nothing.
|
|
1459
1459
|
* Exported beside readContextFromTranscript for the same reason: the rule is
|
|
1460
1460
|
* worth pinning directly rather than through a live server. */
|
|
1461
|
-
|
|
1461
|
+
async function readSessionNamingFromTranscript(path) {
|
|
1462
1462
|
const state = await scanTranscript(path);
|
|
1463
1463
|
if (!state) return null;
|
|
1464
1464
|
if (!state.agentName && !state.aiTitle) return null;
|
|
@@ -3470,7 +3470,13 @@ export function replayScope(workspace, platform = process.platform) {
|
|
|
3470
3470
|
* that case cheap needs an index of where a workspace's lines are, which is a
|
|
3471
3471
|
* different change from this one.
|
|
3472
3472
|
*/
|
|
3473
|
-
|
|
3473
|
+
/* Exported for the suite, with both ceilings as parameters. The byte budget is
|
|
3474
|
+
* 128 MiB, so a test that wanted to reach it honestly would have to write 128
|
|
3475
|
+
* MiB — which is why the count bound was the only one anything pinned, and why
|
|
3476
|
+
* the byte bound was the one that broke. Production passes neither argument. */
|
|
3477
|
+
export async function replayLog(filePath, workspace = "", {
|
|
3478
|
+
maxEvents = MAX_BUFFER, maxChars = MAX_BUFFER_CHARS,
|
|
3479
|
+
} = {}) {
|
|
3474
3480
|
if (!existsSync(filePath)) return 0;
|
|
3475
3481
|
let skipped = 0;
|
|
3476
3482
|
let skippedBytes = 0;
|
|
@@ -3499,17 +3505,32 @@ async function replayLog(filePath, workspace = "") {
|
|
|
3499
3505
|
}
|
|
3500
3506
|
} else {
|
|
3501
3507
|
// Newest first, so this is filled back to front and then walked in reverse
|
|
3502
|
-
// to push. Bounded by
|
|
3503
|
-
// property of the ring rather than of the file.
|
|
3508
|
+
// to push. Bounded by BOTH of the ring's limits, which is what makes the
|
|
3509
|
+
// memory here a property of the ring rather than of the file.
|
|
3510
|
+
//
|
|
3511
|
+
// The count alone was not enough, and the comment that used to say it was
|
|
3512
|
+
// predates #625. Eviction is the only thing that applies MAX_BUFFER_CHARS,
|
|
3513
|
+
// and eviction happens inside pushEvent — which does not run until this
|
|
3514
|
+
// array is already full. Measured on a 187 MB log of 40 events of 4.9M
|
|
3515
|
+
// characters each, every one of them under the ingest cap: RSS went from
|
|
3516
|
+
// 215 MB to a peak of 505 MB, and the ring that survived held 27 events and
|
|
3517
|
+
// 126 MiB. About 290 MB staged for a ring capped at 128.
|
|
3518
|
+
//
|
|
3519
|
+
// Rotation at 50 MB normally keeps logs well under this, but rotation is
|
|
3520
|
+
// best-effort and its failure is only logged, and this file's own header
|
|
3521
|
+
// records logs reaching gigabytes.
|
|
3504
3522
|
const newestFirst = [];
|
|
3523
|
+
let stagedChars = 0;
|
|
3505
3524
|
for await (const line of linesFromEnd(filePath)) {
|
|
3506
3525
|
if (!line) continue;
|
|
3507
3526
|
const evt = parse(line);
|
|
3508
3527
|
if (!usable(evt) || !admits(evt.payload)) continue;
|
|
3509
3528
|
newestFirst.push(evt);
|
|
3529
|
+
stagedChars += ENVELOPE_CHARS + payloadChars(evt.payload);
|
|
3510
3530
|
// Everything older than this would be evicted by the events already held,
|
|
3511
3531
|
// so reading further is work whose only result is throwing it away.
|
|
3512
|
-
|
|
3532
|
+
// Either limit reaching its ceiling means exactly that.
|
|
3533
|
+
if (newestFirst.length >= maxEvents || stagedChars >= maxChars) break;
|
|
3513
3534
|
}
|
|
3514
3535
|
for (let i = newestFirst.length - 1; i >= 0; i--) replay(newestFirst[i]);
|
|
3515
3536
|
count = newestFirst.length;
|
|
@@ -4116,7 +4137,7 @@ async function handleBrowserWatchSettings(req, res) {
|
|
|
4116
4137
|
try { body = JSON.parse(raw ?? ""); } catch { /* handled below */ }
|
|
4117
4138
|
if (!body || typeof body !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
|
|
4118
4139
|
|
|
4119
|
-
const { readStore,
|
|
4140
|
+
const { readStore, updateStore, normalise } = await import(
|
|
4120
4141
|
pathToFileURL(join(PKG_ROOT, "src/server/browser-watch-store.mjs")).href
|
|
4121
4142
|
);
|
|
4122
4143
|
const { invalidateBrowserWatchCache, noteWatchSetting } = await import(
|
|
@@ -4132,7 +4153,11 @@ async function handleBrowserWatchSettings(req, res) {
|
|
|
4132
4153
|
// this field ERASES it. Measured — changing the reaction wiped every
|
|
4133
4154
|
// dismissal, so every episode the reader had reviewed came straight back on
|
|
4134
4155
|
// the next poll, from a settings change that had nothing to do with them.
|
|
4135
|
-
|
|
4156
|
+
// Only the settings are this route's to change. `updateStore` re-reads inside
|
|
4157
|
+
// the write queue, so a poll that landed between the read above and this line
|
|
4158
|
+
// cannot have its archive thrown away by a settings change — which is what
|
|
4159
|
+
// writing a whole state read seconds earlier used to do.
|
|
4160
|
+
await updateStore(cur => ({ ...cur, settings }));
|
|
4136
4161
|
// The one line in the log that is somebody acting rather than the deck
|
|
4137
4162
|
// reading, which is exactly why it is worth its own entry.
|
|
4138
4163
|
if (settings.enabled !== store.settings.enabled) {
|
|
@@ -4168,7 +4193,7 @@ async function handleBrowserWatchDismiss(req, res) {
|
|
|
4168
4193
|
const startMs = typeof body?.startMs === "number" && Number.isFinite(body.startMs) ? body.startMs : null;
|
|
4169
4194
|
if (host === null || startMs === null) return send(res, 400, { ok: false, reason: "bad_request" });
|
|
4170
4195
|
|
|
4171
|
-
const { readStore,
|
|
4196
|
+
const { readStore, updateStore, episodeKey } = await import(
|
|
4172
4197
|
pathToFileURL(join(PKG_ROOT, "src/server/browser-watch-store.mjs")).href
|
|
4173
4198
|
);
|
|
4174
4199
|
const { invalidateBrowserWatchCache, noteWatchSetting } = await import(
|
|
@@ -4177,7 +4202,7 @@ async function handleBrowserWatchDismiss(req, res) {
|
|
|
4177
4202
|
const store = await readStore();
|
|
4178
4203
|
const key = episodeKey(host, startMs);
|
|
4179
4204
|
const dismissed = [...new Set([...(store.dismissed ?? []), key])];
|
|
4180
|
-
await
|
|
4205
|
+
await updateStore(cur => ({ ...cur, dismissed }));
|
|
4181
4206
|
// The reader acting on their own list, which is exactly the kind of line the
|
|
4182
4207
|
// `act` level exists for.
|
|
4183
4208
|
noteWatchSetting(`dismissed ${host}`);
|
|
@@ -4188,6 +4213,12 @@ async function handleBrowserWatchDismiss(req, res) {
|
|
|
4188
4213
|
async function handleBrowserWatch(req, res) {
|
|
4189
4214
|
const url = new URL(req.url, "http://localhost");
|
|
4190
4215
|
const force = url.searchParams.get("refresh") === "1";
|
|
4216
|
+
// `live=0` is the badge's five-minute poll saying "the archive is enough".
|
|
4217
|
+
// With the watch OFF that is honoured and no browser is read at all — see
|
|
4218
|
+
// browserWatchSnapshot: the switch used to gate only what was KEPT, so a deck
|
|
4219
|
+
// nobody had switched on still copied every History database every five
|
|
4220
|
+
// minutes. A forced read overrides it, because that is the user pressing ↻.
|
|
4221
|
+
const readBrowsers = force || url.searchParams.get("live") !== "0";
|
|
4191
4222
|
|
|
4192
4223
|
// Numbers from a query string are refused rather than coerced: NaN would
|
|
4193
4224
|
// silently widen the quiet gate to "everything counts", which is the failure
|
|
@@ -4217,6 +4248,7 @@ async function handleBrowserWatch(req, res) {
|
|
|
4217
4248
|
const ports = await registeredDeckPorts();
|
|
4218
4249
|
return send(res, 200, await fetchBrowserWatch({
|
|
4219
4250
|
force,
|
|
4251
|
+
readBrowsers,
|
|
4220
4252
|
deckOrigins: deckOwnOrigins(undefined, ports),
|
|
4221
4253
|
quietMs: minutes("quiet"),
|
|
4222
4254
|
gapMs: minutes("gap"),
|
|
@@ -4676,9 +4708,19 @@ function handleHookChallenge(_req, res, url) {
|
|
|
4676
4708
|
send(res, 200, { proof: challengeProof(HOOK_TOKEN, nonce) });
|
|
4677
4709
|
}
|
|
4678
4710
|
|
|
4711
|
+
// Signal 0 delivers nothing; it asks whether the pid could be signalled.
|
|
4712
|
+
//
|
|
4713
|
+
// BOTH ERRNOS, and the second one is the Windows spelling. POSIX `kill(2)`
|
|
4714
|
+
// answers EPERM for a process this account may not signal. On Windows
|
|
4715
|
+
// `uv_kill` calls `OpenProcess`, a denial is ERROR_ACCESS_DENIED, and libuv
|
|
4716
|
+
// maps that to EACCES — so a deck started from an elevated terminal, or under
|
|
4717
|
+
// another account, read as DEAD to every probe in this repo. What followed was
|
|
4718
|
+
// silent: the live deck's discovery file was unlinked on the next hook fire,
|
|
4719
|
+
// rewritten five seconds later by keepDiscovery, and its banner went on
|
|
4720
|
+
// claiming it was receiving events it had stopped receiving.
|
|
4679
4721
|
function isProcessAlive(pid) {
|
|
4680
4722
|
try { process.kill(pid, 0); return true; }
|
|
4681
|
-
catch (e) { return e && e.code === "EPERM"; }
|
|
4723
|
+
catch (e) { return !!e && (e.code === "EPERM" || e.code === "EACCES"); }
|
|
4682
4724
|
}
|
|
4683
4725
|
|
|
4684
4726
|
async function sweepStaleDiscovery() {
|
|
@@ -4990,6 +5032,72 @@ function isDeckUiRequest({ origin, host, secFetchSite } = {}) {
|
|
|
4990
5032
|
// presents nothing at all no longer changes anything — and what is opened is
|
|
4991
5033
|
// the honest door, so a script of the user's own authenticates by reading the
|
|
4992
5034
|
// token instead of impersonating a page.
|
|
5035
|
+
/**
|
|
5036
|
+
* May this request read the deck's OWN data — the events, the accounts, the
|
|
5037
|
+
* browsing episodes?
|
|
5038
|
+
*
|
|
5039
|
+
* The mutation gate exists because `curl -XPOST localhost:4317/…/admin` handed
|
|
5040
|
+
* a live OAuth refresh token to a sandboxed subprocess with loopback egress.
|
|
5041
|
+
* The same caller could still `curl localhost:4317/api/events` and read the
|
|
5042
|
+
* whole ring — prompt text, the Bash command lines the agent ran, the paths and
|
|
5043
|
+
* contents it wrote, the contents of every file it read back — plus the account
|
|
5044
|
+
* roster and the browsing episodes. The threat model had been applied to half
|
|
5045
|
+
* the surface.
|
|
5046
|
+
*
|
|
5047
|
+
* Same shape as isAuthorizedMutation, with one difference forced by the
|
|
5048
|
+
* browser: a same-origin GET carries no `Origin` header at all, so the UI
|
|
5049
|
+
* cannot be recognised the way a POST is. `Sec-Fetch-Site: same-origin` is what
|
|
5050
|
+
* a page's own fetch and its EventSource both send, on every browser new enough
|
|
5051
|
+
* to run this bundle, and it is a header no non-browser client sends by
|
|
5052
|
+
* accident. A caller that sends neither it nor the token is not a page.
|
|
5053
|
+
*
|
|
5054
|
+
* WHAT STAYS OPEN, deliberately: /api/health (the hook's readiness probe),
|
|
5055
|
+
* /api/hook-challenge (the handshake itself), the static files, and every
|
|
5056
|
+
* measurement route — the machine panel's numbers are about the machine, not
|
|
5057
|
+
* about what the user is doing on it.
|
|
5058
|
+
*/
|
|
5059
|
+
function isAuthorizedDataRead(req) {
|
|
5060
|
+
const headers = req?.headers ?? {};
|
|
5061
|
+
if (presentsDeckToken(headers)) return true;
|
|
5062
|
+
// Addressed to this machine by a name that can only be this machine — so a
|
|
5063
|
+
// rebound page, which also reports same-origin, does not qualify.
|
|
5064
|
+
if (!isLoopbackHost(headers.host)) return false;
|
|
5065
|
+
|
|
5066
|
+
const site = typeof headers["sec-fetch-site"] === "string"
|
|
5067
|
+
? headers["sec-fetch-site"].trim().toLowerCase() : "";
|
|
5068
|
+
if (site === "same-origin") return true;
|
|
5069
|
+
// ANY FETCH METADATA AT ALL, AND IT SAID SOMETHING ELSE. `cross-site`,
|
|
5070
|
+
// `same-site` and `none` are all a page that is not this one — or a top-level
|
|
5071
|
+
// navigation typed into the address bar, which has no business reading the
|
|
5072
|
+
// ring.
|
|
5073
|
+
if (site !== "") return false;
|
|
5074
|
+
|
|
5075
|
+
// THE BROWSER THAT SENDS NO FETCH METADATA, and this is the whole reason this
|
|
5076
|
+
// branch exists. Sec-Fetch-Site is Safari 16.4 and newer; Safari 16.0-16.3
|
|
5077
|
+
// runs this bundle perfectly well (Vite's default target is Safari 16) and
|
|
5078
|
+
// sends none of it. Without a fallback those users get an empty canvas and a
|
|
5079
|
+
// 401 they cannot act on — an impediment for a browser that is otherwise
|
|
5080
|
+
// fine.
|
|
5081
|
+
//
|
|
5082
|
+
// Referer is what they do send, on a page's own fetches and on its
|
|
5083
|
+
// EventSource, and it must name THIS origin. A cross-site page's Referer
|
|
5084
|
+
// names its own; a rebound page's names the attacker's host, which is not a
|
|
5085
|
+
// loopback identity. It is forgeable by a non-browser client — and so is
|
|
5086
|
+
// Sec-Fetch-Site, which curl sets as easily; neither is the control that
|
|
5087
|
+
// stops a deliberate local caller. That control is the token, and this only
|
|
5088
|
+
// decides which BROWSERS are recognised as the deck's own page.
|
|
5089
|
+
return originMatchesHost(headers.referer, headers.host);
|
|
5090
|
+
}
|
|
5091
|
+
|
|
5092
|
+
/** The reads that carry the user's own work, rather than the machine's. */
|
|
5093
|
+
const GUARDED_READS = new Set([
|
|
5094
|
+
"/events",
|
|
5095
|
+
"/api/events",
|
|
5096
|
+
"/api/claude-accounts",
|
|
5097
|
+
"/api/claude-accounts/login",
|
|
5098
|
+
"/api/browser-watch",
|
|
5099
|
+
]);
|
|
5100
|
+
|
|
4993
5101
|
function isAuthorizedMutation(req) {
|
|
4994
5102
|
const headers = req?.headers ?? {};
|
|
4995
5103
|
if (presentsDeckToken(headers)) return true;
|
|
@@ -5200,6 +5308,14 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
5200
5308
|
return send(res, 401, { error: "unauthenticated" });
|
|
5201
5309
|
}
|
|
5202
5310
|
|
|
5311
|
+
// And the reads that carry the same secrets. See isAuthorizedDataRead: the
|
|
5312
|
+
// gate above was written for a sandboxed subprocess with loopback egress,
|
|
5313
|
+
// and that caller was reading the ring through a GET the whole time.
|
|
5314
|
+
if ((req.method === "GET" || req.method === "HEAD")
|
|
5315
|
+
&& GUARDED_READS.has(url.pathname) && !isAuthorizedDataRead(req)) {
|
|
5316
|
+
return send(res, 401, { error: "unauthenticated" });
|
|
5317
|
+
}
|
|
5318
|
+
|
|
5203
5319
|
// `?persist=0` — another deck was elected to write this event to the log
|
|
5204
5320
|
// the two of them share. Absent, this deck writes it.
|
|
5205
5321
|
if (req.method === "POST" && url.pathname === "/api/event") return guard(handleEventIngest(req, res, url.searchParams.get("persist") !== "0"), res);
|
package/src/server/installer.mjs
CHANGED
|
@@ -353,7 +353,31 @@ async function writeFileAtomic(rawTarget, text) {
|
|
|
353
353
|
// to stay 600. No-op on Windows, where chmod only toggles the read-only bit.
|
|
354
354
|
const mode = await stat(target).then(s => s.mode, () => null);
|
|
355
355
|
if (mode !== null) await chmod(tmp, mode).catch(() => {});
|
|
356
|
+
// A READ-ONLY TARGET IS A DEAD END ON WINDOWS, and only there. libuv's
|
|
357
|
+
// rename is one MoveFileExW(MOVEFILE_REPLACE_EXISTING), which refuses to
|
|
358
|
+
// replace a destination carrying FILE_ATTRIBUTE_READONLY; POSIX rename(2)
|
|
359
|
+
// over a 0444 file succeeds, because only the parent directory's write bit
|
|
360
|
+
// decides. A settings.json picks that attribute up from a OneDrive restore,
|
|
361
|
+
// a copy off a network share, or read-only media — and EACCES is in the
|
|
362
|
+
// retry ladder, so the whole ~1.4s was spent before throwing, on every
|
|
363
|
+
// boot, forever. Every settings writer goes through here, so hooks never
|
|
364
|
+
// installed and the sound-hook retirement could never repair a stale entry
|
|
365
|
+
// either.
|
|
366
|
+
//
|
|
367
|
+
// chmod on Windows toggles exactly that attribute and nothing else, which
|
|
368
|
+
// is why this is safe to do unconditionally there: the mode carried above
|
|
369
|
+
// is re-applied to the new file after the rename, so a file the user marked
|
|
370
|
+
// read-only stays read-only.
|
|
371
|
+
// ONLY WHEN THE TARGET IS ACTUALLY READ-ONLY. Two extra syscalls on the
|
|
372
|
+
// path between the temp write and the rename are not free on Windows:
|
|
373
|
+
// discovery-live.test.ts hammers writeDiscovery while a reader holds the
|
|
374
|
+
// destination open, and the wider window turned a rename the retry ladder
|
|
375
|
+
// used to win into an EPERM it gave up on. The attribute is what this
|
|
376
|
+
// clears, so a file that does not carry it has nothing to clear.
|
|
377
|
+
const readOnly = process.platform === "win32" && mode !== null && (mode & 0o200) === 0;
|
|
378
|
+
if (readOnly) await chmod(target, 0o666).catch(() => {});
|
|
356
379
|
await renameWithRetry(tmp, target);
|
|
380
|
+
if (readOnly) await chmod(target, mode).catch(() => {});
|
|
357
381
|
} catch (err) {
|
|
358
382
|
// Cleanup covers the write and the fsync as well as the rename: a full disk
|
|
359
383
|
// used to leave the half-written temp file sitting beside the target.
|
|
@@ -405,7 +429,7 @@ function dedupeOurEntries(group) {
|
|
|
405
429
|
}
|
|
406
430
|
|
|
407
431
|
/** Install hooks for a single provider. Returns {settingsPath, hookPath, events, changed}. */
|
|
408
|
-
export async function installHooks({ provider = "claude" } = {}) {
|
|
432
|
+
export async function installHooks({ provider = "claude", beforeWrite = null } = {}) {
|
|
409
433
|
const cfg = PROVIDERS[provider];
|
|
410
434
|
if (!cfg) throw new Error(`unknown provider: ${provider}`);
|
|
411
435
|
// An uninstall-only provider has no event list. Saying so beats the
|
|
@@ -481,7 +505,38 @@ export async function installHooks({ provider = "claude" } = {}) {
|
|
|
481
505
|
// compare against the exact bytes we read and, when they match, do nothing.
|
|
482
506
|
const next = JSON.stringify(current, null, 2) + "\n";
|
|
483
507
|
const changed = next !== before;
|
|
484
|
-
if (changed)
|
|
508
|
+
if (changed) {
|
|
509
|
+
// COMPARE AGAINST THE FILE, NOT AGAINST THE SNAPSHOT, at the last moment.
|
|
510
|
+
//
|
|
511
|
+
// Everything above was computed from bytes read at the top of this
|
|
512
|
+
// function, and two decks booting together — the ordinary case on a machine
|
|
513
|
+
// where one was already running — interleave inside that window. The one
|
|
514
|
+
// that loses is unrecoverable rather than merely stale: deck A restores the
|
|
515
|
+
// user's own sound hooks from the parked file and deletes the park, and
|
|
516
|
+
// deck B then writes a settings object computed before that restore, with
|
|
517
|
+
// an empty park behind it. The user's hook is gone from settings.json and
|
|
518
|
+
// from the only other copy of it.
|
|
519
|
+
//
|
|
520
|
+
// So the file is re-read immediately before the write and, if another
|
|
521
|
+
// writer has touched it, this pass declines. Declining is safe by
|
|
522
|
+
// construction: every boot reinstalls, so the next one recomputes against
|
|
523
|
+
// the new bytes and converges — and the entries this function adds are
|
|
524
|
+
// identical on both decks, which is why the loser has nothing of its own to
|
|
525
|
+
// lose.
|
|
526
|
+
// The seam the suite needs, and the only way to test this deterministically:
|
|
527
|
+
// the window between the read at the top and the write below is filled with
|
|
528
|
+
// real fs work, so a test that raced it by wall clock would pass or fail by
|
|
529
|
+
// how fast the machine is. Production passes nothing.
|
|
530
|
+
if (beforeWrite) await beforeWrite();
|
|
531
|
+
const { raw: onDisk } = await readSettingsForWrite(cfg.settingsPath).catch(() => ({ raw: before }));
|
|
532
|
+
if (onDisk !== before) {
|
|
533
|
+
return {
|
|
534
|
+
settingsPath: cfg.settingsPath, hookPath, events: cfg.events, provider,
|
|
535
|
+
changed: false, raced: true, retire: { ...retire, pending: false },
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
await writeFileAtomic(cfg.settingsPath, next);
|
|
539
|
+
}
|
|
485
540
|
// After the write, never before it: the notify script an older deck installed
|
|
486
541
|
// is what a live session's cached command still names until the new entry is
|
|
487
542
|
// on disk, and deleting it early turns a stale sound into a missing module.
|
|
@@ -773,4 +828,4 @@ export { AGENT_DAG_DIR, CLAUDE_DIR, CODEX_DIR };
|
|
|
773
828
|
// repo for the same reasons settings.json is, and "never rename onto a link" is
|
|
774
829
|
// one rule: codex-auth.mjs called a realpath of its own before this existed, and
|
|
775
830
|
// two spellings of a rule are two things that can drift.
|
|
776
|
-
export { readSettingsForWrite, writeFileAtomic,
|
|
831
|
+
export { readSettingsForWrite, writeFileAtomic, renameWithRetry, createTemp, resolveWriteTarget };
|
package/src/server/macmon.mjs
CHANGED
|
@@ -193,11 +193,6 @@ export function macmonAsset(release) {
|
|
|
193
193
|
return { version: typeof tag === "string" ? tag : "unknown", url: a.browser_download_url, sha256: m[1] };
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
-
/** A macmon this function installed earlier, or null. */
|
|
197
|
-
export function existingBootstrappedMacmon() {
|
|
198
|
-
const bin = join(MACMON_DIR, "macmon");
|
|
199
|
-
return existsSync(bin) ? bin : null;
|
|
200
|
-
}
|
|
201
196
|
|
|
202
197
|
/**
|
|
203
198
|
* Download macmon into ~/.agents-deck/tools/macmon.
|
|
@@ -210,9 +205,25 @@ export function existingBootstrappedMacmon() {
|
|
|
210
205
|
* ioreg and never reaches this file at all.
|
|
211
206
|
*/
|
|
212
207
|
export async function bootstrapMacmon({
|
|
213
|
-
platform = process.platform,
|
|
208
|
+
platform = process.platform, arch = process.arch, env = process.env,
|
|
209
|
+
fetchFn = fetch, dir = MACMON_DIR, findBin = macmonBin,
|
|
214
210
|
} = {}) {
|
|
215
211
|
if (platform !== "darwin") return { ok: false, reason: "unsupported_platform" };
|
|
212
|
+
// ARM64 ONLY, checked rather than merely documented. The release publishes
|
|
213
|
+
// one asset and it is an arm64 Mach-O, so an Intel Mac downloaded 746 KB it
|
|
214
|
+
// could not execute and failed its own --version check afterwards. That is
|
|
215
|
+
// the small half. The larger half is the promise: the README says an Intel
|
|
216
|
+
// Mac never downloads anything, and this reached api.github.com on any Mac
|
|
217
|
+
// whose sensors happened to stay silent — which an Intel Mac's do whenever
|
|
218
|
+
// ioreg publishes no Temperature(C).
|
|
219
|
+
if (arch !== "arm64") return { ok: false, reason: "unsupported_arch" };
|
|
220
|
+
// A macmon the user already has is the other thing the README promises to
|
|
221
|
+
// notice, and until now the skip was emergent rather than checked: a working
|
|
222
|
+
// copy produces a reading, the reading sets thermalEverAnswered, and the
|
|
223
|
+
// give-up branch never fires. That chain breaks on a macmon which runs but
|
|
224
|
+
// reports values this deck rejects as implausible — and then a machine with
|
|
225
|
+
// macmon on PATH downloaded a second one.
|
|
226
|
+
if (await findBin()) return { ok: false, reason: "already_installed" };
|
|
216
227
|
// Both switches, for the reason uv-bootstrap has both: downloading an
|
|
217
228
|
// executable is a bigger step than installing a package with a tool the user
|
|
218
229
|
// already chose, so somebody may want the managed installs and not this.
|
package/src/server/npx.mjs
CHANGED
|
@@ -194,7 +194,12 @@ export function npxPrefetch(spec, {
|
|
|
194
194
|
timer.unref?.();
|
|
195
195
|
|
|
196
196
|
child.on("error", err => settle({ ok: false, error: `could not run npx: ${err?.message ?? err}`, hint: null }));
|
|
197
|
-
|
|
197
|
+
// 'close' rather than 'exit': 'exit' fires when the process ends and says
|
|
198
|
+
// nothing about its pipes, so past one pipe buffer the tail this kept —
|
|
199
|
+
// deliberately the TAIL, because npm's reason is on its last lines — was
|
|
200
|
+
// still only the first 8 KiB when npxFailureSummary read it. 'close' is
|
|
201
|
+
// emitted after both streams have been drained.
|
|
202
|
+
child.on("close", (code, signal) => {
|
|
198
203
|
if (code === 0) { settle({ ok: true, error: null, hint: null }); return; }
|
|
199
204
|
settle({
|
|
200
205
|
ok: false,
|
package/src/server/open-url.mjs
CHANGED
|
@@ -166,11 +166,31 @@ export function startCommand(url, env = process.env, comspec) {
|
|
|
166
166
|
* it and `start` would run it.
|
|
167
167
|
*/
|
|
168
168
|
export function isOpenable(url) {
|
|
169
|
+
return normalizeOpenable(url) !== null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The address to actually hand the desktop, or null when it is not one.
|
|
174
|
+
*
|
|
175
|
+
* The guard used to parse into a local `u`, check its protocol and throw the
|
|
176
|
+
* parse away — so what reached `launchers` was the RAW string. `new URL()`
|
|
177
|
+
* accepts characters it normalizes only in `href`, a `"` among them, and on
|
|
178
|
+
* Windows `startCommand` builds `start "" "<url>"` with
|
|
179
|
+
* `windowsVerbatimArguments: true`, where a quote is syntax. Today the only
|
|
180
|
+
* caller passes a loopback URL this deck built itself, so nothing is reachable;
|
|
181
|
+
* the function's own doc says it exists "for the day a second caller passes a
|
|
182
|
+
* path", and as written it would not have stopped that caller.
|
|
183
|
+
*
|
|
184
|
+
* Returning the normalized `href` means the string that was checked is the
|
|
185
|
+
* string that is launched.
|
|
186
|
+
*/
|
|
187
|
+
export function normalizeOpenable(url) {
|
|
169
188
|
try {
|
|
170
189
|
const u = new URL(String(url));
|
|
171
|
-
|
|
190
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
|
|
191
|
+
return u.href;
|
|
172
192
|
} catch {
|
|
173
|
-
return
|
|
193
|
+
return null;
|
|
174
194
|
}
|
|
175
195
|
}
|
|
176
196
|
|
|
@@ -183,8 +203,9 @@ export function isOpenable(url) {
|
|
|
183
203
|
* the same recovery an error row would have offered.
|
|
184
204
|
*/
|
|
185
205
|
export function openUrl(url, { platform = process.platform, env = process.env, spawnFn = spawn } = {}) {
|
|
186
|
-
|
|
187
|
-
|
|
206
|
+
const href = normalizeOpenable(url);
|
|
207
|
+
if (href === null) return;
|
|
208
|
+
const tries = launchers(href, { platform, env });
|
|
188
209
|
|
|
189
210
|
const attempt = (i) => {
|
|
190
211
|
if (i >= tries.length) return;
|