ccdeck 3.26.3 → 3.26.5
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/dist/web/assets/{BrowserWatchModal-CjOjANxG.js → BrowserWatchModal-B6MuDGvq.js} +1 -1
- package/dist/web/assets/{UsageHistoryModal-DfPKQO6q.js → UsageHistoryModal-BJA3mUzs.js} +1 -1
- package/dist/web/assets/index-D4fPrztm.js +297 -0
- package/dist/web/index.html +1 -1
- package/hook/hook.js +61 -20
- package/package.json +1 -1
- package/release-notes.json +3 -1
- package/src/server/deck-prefs.mjs +42 -0
- package/src/server/index.mjs +40 -22
- package/src/server/installer.mjs +23 -1
- package/src/server/lan-engine.mjs +110 -2
- package/src/server/lan-socket.mjs +9 -0
- package/src/server/retire-sound-hook.mjs +10 -1
- package/dist/web/assets/index-DZzBylIp.js +0 -297
package/dist/web/index.html
CHANGED
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
stored === "light" || (stored !== "dark" && light) ? "light" : "dark");
|
|
45
45
|
})();
|
|
46
46
|
</script>
|
|
47
|
-
<script type="module" crossorigin src="/assets/index-
|
|
47
|
+
<script type="module" crossorigin src="/assets/index-D4fPrztm.js"></script>
|
|
48
48
|
<link rel="stylesheet" crossorigin href="/assets/index-DlaHgzye.css">
|
|
49
49
|
</head>
|
|
50
50
|
<body>
|
package/hook/hook.js
CHANGED
|
@@ -357,13 +357,15 @@ function sameProof(got, want) {
|
|
|
357
357
|
//
|
|
358
358
|
// They are now separated by a barrier: every target is challenged, then the
|
|
359
359
|
// election is decided, then the payload goes out (#695). The worst case is
|
|
360
|
-
// unchanged — the challenges run in parallel, so it is still one
|
|
361
|
-
// followed by one 1000ms deadline
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
//
|
|
360
|
+
// unchanged — the challenges run in parallel, so it is still one challenge's
|
|
361
|
+
// two 400ms attempts followed by one 1000ms deadline, and each 400ms bounds the
|
|
362
|
+
// whole answer rather than a silence in it (see prove). What the barrier does
|
|
363
|
+
// cost is that an honest deck's POST waits for the slowest challenge in the
|
|
364
|
+
// set, which only matters when some OTHER record's port accepts a connection
|
|
365
|
+
// and then does not answer in time. A ghost port with nothing behind it refuses
|
|
366
|
+
// instantly and delays no one; one with something slow or silent behind it
|
|
367
|
+
// costs both attempts once, and its record is then forgotten if no deck has
|
|
368
|
+
// been keeping it — see forgetIfAbandoned.
|
|
367
369
|
const CHALLENGE_TIMEOUT_MS = 400;
|
|
368
370
|
const POST_TIMEOUT_MS = 1000;
|
|
369
371
|
|
|
@@ -423,6 +425,8 @@ const CAP_MS = 1900;
|
|
|
423
425
|
*/
|
|
424
426
|
function prove(d, cb, attempt = 0) {
|
|
425
427
|
let settled = false;
|
|
428
|
+
// Armed once the request is out, and cleared by whichever verdict comes first.
|
|
429
|
+
let deadline = null;
|
|
426
430
|
// A DEADLINE IS NOT AN ANSWER, and the difference is worth one retry.
|
|
427
431
|
//
|
|
428
432
|
// A wrong proof, a refused connection and a 404 are all verdicts: that port
|
|
@@ -444,7 +448,12 @@ function prove(d, cb, attempt = 0) {
|
|
|
444
448
|
settled = true; // this attempt is over; the next owns `cb`
|
|
445
449
|
prove(d, cb, attempt + 1);
|
|
446
450
|
};
|
|
447
|
-
const finish = (ok, why = null) => {
|
|
451
|
+
const finish = (ok, why = null) => {
|
|
452
|
+
if (settled) return;
|
|
453
|
+
settled = true;
|
|
454
|
+
clearTimeout(deadline);
|
|
455
|
+
cb(ok, why);
|
|
456
|
+
};
|
|
448
457
|
|
|
449
458
|
if (!requiresProof(d)) return finish(true);
|
|
450
459
|
|
|
@@ -456,7 +465,6 @@ function prove(d, cb, attempt = 0) {
|
|
|
456
465
|
port: d.port,
|
|
457
466
|
path: `/api/hook-challenge?nonce=${nonce}`,
|
|
458
467
|
method: "GET",
|
|
459
|
-
timeout: CHALLENGE_TIMEOUT_MS,
|
|
460
468
|
}, res => {
|
|
461
469
|
if (res.statusCode !== 200) { res.resume(); return res.on("end", () => finish(false)); }
|
|
462
470
|
let answer = "";
|
|
@@ -476,12 +484,26 @@ function prove(d, cb, attempt = 0) {
|
|
|
476
484
|
finish(sameProof(proof, want));
|
|
477
485
|
});
|
|
478
486
|
});
|
|
479
|
-
|
|
480
|
-
//
|
|
481
|
-
//
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
487
|
+
req.on("error", () => finish(false));
|
|
488
|
+
// THE DEADLINE IS ON THE WHOLE ANSWER, not on the silences in it (#1172).
|
|
489
|
+
//
|
|
490
|
+
// This was http.request's `timeout`, which is an IDLE timeout: it fires only
|
|
491
|
+
// after that long with no bytes either way. A port that answers one byte
|
|
492
|
+
// every 150ms is never idle and never reaches the 4096-byte cap above, so its
|
|
493
|
+
// prove() never settled — and proveTargets waits for every target before
|
|
494
|
+
// anything is posted. Measured with an honest deck beside such a port: the
|
|
495
|
+
// hook ran to CAP_MS at 1938ms and the deck saw its challenge and no event,
|
|
496
|
+
// on every tool call for as long as the record stood, and the record was
|
|
497
|
+
// never forgotten either, because the "deadline" verdict forgetIfAbandoned
|
|
498
|
+
// needs never came.
|
|
499
|
+
//
|
|
500
|
+
// The idle timeout had a second hole, and a port that sends its status line
|
|
501
|
+
// and then goes quiet fell into it: once a response has begun, destroying the
|
|
502
|
+
// request raises no 'error' on it, so the verdict that used to ride on that
|
|
503
|
+
// event never arrived either. So the timer gives the verdict itself, and
|
|
504
|
+
// gives it first: the destroy's own 'error', if it raises one, finds this
|
|
505
|
+
// attempt already over.
|
|
506
|
+
deadline = setTimeout(() => { retryOnTimeout(); req.destroy(); }, CHALLENGE_TIMEOUT_MS);
|
|
485
507
|
req.end();
|
|
486
508
|
}
|
|
487
509
|
|
|
@@ -686,11 +708,30 @@ function post(d, body, persists, done) {
|
|
|
686
708
|
finish(res.statusCode >= 200 && res.statusCode < 300);
|
|
687
709
|
});
|
|
688
710
|
req.on("finish", () => { sent = true; });
|
|
689
|
-
// `destroy()` on a timeout makes 'error' fire with ECONNRESET
|
|
690
|
-
//
|
|
691
|
-
// writer hanging up.
|
|
692
|
-
|
|
693
|
-
|
|
711
|
+
// `destroy()` on a timeout makes 'error' fire with ECONNRESET — the status
|
|
712
|
+
// line has not arrived, or it would have settled this already — so `timedOut`
|
|
713
|
+
// is what tells this process giving up apart from the writer hanging up.
|
|
714
|
+
//
|
|
715
|
+
// AND THE BODY'S STATE IS READ WHEN THE DEADLINE FIRES, NOT WHEN THE ERROR
|
|
716
|
+
// ARRIVES (#1172). `timedOut && sent` was evaluated in the 'error' handler,
|
|
717
|
+
// and by the time that runs the answer is always yes: destroying a request
|
|
718
|
+
// that has been end()ed finalises its writable, which emits 'finish' — so
|
|
719
|
+
// `sent` flips to true on the way out of req.destroy(), one tick before the
|
|
720
|
+
// 'error' it causes. Measured on Node 22 against a listener that takes the
|
|
721
|
+
// headers and then stops reading, with 256MB still queued:
|
|
722
|
+
//
|
|
723
|
+
// TIMEOUT sent=false writableLength=268435608
|
|
724
|
+
// FINISH writableFinished=true pending=0
|
|
725
|
+
// ERROR ECONNRESET sent=true <- verdict "deadline"
|
|
726
|
+
//
|
|
727
|
+
// which made every deadline a "deadline" and left the paragraph above
|
|
728
|
+
// describing a branch that could not be taken: a writer wedged mid-read kept
|
|
729
|
+
// a log it had not received, and the deck behind it — which could have
|
|
730
|
+
// written the line — was never asked. The snapshot is the rule that paragraph
|
|
731
|
+
// states, taken at the only moment it is still true.
|
|
732
|
+
let sentByDeadline = false;
|
|
733
|
+
req.on("error", () => finish(false, timedOut && sentByDeadline ? "deadline" : null));
|
|
734
|
+
req.on("timeout", () => { timedOut = true; sentByDeadline = sent; req.destroy(); });
|
|
694
735
|
req.write(body);
|
|
695
736
|
req.end();
|
|
696
737
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccdeck",
|
|
3
|
-
"version": "3.26.
|
|
3
|
+
"version": "3.26.5",
|
|
4
4
|
"description": "Live dashboard for Claude Code and Codex — which agent is waiting on you, tool calls, token spend and every Claude Code subagent on one calm canvas. Run it with npx ccdeck.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/release-notes.json
CHANGED
|
@@ -415,6 +415,48 @@ export async function updatePrefs(mutate, home = deckDataDir(), deps = {}) {
|
|
|
415
415
|
return queued(() => save(mutate, home, deps));
|
|
416
416
|
}
|
|
417
417
|
|
|
418
|
+
/**
|
|
419
|
+
* The two `mutate`s the three callers above hand updatePrefs, named so that the
|
|
420
|
+
* callers and prefs-update-1041.test.ts run the same function. The suite used to
|
|
421
|
+
* carry its own copy of each closure and test the copy (#1168), which stays
|
|
422
|
+
* green however the one in index.mjs is edited.
|
|
423
|
+
*/
|
|
424
|
+
|
|
425
|
+
/** `lan.manual` with `entry` on the end, or no change when it is there already.
|
|
426
|
+
* What `onDial` and the accept route both write. */
|
|
427
|
+
export function withManualEntry(entry) {
|
|
428
|
+
return prev => {
|
|
429
|
+
const manual = Array.isArray(prev?.lan?.manual) ? prev.lan.manual : [];
|
|
430
|
+
return manual.includes(entry) ? null : { lan: { manual: [...manual, entry] } };
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* `lan.shared` with `key` on the end, or no change when it is already there.
|
|
436
|
+
*
|
|
437
|
+
* The tick an account gets when it ARRIVES over the local network (#1188): the
|
|
438
|
+
* group already has that login, so a deck that was just given one is as useful
|
|
439
|
+
* to the next machine as the deck that gave it. Only on arrival — an untick
|
|
440
|
+
* afterwards is the person's answer and nothing re-ticks it, because an account
|
|
441
|
+
* this deck already holds is never added again.
|
|
442
|
+
*/
|
|
443
|
+
export function withShared(key) {
|
|
444
|
+
return prev => {
|
|
445
|
+
const shared = Array.isArray(prev?.lan?.shared) ? prev.lan.shared : [];
|
|
446
|
+
return !key || shared.includes(key) ? null : { lan: { shared: [...shared, key] } };
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** `lan.aliases` with `fp` called `name`, or without `fp` when the name is
|
|
451
|
+
* empty. The alias route's write; the whole map, rebuilt from the one read. */
|
|
452
|
+
export function withAlias(fp, name) {
|
|
453
|
+
return prev => {
|
|
454
|
+
const next = { ...(prev?.lan?.aliases ?? {}) };
|
|
455
|
+
if (name) next[fp] = name; else delete next[fp];
|
|
456
|
+
return { lan: { aliases: next } };
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
418
460
|
/** One read-modify-write, behind every other one. Both entry points go through
|
|
419
461
|
* here so there is a single queue and a single merge. */
|
|
420
462
|
function queued(job) {
|
package/src/server/index.mjs
CHANGED
|
@@ -34,7 +34,7 @@ import { createOutputWatch } from "./output-watch.mjs";
|
|
|
34
34
|
import { RECAP_MARK, foldRecapLine } from "./session-recap.mjs";
|
|
35
35
|
import { AWAY_BOOT_GRACE_MS, AWAY_RECHECK_MS, AWAY_TICK_MS, awayGate, awayUpdateStep } from "./auto-update.mjs";
|
|
36
36
|
import { createPresence } from "./presence.mjs";
|
|
37
|
-
import { DEFAULTS as PREF_DEFAULTS, cleanAlias, isAliasKey, lanEnabled, notificationsOn, notificationsVetoed, publicPrefs, readPrefs, updatePrefs, writePrefs } from "./deck-prefs.mjs";
|
|
37
|
+
import { DEFAULTS as PREF_DEFAULTS, cleanAlias, isAliasKey, lanEnabled, notificationsOn, notificationsVetoed, publicPrefs, readPrefs, updatePrefs, withAlias, withManualEntry, withShared, writePrefs } from "./deck-prefs.mjs";
|
|
38
38
|
import { createEngine, defaultName } from "./lan-engine.mjs";
|
|
39
39
|
import { createTailnet, IDLE_MS as TAILNET_IDLE_MS } from "./tailscale.mjs";
|
|
40
40
|
import { portHolder } from "./port-holder.mjs";
|
|
@@ -3962,6 +3962,19 @@ const lanEngine = createEngine({
|
|
|
3962
3962
|
// listener had one is missing half of them — see forgetReach.
|
|
3963
3963
|
forgetReach();
|
|
3964
3964
|
},
|
|
3965
|
+
// An account arrived from a paired deck, and this deck now offers it too
|
|
3966
|
+
// (#1188). Written before the engine hears it, so a restart between the two
|
|
3967
|
+
// leaves the tick on disk rather than only in the running engine's copy.
|
|
3968
|
+
onShared: async key => {
|
|
3969
|
+
try {
|
|
3970
|
+
const before = _prefs;
|
|
3971
|
+
_prefs = await updatePrefs(withShared(key));
|
|
3972
|
+
if (_prefs?.lan?.shared === before?.lan?.shared) return; // it was already ticked
|
|
3973
|
+
await lanEngine.apply({ shared: _prefs.lan.shared });
|
|
3974
|
+
} catch (err) {
|
|
3975
|
+
console.error(`${PRODUCT}: lan sync could not share the account it just received:`, err?.message ?? err);
|
|
3976
|
+
}
|
|
3977
|
+
},
|
|
3965
3978
|
// A deck was accepted or unpaired. Written straight through, because the
|
|
3966
3979
|
// trusted list is the whole of who this deck will talk to and a list that
|
|
3967
3980
|
// only existed in memory would drop every pairing on restart.
|
|
@@ -3982,10 +3995,7 @@ const lanEngine = createEngine({
|
|
|
3982
3995
|
// is the WHOLE array, so a stale one wins. Pressing accept on a heard deck
|
|
3983
3996
|
// at the moment an invite round fires this dropped the dialled address out
|
|
3984
3997
|
// of `lan.manual`, which is the failure the comment above describes.
|
|
3985
|
-
_prefs = await updatePrefs(
|
|
3986
|
-
const manual = Array.isArray(prev?.lan?.manual) ? prev.lan.manual : [];
|
|
3987
|
-
return manual.includes(entry) ? null : { lan: { manual: [...manual, entry] } };
|
|
3988
|
-
});
|
|
3998
|
+
_prefs = await updatePrefs(withManualEntry(entry));
|
|
3989
3999
|
// AND RECONCILE THE ENGINE'S DIAL LIST with what was just written. The
|
|
3990
4000
|
// disk is now right, but `setPeers` replaces the list wholesale from
|
|
3991
4001
|
// `_prefs` on every settings write — so a write that raced this one, and
|
|
@@ -4360,12 +4370,7 @@ async function handleLanPeer(req, res) {
|
|
|
4360
4370
|
// Inside the job, like `onDial` — the same whole-array patch computed
|
|
4361
4371
|
// from the same stale copy, and the same address lost when two of them
|
|
4362
4372
|
// land in one turn.
|
|
4363
|
-
try {
|
|
4364
|
-
_prefs = await updatePrefs(prev => {
|
|
4365
|
-
const manual = Array.isArray(prev?.lan?.manual) ? prev.lan.manual : [];
|
|
4366
|
-
return manual.includes(entry) ? null : { lan: { manual: [...manual, entry] } };
|
|
4367
|
-
});
|
|
4368
|
-
}
|
|
4373
|
+
try { _prefs = await updatePrefs(withManualEntry(entry)); }
|
|
4369
4374
|
catch { /* it is dialled this session; the next accept re-adds it */ }
|
|
4370
4375
|
}
|
|
4371
4376
|
return send(res, 200, { ok: true, added, ...lanEngine.status() });
|
|
@@ -4391,11 +4396,7 @@ async function handleLanPeer(req, res) {
|
|
|
4391
4396
|
case "alias": {
|
|
4392
4397
|
if (!isAliasKey(fp)) return send(res, 400, { ok: false, reason: "bad_request" });
|
|
4393
4398
|
const name = cleanAlias(body.name);
|
|
4394
|
-
_prefs = await updatePrefs(
|
|
4395
|
-
const next = { ...(prev?.lan?.aliases ?? {}) };
|
|
4396
|
-
if (name) next[fp] = name; else delete next[fp];
|
|
4397
|
-
return { lan: { aliases: next } };
|
|
4398
|
-
});
|
|
4399
|
+
_prefs = await updatePrefs(withAlias(fp, name));
|
|
4399
4400
|
await lanEngine.apply({ aliases: _prefs.lan.aliases });
|
|
4400
4401
|
return send(res, 200, { ok: true, ...lanEngine.status() });
|
|
4401
4402
|
}
|
|
@@ -6756,10 +6757,25 @@ export function requestUrl(rawUrl) {
|
|
|
6756
6757
|
// So the Host check runs for every method now. What it asks is only the
|
|
6757
6758
|
// rebinding question — did this request arrive addressed to a name that can
|
|
6758
6759
|
// only ever be this machine — and it asks it of browser-shaped requests alone,
|
|
6759
|
-
// meaning anything carrying an Origin or
|
|
6760
|
-
//
|
|
6761
|
-
// hook/hook.js, a plain Node http.request from the user's own machine,
|
|
6762
|
-
// keeps reaching the deck under whatever name it used before.
|
|
6760
|
+
// meaning anything carrying an Origin, fetch metadata or a Referer. A client
|
|
6761
|
+
// sending none of them is not a page and has no ambient authority to borrow:
|
|
6762
|
+
// that is hook/hook.js, a plain Node http.request from the user's own machine,
|
|
6763
|
+
// and it keeps reaching the deck under whatever name it used before.
|
|
6764
|
+
//
|
|
6765
|
+
// THE REFERER IS ON THAT LIST because on one browser it is the only mark a page
|
|
6766
|
+
// leaves (#1168). A same-origin GET carries no Origin, and Safari 16.0-16.3
|
|
6767
|
+
// sends no Sec-Fetch-Site — the premise isAuthorizedDataRead's fallback is
|
|
6768
|
+
// built on — so a rebound page on that browser sent `Host: attacker.example:
|
|
6769
|
+
// 4317` and `Referer: http://attacker.example:4317/` and nothing else, was not
|
|
6770
|
+
// browser-shaped by the test as it stood, and was answered. Measured against a
|
|
6771
|
+
// running deck before this line changed: /api/health (the absolute workspace
|
|
6772
|
+
// path), /api/hook-challenge (the proof oracle, which handleHookChallenge says
|
|
6773
|
+
// a rebound page cannot see), /api/system/processes and the page itself all
|
|
6774
|
+
// answered 200. The guarded reads held only because isAuthorizedDataRead
|
|
6775
|
+
// happens to test the Host before it reads the Referer. No client of this
|
|
6776
|
+
// server that is not a browser sends a Referer — hook.js, the desktop app and
|
|
6777
|
+
// bin/ send none — so nothing that reached the deck before is turned away by
|
|
6778
|
+
// this, and the deck's own page names a loopback Host whatever it sends.
|
|
6763
6779
|
//
|
|
6764
6780
|
// Deliberately not part of this: the Sec-Fetch-Site test that isTrustedMutation
|
|
6765
6781
|
// applies. `cross-site` on a read is an ordinary top-level navigation — a link
|
|
@@ -6767,9 +6783,10 @@ export function requestUrl(rawUrl) {
|
|
|
6767
6783
|
// the deck's own UI on the deck's own origin, which is not an attack and used
|
|
6768
6784
|
// to work. Rebinding does not need that test either: a rebound page's requests
|
|
6769
6785
|
// report `same-origin`, and it is the Host that gives it away.
|
|
6770
|
-
export function isTrustedRead({ origin, host, secFetchSite } = {}) {
|
|
6786
|
+
export function isTrustedRead({ origin, host, secFetchSite, referer } = {}) {
|
|
6771
6787
|
const browserShaped = (typeof origin === "string" && origin !== "")
|
|
6772
|
-
|| (typeof secFetchSite === "string" && secFetchSite.trim() !== "")
|
|
6788
|
+
|| (typeof secFetchSite === "string" && secFetchSite.trim() !== "")
|
|
6789
|
+
|| (typeof referer === "string" && referer.trim() !== "");
|
|
6773
6790
|
if (!browserShaped) return true;
|
|
6774
6791
|
return isLoopbackHost(host);
|
|
6775
6792
|
}
|
|
@@ -7377,6 +7394,7 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
7377
7394
|
origin: req.headers.origin,
|
|
7378
7395
|
host: req.headers.host,
|
|
7379
7396
|
secFetchSite: req.headers["sec-fetch-site"],
|
|
7397
|
+
referer: req.headers.referer,
|
|
7380
7398
|
})) {
|
|
7381
7399
|
return send(res, 403, { error: "cross-site request blocked" });
|
|
7382
7400
|
}
|
package/src/server/installer.mjs
CHANGED
|
@@ -403,15 +403,37 @@ async function writeFileAtomic(rawTarget, text) {
|
|
|
403
403
|
* identical content skips the write and the file is not replaced at all.
|
|
404
404
|
*/
|
|
405
405
|
async function installScript(src, dst) {
|
|
406
|
-
|
|
406
|
+
return installText(await readFile(src, "utf8"), dst);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async function installText(text, dst) {
|
|
407
410
|
const current = await readFile(dst, "utf8").catch(() => null);
|
|
408
411
|
if (current === text) return false;
|
|
409
412
|
await writeFileAtomic(dst, text);
|
|
410
413
|
return true;
|
|
411
414
|
}
|
|
412
415
|
|
|
416
|
+
/**
|
|
417
|
+
* What makes the installed hook.js a CommonJS file wherever the config dir is.
|
|
418
|
+
*
|
|
419
|
+
* Node decides whether a `.js` file is CommonJS or an ES module from the nearest
|
|
420
|
+
* package.json above it, and hook.js is CommonJS. With none in agent-dag/, that
|
|
421
|
+
* nearest one is whatever the user has further up: a `"type": "module"` in
|
|
422
|
+
* ~/package.json, or in the repo a relocated CLAUDE_CONFIG_DIR lives in, loads
|
|
423
|
+
* the hook as an ES module, and it dies on its first `require` before main()
|
|
424
|
+
* has installed a single handler. Exit 1 and `require is not defined in ES
|
|
425
|
+
* module scope` on every tool call, and no deck receives anything (#1172). One
|
|
426
|
+
* file here answers the question before Node looks any further.
|
|
427
|
+
*
|
|
428
|
+
* Written before the hook, so a first install never has a hook.js without it,
|
|
429
|
+
* and through the same atomic write, because a half-written package.json is a
|
|
430
|
+
* load error of its own for every hook that starts while it is being written.
|
|
431
|
+
*/
|
|
432
|
+
const HOOK_PACKAGE_JSON = '{ "type": "commonjs" }\n';
|
|
433
|
+
|
|
413
434
|
async function installHookScript(installDir) {
|
|
414
435
|
await ensureDir(installDir);
|
|
436
|
+
await installText(HOOK_PACKAGE_JSON, join(installDir, "package.json"));
|
|
415
437
|
const src = join(PKG_ROOT, "hook", "hook.js");
|
|
416
438
|
const dst = join(installDir, "hook.js");
|
|
417
439
|
await installScript(src, dst);
|
|
@@ -194,9 +194,26 @@ export function heardCurrent(raw, list) {
|
|
|
194
194
|
* Injected rather than imported so a test can run a whole round — two engines,
|
|
195
195
|
* two fake stores, one real socket pair — without claude-swap on the machine.
|
|
196
196
|
*/
|
|
197
|
+
/**
|
|
198
|
+
* Whether an account this round just placed is ticked for sharing here (#1188).
|
|
199
|
+
*
|
|
200
|
+
* ONLY AN ADD: a heal needed the tick to happen at all — roundWith asks for a
|
|
201
|
+
* heal only for an account this deck already shares — so there is nothing to
|
|
202
|
+
* add for one, and an account healed after somebody unticked it must not be
|
|
203
|
+
* ticked again behind them.
|
|
204
|
+
*
|
|
205
|
+
* ONLY FROM THE LOCAL NETWORK: the reasoning for the default is that the login
|
|
206
|
+
* came from the group and the group therefore has it, and a tailnet can reach
|
|
207
|
+
* further than one person's own machines. Sharing there stays a decision
|
|
208
|
+
* somebody makes rather than one an arrival makes for them.
|
|
209
|
+
*/
|
|
210
|
+
export function ticksOnArrival(step, via) {
|
|
211
|
+
return !!step?.key && step.action === "add" && via !== "tailscale";
|
|
212
|
+
}
|
|
213
|
+
|
|
197
214
|
export function createEngine({
|
|
198
215
|
readAccounts, exportAccount, importAccount,
|
|
199
|
-
onChange, onError, onIdentity, onPort, onTrust, onDial, now = Date.now,
|
|
216
|
+
onChange, onError, onIdentity, onPort, onTrust, onDial, onShared, now = Date.now,
|
|
200
217
|
/**
|
|
201
218
|
* The UDP socket the beacon shouts through, injectable for the same reason
|
|
202
219
|
* lan-socket exposes it — and for one more that only showed up in use.
|
|
@@ -351,6 +368,13 @@ export function createEngine({
|
|
|
351
368
|
* still be said to come over the tailnet or the local network — it has no
|
|
352
369
|
* address of its own here, and without this its row could not say which. */
|
|
353
370
|
const spokeFrom = new Map();
|
|
371
|
+
/** Addresses this deck added because a paired deck called in from them and
|
|
372
|
+
* nothing here dialled it — see learnCaller. Kept until a round proves the
|
|
373
|
+
* address answers: one that does is an ordinary dialled peer from then on
|
|
374
|
+
* and leaves this set; one that does not is a caller this deck cannot reach
|
|
375
|
+
* back (a strict NAT, a one-way path), and its row is taken away again so it
|
|
376
|
+
* reverts to "calls in" rather than failing every round. */
|
|
377
|
+
const calledBack = new Set();
|
|
354
378
|
/**
|
|
355
379
|
* When a connection from ANOTHER MACHINE last arrived on the sync listener.
|
|
356
380
|
*
|
|
@@ -457,13 +481,59 @@ export function createEngine({
|
|
|
457
481
|
* connection's — see sessionKey. `ctx.send` seals whatever it is handed when
|
|
458
482
|
* both ends said they seal, so nothing below has to know which kind of deck
|
|
459
483
|
* asked — see frameChannel. */
|
|
484
|
+
/** Does this deck already hold an address it dials for `fp`? A beacon row it
|
|
485
|
+
* still hears, or a typed/learned row that answered as that deck. When
|
|
486
|
+
* neither is true, the only way it ever reaches that deck is if the deck
|
|
487
|
+
* keeps calling — and a called deck is never pulled from. */
|
|
488
|
+
const dialsAlready = fp => {
|
|
489
|
+
if (beacon && [...beacon.peers.values()].some(p => p.fp === fp && stillListed(p, now()))) return true;
|
|
490
|
+
for (const [at, met] of learned) if (met?.fp === fp && manual.has(at)) return true;
|
|
491
|
+
return false;
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* A PAIRED DECK THAT CALLS IN, AND NOTHING HERE DIALS IT.
|
|
496
|
+
*
|
|
497
|
+
* Accounts move only toward the deck that dials — roundWith pulls, serve only
|
|
498
|
+
* answers — so a deck this one holds no address for can offer everything and
|
|
499
|
+
* this one takes nothing. It is the exact state a deck falls into when it
|
|
500
|
+
* cannot hear beacons (a firewall, or Tailscale holding the discovery port):
|
|
501
|
+
* every peer becomes one that only calls, and no account ever arrives.
|
|
502
|
+
*
|
|
503
|
+
* The call itself is the address. The peer connected FROM somewhere and said
|
|
504
|
+
* in its hello which port it LISTENS on, and that pair is dialable. Adding it
|
|
505
|
+
* makes the next round reach the caller and pull — the same dial-back that
|
|
506
|
+
* accepting a deck and joining by invite already do, extended to a peer that
|
|
507
|
+
* simply calls. In memory, like those two: a settings write clears it and the
|
|
508
|
+
* next call re-adds it, and nothing here writes an address to disk.
|
|
509
|
+
*/
|
|
510
|
+
const learnCaller = ctx => {
|
|
511
|
+
const fp = ctx?.peerFp;
|
|
512
|
+
const at = ctx?.peerAddr;
|
|
513
|
+
const port = ctx?.peerPort;
|
|
514
|
+
if (!fp || !at || !port || !engine) return;
|
|
515
|
+
if (!trustedPeer(cfg.trusted, fp)) return;
|
|
516
|
+
if (dialsAlready(fp)) return;
|
|
517
|
+
// As a row the deck ADDED ITSELF, not one a person typed: capped like every
|
|
518
|
+
// other automatic row, and — through calledBack — taken away again if the
|
|
519
|
+
// address turns out not to answer. The caller is already trusted, so the
|
|
520
|
+
// round dials and pulls without a press; `typed` decides only the cap and
|
|
521
|
+
// the undo, never the trust. See roundWith.
|
|
522
|
+
if (engine.addPeer(at, port, { typed: false })) {
|
|
523
|
+
learned.set(`${at}:${port}`, { fp, name: trustedPeer(cfg.trusted, fp)?.name || "" });
|
|
524
|
+
calledBack.add(`${at}:${port}`);
|
|
525
|
+
onChange?.();
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
|
|
460
529
|
const serve = async (msg, ctx) => {
|
|
461
530
|
// Before the verbs, and for every one of them: something that proved it
|
|
462
531
|
// holds a key this deck accepted is talking, now.
|
|
463
532
|
if (ctx?.peerFp) {
|
|
464
533
|
spokeAt.set(ctx.peerFp, now());
|
|
465
|
-
const from = String(ctx.sock?.remoteAddress ?? "").replace(/^::ffff:/, "");
|
|
534
|
+
const from = ctx.peerAddr || String(ctx.sock?.remoteAddress ?? "").replace(/^::ffff:/, "");
|
|
466
535
|
if (from) spokeFrom.set(ctx.peerFp, from);
|
|
536
|
+
learnCaller(ctx);
|
|
467
537
|
}
|
|
468
538
|
try {
|
|
469
539
|
if (msg.t === "manifest") {
|
|
@@ -562,6 +632,10 @@ export function createEngine({
|
|
|
562
632
|
const waitingOnSomebody = () =>
|
|
563
633
|
[...lastRound.values()].some(r => r?.error === "waiting for the other deck to accept this one");
|
|
564
634
|
|
|
635
|
+
/** How this deck reached that peer. A beacon row says so; a typed address is
|
|
636
|
+
* read from the routing table, the way the peer list reads it. */
|
|
637
|
+
const viaOf = peer => peer.via ?? (routeTo(peer.addr) ? "tailscale" : "lan");
|
|
638
|
+
|
|
565
639
|
/** Ask one peer what it has, and heal whatever it can heal. */
|
|
566
640
|
const roundWith = async peer => {
|
|
567
641
|
let conn = null;
|
|
@@ -633,6 +707,10 @@ export function createEngine({
|
|
|
633
707
|
// from then on, because "Constantin-PC" is what the person who typed the
|
|
634
708
|
// address was trying to reach.
|
|
635
709
|
learned.set(`${peer.addr}:${peer.port}`, { fp: conn.peerFp, name: conn.peerName || "" });
|
|
710
|
+
// A DIAL-BACK THAT ANSWERED IS AN ORDINARY PEER NOW. It was on trial only
|
|
711
|
+
// until it proved the deck can reach it; from here it is dialled like any
|
|
712
|
+
// other and is no longer a candidate for the undo below. See learnCaller.
|
|
713
|
+
calledBack.delete(`${peer.addr}:${peer.port}`);
|
|
636
714
|
|
|
637
715
|
// TRUST ON FIRST USE, AND ONLY FOR AN ADDRESS SOMEBODY NAMED. Reaching a
|
|
638
716
|
// deck we have no pin for used to mean the person at this keyboard put
|
|
@@ -738,6 +816,22 @@ export function createEngine({
|
|
|
738
816
|
// than as a healthy one.
|
|
739
817
|
const got = await importAccount(blob, step);
|
|
740
818
|
const ok = got === true || got?.ok === true;
|
|
819
|
+
// AN ACCOUNT THAT ARRIVED HERE IS SHARED ONWARD (#1188). People forget
|
|
820
|
+
// to tick it, and a group where one machine can heal the others and the
|
|
821
|
+
// others can heal nobody is the shape that costs them: the second
|
|
822
|
+
// machine to lose the same login has to go back to the first, which may
|
|
823
|
+
// be asleep or on another network. Nothing new is exposed — the login
|
|
824
|
+
// came FROM the group, so the group has it.
|
|
825
|
+
//
|
|
826
|
+
// ONLY AN ADD, and only from the local network. A heal already needed
|
|
827
|
+
// the tick to happen at all (the filter above), so there is nothing to
|
|
828
|
+
// add for one; and a tailnet reaches further than the person's own
|
|
829
|
+
// machines, which is a decision they make for themselves rather than
|
|
830
|
+
// one an arrival makes for them.
|
|
831
|
+
if (ok && ticksOnArrival(step, viaOf(peer))) {
|
|
832
|
+
try { await onShared?.(step.key); }
|
|
833
|
+
catch { /* the account is here; the tick is retried the next time one arrives */ }
|
|
834
|
+
}
|
|
741
835
|
done.push({ ...step, ok, why: ok ? null : (got?.why ?? "import failed") });
|
|
742
836
|
}
|
|
743
837
|
lastRound.set(peer.fp, { at: now(), name: peer.name, offered: theirs.accounts.length, done });
|
|
@@ -745,6 +839,20 @@ export function createEngine({
|
|
|
745
839
|
return done;
|
|
746
840
|
} catch (err) {
|
|
747
841
|
lastRound.set(peer.fp, { at: now(), name: peer.name, error: err.message });
|
|
842
|
+
// A DIAL-BACK THAT NEVER ANSWERED IS TAKEN AWAY AGAIN. The address came
|
|
843
|
+
// from a paired deck's inbound call, and this round was the test of
|
|
844
|
+
// whether the call can be returned. It could not — a strict NAT, a
|
|
845
|
+
// one-way path — so the row is removed rather than left to fail every
|
|
846
|
+
// minute, and the peer goes back to "calls in". Its next call tries once
|
|
847
|
+
// more. A row that answered has already left calledBack above.
|
|
848
|
+
const at = `${peer.addr}:${peer.port}`;
|
|
849
|
+
if (calledBack.has(at)) {
|
|
850
|
+
calledBack.delete(at);
|
|
851
|
+
manual.delete(at);
|
|
852
|
+
learned.delete(at);
|
|
853
|
+
lastRound.delete(peer.fp);
|
|
854
|
+
onChange?.();
|
|
855
|
+
}
|
|
748
856
|
return [];
|
|
749
857
|
} finally {
|
|
750
858
|
conn?.sock?.destroy();
|
|
@@ -967,6 +967,15 @@ export function createSyncServer({
|
|
|
967
967
|
if (!frame) return refuse("a sealed frame did not open");
|
|
968
968
|
handlers?.(frame, {
|
|
969
969
|
sock, peerFp, key, sealed: !!chan,
|
|
970
|
+
// Where this deck dials the caller BACK. A deck that only ever calls in
|
|
971
|
+
// is one this deck holds no address for, so it could receive nothing —
|
|
972
|
+
// accounts move only toward the deck that dials (see roundWith). The
|
|
973
|
+
// address the caller connected from, and the port it said it listens
|
|
974
|
+
// on, are a dialable pair the engine can add so the next round reaches
|
|
975
|
+
// it. `peerAddr` is the source of this very connection; `peerPort` came
|
|
976
|
+
// from the hello, not the ephemeral source port.
|
|
977
|
+
peerAddr: from(sock),
|
|
978
|
+
peerPort,
|
|
970
979
|
send: obj => sendFrame(sock, chan ? chan.wrap(obj) : obj),
|
|
971
980
|
});
|
|
972
981
|
}, refuse));
|
|
@@ -91,8 +91,17 @@ const EVENT = "Stop";
|
|
|
91
91
|
// writes this any more; retirement reads it once and deletes it.
|
|
92
92
|
const PARKED_PATH = join(homedir(), ".agents-deck", "parked-sound-hooks.json");
|
|
93
93
|
|
|
94
|
+
// `Array.isArray`, not `?? []`, and that is a crash fix rather than a style
|
|
95
|
+
// choice. A group whose `hooks` is not an array — hand-edited settings.json,
|
|
96
|
+
// something else's schema, a half-finished edit — made this `.map` throw
|
|
97
|
+
// `TypeError: … .map is not a function`, out of `retireSoundHookIn`, out of
|
|
98
|
+
// `installHooks`, on the unconditional install every boot runs: the deck would
|
|
99
|
+
// not start at all until the user found and fixed the entry, with nothing but a
|
|
100
|
+
// stack to say which one. installer.mjs's own `isOurEntry` has always read the
|
|
101
|
+
// same field with the same guard, and its answer for a group it cannot read is
|
|
102
|
+
// to leave it exactly where it is.
|
|
94
103
|
const commandsOf = (entry) =>
|
|
95
|
-
(entry?.hooks
|
|
104
|
+
(Array.isArray(entry?.hooks) ? entry.hooks : []).map(h => (typeof h?.command === "string" ? h.command : ""));
|
|
96
105
|
|
|
97
106
|
/**
|
|
98
107
|
* THE TAIL, NOT THE WHOLE PATH — and this is a data-loss fix, not a tidy-up.
|