ccdeck 3.22.14 → 3.23.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 +53 -10
- package/THIRD_PARTY_NOTICES.md +123 -15
- package/bin/deck.js +264 -21
- package/dist/web/assets/BrowserWatchModal-DD6srgsk.js +1 -0
- package/dist/web/assets/UsageHistoryModal-zExZpRWU.js +1 -0
- package/dist/web/assets/index-Bf-qcZ6x.css +1 -0
- package/dist/web/assets/index-Dsca2TwS.js +296 -0
- package/dist/web/index.html +9 -5
- package/hook/hook.js +631 -99
- package/package.json +1 -1
- package/release-notes.json +62 -1
- package/src/server/activity.mjs +42 -4
- package/src/server/args.mjs +30 -4
- package/src/server/browser-history.mjs +164 -38
- package/src/server/browser-presence.mjs +60 -0
- package/src/server/browser-react.mjs +160 -7
- package/src/server/browser-watch-store.mjs +319 -17
- package/src/server/browser-watch.mjs +304 -60
- package/src/server/ccusage.mjs +20 -4
- package/src/server/claude-accounts.mjs +79 -34
- package/src/server/claude-identity.mjs +121 -0
- package/src/server/cswap-admin.mjs +160 -133
- package/src/server/cswap-auto.mjs +86 -7
- package/src/server/cswap-install.mjs +280 -43
- package/src/server/deck-home.mjs +1 -1
- package/src/server/deck-prefs.mjs +252 -36
- package/src/server/deck-probe.mjs +1 -1
- package/src/server/exec.mjs +84 -3
- package/src/server/global-install.mjs +13 -0
- package/src/server/index.mjs +1454 -182
- package/src/server/installer.mjs +136 -8
- package/src/server/lan-about.mjs +8 -3
- package/src/server/lan-engine.mjs +385 -51
- package/src/server/lan-socket.mjs +384 -57
- package/src/server/lan-sync.mjs +495 -22
- package/src/server/log-writer.mjs +575 -2
- package/src/server/login-service.mjs +31 -4
- package/src/server/open-url.mjs +22 -2
- package/src/server/output-watch.mjs +59 -8
- package/src/server/purge-key.mjs +135 -0
- package/src/server/quota.mjs +112 -7
- package/src/server/running-deck.mjs +15 -1
- package/src/server/self-update.mjs +98 -4
- package/src/server/session-recap.mjs +89 -0
- package/src/server/static-cache.mjs +67 -0
- package/src/server/stop-deck.mjs +2 -2
- package/src/server/store-lock.mjs +51 -0
- package/src/server/supervisor.mjs +1 -1
- package/src/server/system-metrics.mjs +143 -17
- package/dist/web/assets/index-BdYsAtN8.js +0 -274
- package/dist/web/assets/index-CgwmcBHv.css +0 -1
|
@@ -23,15 +23,26 @@
|
|
|
23
23
|
// each would be a switch nobody could reason about.
|
|
24
24
|
//
|
|
25
25
|
// The write is the atomic one browser-watch-store.mjs argues for at length —
|
|
26
|
-
// temp file, rename — because the alternative is a truncated JSON
|
|
27
|
-
// the only record of what the user chose
|
|
28
|
-
//
|
|
29
|
-
|
|
26
|
+
// temp file, fsync, rename — because the alternative is a truncated JSON
|
|
27
|
+
// document as the only record of what the user chose.
|
|
28
|
+
//
|
|
29
|
+
// And the READ is the other half of that sentence, which took #1002 to notice.
|
|
30
|
+
// An atomic write makes a truncated prefs.json rare; it does not make it
|
|
31
|
+
// impossible — a full disk, a power cut, an editor saving garbage — and what
|
|
32
|
+
// the deck did when it met one was read it as "nothing chosen yet" and then
|
|
33
|
+
// write defaults over it, destroying the LAN key every paired machine had
|
|
34
|
+
// pinned. A file that cannot be parsed is now moved aside and said out loud;
|
|
35
|
+
// only a genuinely ABSENT file starts clean. See loadPrefs.
|
|
36
|
+
import { chmod, mkdir, readFile, unlink } from "node:fs/promises";
|
|
30
37
|
// The rename, with the Windows retry ladder installer.mjs wrote for exactly
|
|
31
|
-
// this call. See the note over the write below (#786).
|
|
32
|
-
|
|
38
|
+
// this call. See the note over the write below (#786). `stripBom` and
|
|
39
|
+
// `createTemp` come from the same module for the reason its export block gives:
|
|
40
|
+
// a rule spelled twice is a rule that drifts, and both of these are rules
|
|
41
|
+
// settings.json and auth.json already follow on files with the same stakes.
|
|
42
|
+
import { createTemp, renameWithRetry, stripBom } from "./installer.mjs";
|
|
33
43
|
import { join } from "node:path";
|
|
34
44
|
import { deckDataDir } from "./deck-home.mjs";
|
|
45
|
+
import { PRODUCT } from "./brand.mjs";
|
|
35
46
|
|
|
36
47
|
/** Set to "1" to keep the deck off the desktop whatever the stored preference
|
|
37
48
|
* says. Same sheet of switches as AGENTS_DECK_NO_DOWNLOAD and
|
|
@@ -89,6 +100,11 @@ export const DEFAULTS = Object.freeze({
|
|
|
89
100
|
// until somebody ticks a login: the gate that did not change.
|
|
90
101
|
autoAsk: true,
|
|
91
102
|
autoAccept: false,
|
|
103
|
+
// WHICH ACCOUNT THIS DECK IS ON, told to the decks it is paired with — and
|
|
104
|
+
// only ever one it shares, so an unticked account is never named. On, so two
|
|
105
|
+
// of one person's machines show each other where they are working; off, and
|
|
106
|
+
// paired decks read "current account hidden" instead.
|
|
107
|
+
shareActive: true,
|
|
92
108
|
// What somebody HERE calls another deck, keyed by its fingerprint. The name
|
|
93
109
|
// a deck gives itself is its owner's to choose; this is the other half.
|
|
94
110
|
aliases: Object.freeze({}),
|
|
@@ -102,10 +118,12 @@ export const DEFAULTS = Object.freeze({
|
|
|
102
118
|
* machine the default mode hands it to every other account on the box, and
|
|
103
119
|
* from it they can decrypt any credential that crosses the network.
|
|
104
120
|
*
|
|
105
|
-
*
|
|
121
|
+
* Named on the CREATE rather than applied with a follow-up chmod, and the
|
|
106
122
|
* difference is the whole point — claude-swap's transfer.py makes the same
|
|
107
|
-
* argument at length: a
|
|
108
|
-
* window between the two, which is exactly when a secret is in it.
|
|
123
|
+
* argument at length: a create-then-chmod leaves the file readable for the
|
|
124
|
+
* window between the two, which is exactly when a secret is in it. The chmod
|
|
125
|
+
* that does follow the create is not that: it only pins a file the umask may
|
|
126
|
+
* have made narrower, and cannot widen one past what the create allowed. */
|
|
109
127
|
export const PREFS_MODE = 0o600;
|
|
110
128
|
|
|
111
129
|
/** The longest name somebody here may give another deck. The same order as a
|
|
@@ -192,6 +210,9 @@ function normaliseLan(raw) {
|
|
|
192
210
|
// overrides it, because a truthy string from a hand-edited file is not an
|
|
193
211
|
// answer.
|
|
194
212
|
autoAccept: typeof src.autoAccept === "boolean" ? src.autoAccept : DEFAULTS.lan.autoAccept,
|
|
213
|
+
// Whether paired decks are told which shared account this one is on. Absent
|
|
214
|
+
// is on — the default above — and only a real boolean turns it off.
|
|
215
|
+
shareActive: typeof src.shareActive === "boolean" ? src.shareActive : DEFAULTS.lan.shareActive,
|
|
195
216
|
};
|
|
196
217
|
}
|
|
197
218
|
|
|
@@ -210,12 +231,102 @@ export function normalise(raw) {
|
|
|
210
231
|
};
|
|
211
232
|
}
|
|
212
233
|
|
|
213
|
-
/**
|
|
214
|
-
*
|
|
215
|
-
|
|
234
|
+
/** Where the bytes of a prefs.json nothing could parse are put.
|
|
235
|
+
*
|
|
236
|
+
* `Date.now()` rather than an ISO timestamp because a colon is not a legal
|
|
237
|
+
* filename character on Windows, and a quarantine that cannot be created on
|
|
238
|
+
* the platform it is protecting is not a quarantine. */
|
|
239
|
+
export const quarantinePath = (home = deckDataDir(), at = Date.now()) =>
|
|
240
|
+
`${prefsPath(home)}.corrupt-${at}`;
|
|
241
|
+
|
|
242
|
+
/** The refusal `writePrefs` throws rather than merge onto a base it knows is
|
|
243
|
+
* not the user's. Shaped like installer.mjs's SETTINGS_UNREADABLE, which is
|
|
244
|
+
* the same policy on the other file this deck rewrites: a file we cannot
|
|
245
|
+
* reproduce is never treated as an empty one. */
|
|
246
|
+
function unreadablePrefs(path, why) {
|
|
247
|
+
const err = new Error(
|
|
248
|
+
`${path} could not be read (${why}). Refusing to overwrite it — this deck's ` +
|
|
249
|
+
`LAN key and its pairings are in there and cannot be re-derived. Fix the ` +
|
|
250
|
+
`file or move it aside, then restart ${PRODUCT}.`,
|
|
251
|
+
);
|
|
252
|
+
err.code = "PREFS_UNREADABLE";
|
|
253
|
+
err.prefsPath = path;
|
|
254
|
+
err.why = why;
|
|
255
|
+
return err;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Read prefs.json, and say WHICH of four things happened — because three of
|
|
260
|
+
* them hand back the same object and only one of them means it.
|
|
261
|
+
*
|
|
262
|
+
* WHY THE SOURCE IS PART OF THE ANSWER. The old read collapsed "no file yet"
|
|
263
|
+
* and "a file nothing could parse" into one silent `{ ...DEFAULTS }`, which is
|
|
264
|
+
* a defensible answer to a question about VALUES and a catastrophic one as the
|
|
265
|
+
* merge base of a write. A power cut or an OOM kill between the write and the
|
|
266
|
+
* rename leaves a truncated prefs.json; the next boot read it as "nothing
|
|
267
|
+
* chosen yet", found an empty `lan.secret`, generated a new identity and wrote
|
|
268
|
+
* it — over the file that still had the old key, the pairings, the shared
|
|
269
|
+
* accounts and the dialled addresses legibly in it. Every peer that had pinned
|
|
270
|
+
* this deck had to accept it again, and nothing anywhere said why.
|
|
271
|
+
*
|
|
272
|
+
* "file" parsed. These are the user's own settings.
|
|
273
|
+
* "missing" ENOENT, and only ENOENT. Nothing has been chosen yet;
|
|
274
|
+
* defaults, silently, which is what a first start is.
|
|
275
|
+
* "corrupt" bytes that are not JSON. Moved aside to `quarantined` BEFORE
|
|
276
|
+
* this returns, so nothing can merge over them.
|
|
277
|
+
* "unreadable" the read itself failed for some reason other than absence — a
|
|
278
|
+
* permission, a directory in the way. The file is still there
|
|
279
|
+
* and still unread, which is exactly when a write must not land.
|
|
280
|
+
*
|
|
281
|
+
* A BYTE-ORDER MARK IS NOT DAMAGE. Notepad and `Set-Content` write one, and
|
|
282
|
+
* `JSON.parse` throws on it — so stripping it here is what keeps a perfectly
|
|
283
|
+
* good hand-edited file out of quarantine. Same call installer.mjs makes on
|
|
284
|
+
* settings.json for the same reason.
|
|
285
|
+
*/
|
|
286
|
+
export async function loadPrefs(home = deckDataDir(), deps = {}) {
|
|
216
287
|
const read = deps.readFile ?? readFile;
|
|
217
|
-
|
|
218
|
-
|
|
288
|
+
const warn = deps.warn ?? console.error;
|
|
289
|
+
const path = prefsPath(home);
|
|
290
|
+
const defaults = () => ({ ...DEFAULTS });
|
|
291
|
+
|
|
292
|
+
let raw;
|
|
293
|
+
try {
|
|
294
|
+
raw = await read(path, "utf8");
|
|
295
|
+
} catch (err) {
|
|
296
|
+
if (err?.code === "ENOENT") return { prefs: defaults(), source: "missing", quarantined: "" };
|
|
297
|
+
warn(`${PRODUCT}: could not read ${path}: ${err?.message ?? err}. Leaving it alone — settings will not be saved until it can be read.`);
|
|
298
|
+
return { prefs: defaults(), source: "unreadable", quarantined: "" };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
return { prefs: normalise(JSON.parse(stripBom(raw))), source: "file", quarantined: "" };
|
|
303
|
+
} catch (err) {
|
|
304
|
+
const why = err?.message ?? String(err);
|
|
305
|
+
const mv = deps.rename ?? renameWithRetry;
|
|
306
|
+
const to = quarantinePath(home);
|
|
307
|
+
try {
|
|
308
|
+
await mv(path, to);
|
|
309
|
+
} catch (moveErr) {
|
|
310
|
+
// ENOENT means somebody else moved it between the read and the rename —
|
|
311
|
+
// a second deck booting on the same machine. The bytes are safe, just not
|
|
312
|
+
// under a name this read chose, and what is at `path` now is nothing.
|
|
313
|
+
if (moveErr?.code === "ENOENT") return { prefs: defaults(), source: "missing", quarantined: "" };
|
|
314
|
+
// Anything else and the damaged file is STILL THERE, unread and
|
|
315
|
+
// unprotected. Saying so is the whole point: `writePrefs` refuses on it.
|
|
316
|
+
warn(`${PRODUCT}: ${path} could not be read as JSON (${why}), and could not be moved aside either: ${moveErr?.message ?? moveErr}. Settings will not be saved until it is fixed or moved.`);
|
|
317
|
+
return { prefs: defaults(), source: "corrupt", quarantined: "" };
|
|
318
|
+
}
|
|
319
|
+
warn(`${PRODUCT}: ${path} could not be read as JSON (${why}). It has been kept as ${to} and this deck is starting with fresh settings — its LAN key and its pairings are in that file, so do not delete it if you want them back.`);
|
|
320
|
+
return { prefs: defaults(), source: "corrupt", quarantined: to };
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** What is on disk, or the defaults — the VALUES alone, for the callers that
|
|
325
|
+
* want nothing else. `loadPrefs` is the same read with the answer to "and was
|
|
326
|
+
* that really the user's file?" still attached; anything about to write must
|
|
327
|
+
* ask that question, and does. */
|
|
328
|
+
export async function readPrefs(home = deckDataDir(), deps = {}) {
|
|
329
|
+
return (await loadPrefs(home, deps)).prefs;
|
|
219
330
|
}
|
|
220
331
|
|
|
221
332
|
let _chain = Promise.resolve();
|
|
@@ -232,35 +343,140 @@ let _chain = Promise.resolve();
|
|
|
232
343
|
*
|
|
233
344
|
* Serialized for the same reason the other store is: two pages toggling two
|
|
234
345
|
* different switches in the same second must not lose one of them.
|
|
346
|
+
*
|
|
347
|
+
* AND IT REFUSES RATHER THAN MERGE ONTO A BASE THAT IS NOT THE USER'S. The
|
|
348
|
+
* merge above exists to PRESERVE what the caller did not send; handed the
|
|
349
|
+
* defaults because `loadPrefs` could not read the file, it preserves nothing
|
|
350
|
+
* and the write becomes the thing that destroys the key. So the two failures
|
|
351
|
+
* that read as defaults are separated: a file that was moved aside is safe to
|
|
352
|
+
* start clean over, and one still sitting there unread is not. See
|
|
353
|
+
* installer.mjs's readSettingsForWrite, which is this policy on settings.json.
|
|
235
354
|
*/
|
|
236
355
|
export async function writePrefs(patch, home = deckDataDir(), deps = {}) {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
356
|
+
return queued(() => save(() => patch, home, deps));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Change some of the preferences from what the write is about to READ.
|
|
361
|
+
*
|
|
362
|
+
* The twin of `updateStore` in browser-watch-store.mjs, made for the same
|
|
363
|
+
* reason and shaped the same way: `mutate(prev)` runs INSIDE the queued job,
|
|
364
|
+
* after that job's own `loadPrefs` and after the two guards that decide whether
|
|
365
|
+
* this file may be written at all, so the patch is a function of the file
|
|
366
|
+
* rather than of a copy somebody took earlier — and a caller that computes one
|
|
367
|
+
* never sees the defaults `loadPrefs` returns for a file it refused to write
|
|
368
|
+
* over.
|
|
369
|
+
*
|
|
370
|
+
* WHICH CALLERS NEED IT, AND WHY A PATCH IS NOT ENOUGH ON ITS OWN. `writePrefs`
|
|
371
|
+
* already merges field by field, so two writes naming two different fields
|
|
372
|
+
* cannot lose each other. What it cannot do is merge two writes of the SAME
|
|
373
|
+
* field, and three callers compute one whole field — `lan.manual` twice and
|
|
374
|
+
* `lan.aliases` once — out of `index.mjs`'s module-level `_prefs`, which is
|
|
375
|
+
* refreshed only when a previous write resolves. Two of them in one turn both
|
|
376
|
+
* read before either job runs, and the second patch is a whole array or a whole
|
|
377
|
+
* map without the first's entry in it: both answer 200, both panels redraw, one
|
|
378
|
+
* change is never written.
|
|
379
|
+
*
|
|
380
|
+
* Measured against this file in a temp directory, with those exact call shapes,
|
|
381
|
+
* starting from `manual: ["10.0.0.1:5000"]`:
|
|
382
|
+
*
|
|
383
|
+
* memory manual = ["10.0.0.1:5000","10.0.0.3:5002"] # the onDial entry is gone
|
|
384
|
+
* disk manual = ["10.0.0.1:5000","10.0.0.3:5002"]
|
|
385
|
+
*
|
|
386
|
+
* The alias route carried a comment claiming the opposite — "the whole map is
|
|
387
|
+
* rebuilt from the one on disk rather than sent by the page, so two tabs
|
|
388
|
+
* renaming two decks cannot undo each other" — which is the sentence this makes
|
|
389
|
+
* true.
|
|
390
|
+
*
|
|
391
|
+
* `mutate` returns a PATCH, not a whole state, so the merge doctrine above is
|
|
392
|
+
* unchanged: a field nobody mentions keeps its value. It may return nothing,
|
|
393
|
+
* which means "no change" and still rewrites the file with what it read.
|
|
394
|
+
*/
|
|
395
|
+
export async function updatePrefs(mutate, home = deckDataDir(), deps = {}) {
|
|
396
|
+
return queued(() => save(mutate, home, deps));
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** One read-modify-write, behind every other one. Both entry points go through
|
|
400
|
+
* here so there is a single queue and a single merge. */
|
|
401
|
+
function queued(job) {
|
|
259
402
|
const started = _chain.then(job, job);
|
|
260
403
|
_chain = started.then(() => {}, () => {});
|
|
261
404
|
return started;
|
|
262
405
|
}
|
|
263
406
|
|
|
407
|
+
async function save(mutate, home, deps) {
|
|
408
|
+
const mk = deps.mkdir ?? mkdir;
|
|
409
|
+
const temp = deps.createTemp ?? createTemp;
|
|
410
|
+
const setMode = deps.chmod ?? chmod;
|
|
411
|
+
const drop = deps.unlink ?? unlink;
|
|
412
|
+
// `renameWithRetry`, not `rename` (#786). MoveFileExW refuses while any
|
|
413
|
+
// handle without FILE_SHARE_DELETE is open on either side, and Defender and
|
|
414
|
+
// the search indexer open a file the instant it is written — so on Windows
|
|
415
|
+
// a bare rename fails on a perfectly healthy machine, `POST /api/prefs`
|
|
416
|
+
// 500s through `guard`, and the notifications switch silently does not
|
|
417
|
+
// stick. POSIX rename(2) has no such rule, which is why this shipped green.
|
|
418
|
+
const mv = deps.rename ?? renameWithRetry;
|
|
419
|
+
const target = prefsPath(home);
|
|
420
|
+
const { prefs: prev, source, quarantined } = await loadPrefs(home, deps);
|
|
421
|
+
if (source === "unreadable") throw unreadablePrefs(target, "the read failed");
|
|
422
|
+
if (source === "corrupt" && !quarantined) {
|
|
423
|
+
throw unreadablePrefs(target, "it is not JSON and could not be moved aside");
|
|
424
|
+
}
|
|
425
|
+
// INSIDE THE JOB, after the read above and after the two guards that
|
|
426
|
+
// decide whether this file may be written at all — see `updatePrefs`.
|
|
427
|
+
// `writePrefs` hands over a constant here, which is what makes the two
|
|
428
|
+
// one function.
|
|
429
|
+
const patch = await mutate(prev);
|
|
430
|
+
// The LAN section merges rather than replaces, so a page toggling the
|
|
431
|
+
// switch does not have to send the passphrase back to keep it — and so
|
|
432
|
+
// nothing has to send a secret it was never given.
|
|
433
|
+
const merged = { ...prev, ...patch, lan: { ...prev.lan, ...(patch?.lan ?? {}) } };
|
|
434
|
+
const next = normalise(merged);
|
|
435
|
+
await mk(prefsDir(home), { recursive: true, mode: 0o700 });
|
|
436
|
+
// `createTemp`, not a name built out of the pid alone.
|
|
437
|
+
//
|
|
438
|
+
// The splice browser-watch-store.mjs met is NOT the hazard here: `_chain`
|
|
439
|
+
// above serializes every write in this process, and two decks on one home
|
|
440
|
+
// have two pids. The hazard is the LEFTOVER. A deck killed between the
|
|
441
|
+
// create and the rename strands a temp file with this deck's private key
|
|
442
|
+
// in it under a name derived from its pid, the old code never unlinked one
|
|
443
|
+
// on failure either — and `writeFile`'s `mode` applies only when the call
|
|
444
|
+
// CREATES the file, so a later deck the OS hands that pid back adopted the
|
|
445
|
+
// stranded file whole, keeping whatever permissions it had, and wrote the
|
|
446
|
+
// key into it. O_EXCL is what turns a taken name into an error the caller
|
|
447
|
+
// handles instead, and it is also what makes PREFS_MODE binding from the
|
|
448
|
+
// first byte rather than a hope about what was there before. codex-auth.mjs
|
|
449
|
+
// argues all of this at length over the one other secret this deck stages
|
|
450
|
+
// through a temp file.
|
|
451
|
+
const { tmp, handle } = await temp(target, { mode: PREFS_MODE });
|
|
452
|
+
let landed = false;
|
|
453
|
+
try {
|
|
454
|
+
try {
|
|
455
|
+
await handle.writeFile(JSON.stringify(next, null, 2) + "\n", "utf8");
|
|
456
|
+
// The fsync is the half of "atomic" a rename alone does not buy. A
|
|
457
|
+
// rename orders the directory entry; it does not order the BYTES, so a
|
|
458
|
+
// machine that loses power just after it can come up with the entry
|
|
459
|
+
// pointing at a file that was never flushed — which is the truncated
|
|
460
|
+
// prefs.json this whole read path now exists to survive.
|
|
461
|
+
await handle.sync();
|
|
462
|
+
} finally {
|
|
463
|
+
await handle.close();
|
|
464
|
+
}
|
|
465
|
+
// The umask only ever clears bits off the creation mode, so the key is
|
|
466
|
+
// never wider than 0600 — but it can land narrower, and a prefs.json at
|
|
467
|
+
// 0400 is one this writer cannot replace next time. On Windows chmod's
|
|
468
|
+
// only effect is the read-only bit, and a read-only target is one no
|
|
469
|
+
// rename can replace. Pin it either way.
|
|
470
|
+
await setMode(tmp, PREFS_MODE);
|
|
471
|
+
await mv(tmp, target);
|
|
472
|
+
landed = true;
|
|
473
|
+
} finally {
|
|
474
|
+
// A temp left behind holds this deck's private key in cleartext.
|
|
475
|
+
if (!landed) await drop(tmp).catch(() => {});
|
|
476
|
+
}
|
|
477
|
+
return next;
|
|
478
|
+
}
|
|
479
|
+
|
|
264
480
|
/**
|
|
265
481
|
* May the deck raise a desktop notification right now?
|
|
266
482
|
*
|
|
@@ -30,7 +30,7 @@ import { request as httpRequest } from "node:http";
|
|
|
30
30
|
// The same deadline hook.js gives a challenge, and for the same reason: a
|
|
31
31
|
// bodyless GET to a loopback port is sub-millisecond when a deck is there and an
|
|
32
32
|
// instant ECONNREFUSED when nothing is.
|
|
33
|
-
|
|
33
|
+
const DECK_CHALLENGE_TIMEOUT_MS = 400;
|
|
34
34
|
|
|
35
35
|
// Signal 0 delivers nothing; it asks whether the pid could be signalled.
|
|
36
36
|
//
|
package/src/server/exec.mjs
CHANGED
|
@@ -402,6 +402,81 @@ export function killTree(child, signal) {
|
|
|
402
402
|
}
|
|
403
403
|
}
|
|
404
404
|
|
|
405
|
+
/**
|
|
406
|
+
* Every child this module started and has not yet seen the end of.
|
|
407
|
+
*
|
|
408
|
+
* A deadline is not a promise unless something is alive to enforce it, and the
|
|
409
|
+
* deadlines in this file all live in the parent: `run` states the outcome on a
|
|
410
|
+
* timer and only then kills, `runInteractive` does the same. Kill the parent
|
|
411
|
+
* and the timer dies with it, so the very case a deadline exists for — a tool
|
|
412
|
+
* that has hung — is the one case where nothing is left to stop it.
|
|
413
|
+
*
|
|
414
|
+
* Observed (#1012). A sandboxed deck, a quota poll in flight, SIGINT to the
|
|
415
|
+
* deck at 05:26:22.706. The deck was gone within 200ms; its `claude --print
|
|
416
|
+
* /usage` child, spawned at 05:26:18.078 under a 15-second deadline, was still
|
|
417
|
+
* running at 05:26:35.204 — reparented to init, past the deadline the dead
|
|
418
|
+
* parent would have enforced, and with nothing anywhere that would ever kill
|
|
419
|
+
* it. A whole Claude Code process, hundreds of MB resident, orphaned by a
|
|
420
|
+
* Ctrl+C.
|
|
421
|
+
*
|
|
422
|
+
* REMOVED ON 'close', NOT ON 'exit', and the difference is Windows'. A batch
|
|
423
|
+
* candidate runs THROUGH cmd.exe and the tool is a grandchild (see viaCmd and
|
|
424
|
+
* killTree): the wrapper can exit while the tool underneath goes on holding
|
|
425
|
+
* the inherited stdio, which is exactly the shape killTree's `taskkill /T`
|
|
426
|
+
* exists for. 'exit' there means "the wrapper is gone" and 'close' means "and
|
|
427
|
+
* so is everything it started" — forgetting on the first would drop a tree
|
|
428
|
+
* that is still running and still reachable. 'error' is the third way a child
|
|
429
|
+
* ends, and the only one after which 'close' may never come at all.
|
|
430
|
+
*
|
|
431
|
+
* `runDetached` is deliberately absent. It is the one function here named for
|
|
432
|
+
* outliving its call — a sound, or a collection whose result lands in a file
|
|
433
|
+
* the next poll reads — it holds no deadline, and it unrefs its child on
|
|
434
|
+
* purpose. Killing those on the way out would be this set overreaching.
|
|
435
|
+
*/
|
|
436
|
+
const live = new Set();
|
|
437
|
+
|
|
438
|
+
/** Track one child until it ends. Returns it, so it can wrap a spawn inline. */
|
|
439
|
+
function watchChild(cp) {
|
|
440
|
+
if (!cp) return cp;
|
|
441
|
+
live.add(cp);
|
|
442
|
+
const forget = () => { live.delete(cp); };
|
|
443
|
+
cp.once?.("close", forget);
|
|
444
|
+
cp.once?.("error", forget);
|
|
445
|
+
return cp;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* The pids of the children this deck still has running.
|
|
450
|
+
*
|
|
451
|
+
* Exported for the test below this, and because it is the honest answer to
|
|
452
|
+
* "what is this deck still doing" that a future `--status` wants. It may
|
|
453
|
+
* over-report by one in the Windows case above — a wrapper whose 'close' never
|
|
454
|
+
* arrives stays here — which is the right direction to be wrong in: that entry
|
|
455
|
+
* names a process that really is still running.
|
|
456
|
+
*/
|
|
457
|
+
export const liveChildPids = () => [...live].map(c => c?.pid).filter(Boolean);
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Stop every child this module started, and everything they started.
|
|
461
|
+
*
|
|
462
|
+
* Called from shutdown(), where the alternative is the orphan above. It does
|
|
463
|
+
* not WAIT for the corpses, on purpose: a signal is delivered synchronously on
|
|
464
|
+
* POSIX, and on Windows killTree's taskkill is a process of its own that
|
|
465
|
+
* CreateProcess has already started by the time spawn() returns and that
|
|
466
|
+
* outlives this one. Waiting would buy nothing and would spend the ~200ms exit
|
|
467
|
+
* that is the rest of shutdown's good behaviour.
|
|
468
|
+
*
|
|
469
|
+
* The set is cleared first so a 'close' arriving mid-loop cannot mutate what
|
|
470
|
+
* is being iterated, and so a second call — shutdown can be reached twice —
|
|
471
|
+
* has nothing left to do.
|
|
472
|
+
*/
|
|
473
|
+
export function killLiveChildren(signal) {
|
|
474
|
+
const doomed = [...live];
|
|
475
|
+
live.clear();
|
|
476
|
+
for (const cp of doomed) killTree(cp, signal);
|
|
477
|
+
return doomed.length;
|
|
478
|
+
}
|
|
479
|
+
|
|
405
480
|
// Reasons to try the next candidate spelling rather than give up. EINVAL and
|
|
406
481
|
// UNKNOWN show up on Windows for a file that exists but cannot be executed the
|
|
407
482
|
// way it was asked for; both mean "not this one", not "no such tool".
|
|
@@ -695,8 +770,11 @@ export function run(cmd, args, { timeout = 20_000, maxBuffer = 4 << 20, env } =
|
|
|
695
770
|
};
|
|
696
771
|
|
|
697
772
|
try {
|
|
698
|
-
|
|
699
|
-
|
|
773
|
+
// watchChild: this run's deadline lives in the timer below, in THIS
|
|
774
|
+
// process, so a shutdown that does not reap it leaves the child with
|
|
775
|
+
// no deadline at all. See `live` above, and #1012.
|
|
776
|
+
const cp = watchChild(execFile(file, argv,
|
|
777
|
+
{ timeout: 0, shell: false, windowsHide: true, maxBuffer, ...(env ? { env } : {}), ...opts }, done));
|
|
700
778
|
// Give the child EOF on stdin straight away, which is what this
|
|
701
779
|
// function's contract has always claimed ("run closes stdin", says
|
|
702
780
|
// runInteractive's header) and what execFile does not do: it leaves the
|
|
@@ -853,7 +931,10 @@ export function runInteractive(cmd, args, { timeout = 300_000, maxOutput = 256 <
|
|
|
853
931
|
const { file, args: argv, opts, launch } = candidateSpec(raw, args);
|
|
854
932
|
let proc;
|
|
855
933
|
try {
|
|
856
|
-
|
|
934
|
+
// Tracked for the same reason `run`'s child is, and with more at stake:
|
|
935
|
+
// this one's deadline is five minutes and it is a `claude auth login`
|
|
936
|
+
// blocked on a stdin pipe only this process holds. See `live` above.
|
|
937
|
+
proc = watchChild(spawn(file, argv, { stdio: ["pipe", "pipe", "pipe"], shell: false, windowsHide: true, ...opts }));
|
|
857
938
|
} catch (err) {
|
|
858
939
|
return tryNext(err) ? attempt(i + 1) : finish(-1, err);
|
|
859
940
|
}
|
|
@@ -54,6 +54,16 @@ export function readGlobalRoot(out) {
|
|
|
54
54
|
* is not obvious: a global prefix owned by root needs either sudo or a prefix
|
|
55
55
|
* of the user's own, and "permission denied" alone sends people to sudo when
|
|
56
56
|
* the second is the better answer.
|
|
57
|
+
*
|
|
58
|
+
* EEXIST is the other, and it is the one this deck's own history produced. The
|
|
59
|
+
* retired `agents-deck` and `agent-dag` packages shipped all three commands in
|
|
60
|
+
* their `bin` map, so a machine that installed either of them owns
|
|
61
|
+
* `<prefix>/bin/ccdeck` under that package's name. `npm i -g ccdeck` then has a
|
|
62
|
+
* command file to link and something already sitting on it, and npm refuses
|
|
63
|
+
* rather than taking a file another package still claims. The raw first stderr
|
|
64
|
+
* line says `EEXIST` and names a path, which tells a user nothing about the
|
|
65
|
+
* package that has to come off first — and it is exactly the users of #975,
|
|
66
|
+
* reaching for the one command that would have got them out of it.
|
|
57
67
|
*/
|
|
58
68
|
export function installFailure(out, { pkg = "ccdeck" } = {}) {
|
|
59
69
|
const said = `${out?.stderr ?? ""}\n${out?.stdout ?? ""}`;
|
|
@@ -62,6 +72,9 @@ export function installFailure(out, { pkg = "ccdeck" } = {}) {
|
|
|
62
72
|
if (/EACCES|permission denied/i.test(said)) {
|
|
63
73
|
return `npm cannot write to the global prefix. Either \`sudo npm i -g ${pkg}\`, or point npm at a prefix you own (\`npm config set prefix ~/.npm-global\`) and add its \`bin\` to PATH`;
|
|
64
74
|
}
|
|
75
|
+
if (/EEXIST/i.test(said)) {
|
|
76
|
+
return `npm will not overwrite a command another global package owns. This deck's retired names ship the same commands, so remove them first: \`npm rm -g agents-deck agent-dag\`, then \`npm i -g ${pkg}\``;
|
|
77
|
+
}
|
|
65
78
|
const first = said.split(/\r?\n/).map(s => s.trim()).filter(Boolean)[0];
|
|
66
79
|
return first || `npm exited ${out?.status ?? "non-zero"}`;
|
|
67
80
|
}
|