agent-dag 3.20.0 → 3.21.1
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 +113 -5
- package/dist/web/assets/{index-DVj_M279.css → index-BYMVIG6f.css} +1 -1
- package/dist/web/assets/index-ByXJWM3T.js +266 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/server/activity.mjs +52 -0
- package/src/server/auto-update.mjs +79 -0
- package/src/server/claude-accounts.mjs +32 -0
- package/src/server/cswap-admin.mjs +87 -11
- package/src/server/deck-prefs.mjs +51 -1
- package/src/server/index.mjs +162 -6
- package/src/server/lan-about.mjs +119 -0
- package/src/server/lan-engine.mjs +90 -7
- package/src/server/lan-sync.mjs +5 -1
- package/src/server/presence.mjs +40 -0
- package/dist/web/assets/index-Dr-bVaHM.js +0 -266
package/src/server/index.mjs
CHANGED
|
@@ -26,8 +26,12 @@ import { claudeConfigDir } from "./claude-dir.mjs";
|
|
|
26
26
|
import { CODEX_HOME, CODEX_SESSIONS_DIR, STOP, walkRolloutDays } from "./codex-dir.mjs";
|
|
27
27
|
import { PRODUCT } from "./brand.mjs";
|
|
28
28
|
import { createBlockNotifier } from "./block-notify.mjs";
|
|
29
|
-
import {
|
|
29
|
+
import { createActivity } from "./activity.mjs";
|
|
30
|
+
import { AWAY_BOOT_GRACE_MS, AWAY_RECHECK_MS, AWAY_TICK_MS, awayGate, awayUpdateStep } from "./auto-update.mjs";
|
|
31
|
+
import { createPresence } from "./presence.mjs";
|
|
32
|
+
import { DEFAULTS as PREF_DEFAULTS, cleanAlias, isAliasKey, notificationsOn, notificationsVetoed, publicPrefs, readPrefs, writePrefs } from "./deck-prefs.mjs";
|
|
30
33
|
import { createEngine, defaultName } from "./lan-engine.mjs";
|
|
34
|
+
import { aboutThisDeck } from "./lan-about.mjs";
|
|
31
35
|
import { PROBE_PS, localAliases, reachability, readProbe } from "./lan-reach.mjs";
|
|
32
36
|
import { run } from "./exec.mjs";
|
|
33
37
|
import { notify as osNotify } from "./browser-react.mjs";
|
|
@@ -48,6 +52,13 @@ const RUNNING_VERSION = (() => {
|
|
|
48
52
|
catch { return null; }
|
|
49
53
|
})();
|
|
50
54
|
|
|
55
|
+
// The two facts the away-update waits on (awayUpdateTick): whether a turn is
|
|
56
|
+
// running, fed from pushEvent, and whether a tab is being looked at, fed from
|
|
57
|
+
// POST /api/presence. Up here rather than beside the tick because pushEvent
|
|
58
|
+
// can run before the module below it has finished evaluating.
|
|
59
|
+
const activity = createActivity();
|
|
60
|
+
const presence = createPresence();
|
|
61
|
+
|
|
51
62
|
const MIME = {
|
|
52
63
|
".html": "text/html; charset=utf-8",
|
|
53
64
|
".js": "application/javascript; charset=utf-8",
|
|
@@ -3127,10 +3138,14 @@ function redactDeckToken(raw) {
|
|
|
3127
3138
|
* same freshness every other cross-deck setting has.
|
|
3128
3139
|
*/
|
|
3129
3140
|
let _prefs = { ...PREF_DEFAULTS };
|
|
3130
|
-
//
|
|
3131
|
-
//
|
|
3132
|
-
//
|
|
3133
|
-
|
|
3141
|
+
// Read at import, so `consider` has its answer from the first event. LAN sync
|
|
3142
|
+
// used to start from this same read, and it must not: bin/deck.js imports this
|
|
3143
|
+
// module to ASK whether a deck is already up, and a launcher that then attaches
|
|
3144
|
+
// and exits had already bound the beacon and the sync port on the way in —
|
|
3145
|
+
// which is the `lan sync (listen): EADDRINUSE` line a second `ccdeck` printed
|
|
3146
|
+
// above `deck already running`. The engine starts from the listen that
|
|
3147
|
+
// succeeds, in startServer, which is the only process that may hold a port.
|
|
3148
|
+
const _prefsRead = readPrefs().then(p => { _prefs = p; }).catch(() => {});
|
|
3134
3149
|
|
|
3135
3150
|
const blockNotifier = createBlockNotifier({
|
|
3136
3151
|
notify: osNotify,
|
|
@@ -3186,12 +3201,87 @@ async function handlePrefsWrite(req, res) {
|
|
|
3186
3201
|
return send(res, 200, prefsPayload());
|
|
3187
3202
|
}
|
|
3188
3203
|
|
|
3204
|
+
// ── updating while nobody is looking ────────────────────────────────────────
|
|
3205
|
+
//
|
|
3206
|
+
// auto-update.mjs has the why and the rules; this is the timer and the three
|
|
3207
|
+
// effects — install, restart, npx relaunch — each through the code a press
|
|
3208
|
+
// uses.
|
|
3209
|
+
let _awayTry = null;
|
|
3210
|
+
let _awayTimer = null;
|
|
3211
|
+
let _bootedAt = Date.now();
|
|
3212
|
+
// Until when a report that found nothing newer stands — see AWAY_RECHECK_MS.
|
|
3213
|
+
let _awayNothingUntil = 0;
|
|
3214
|
+
|
|
3215
|
+
/**
|
|
3216
|
+
* One tick of the away-update. Returns what it did, or null.
|
|
3217
|
+
*
|
|
3218
|
+
* Exported so a test can drive it without waiting out the boot grace — the one
|
|
3219
|
+
* input that is a policy rather than a fact, and so the only one it may pass.
|
|
3220
|
+
* Every clock is the real one: presence and activity are stamped with it.
|
|
3221
|
+
*/
|
|
3222
|
+
export async function awayUpdateTick({ graceMs = AWAY_BOOT_GRACE_MS } = {}) {
|
|
3223
|
+
const now = Date.now();
|
|
3224
|
+
if (!awayGate({
|
|
3225
|
+
enabled: _prefs?.autoUpdate !== false,
|
|
3226
|
+
supervised: _onRestart != null && _canRestart,
|
|
3227
|
+
restarting: _restarting,
|
|
3228
|
+
sinceBootMs: now - _bootedAt,
|
|
3229
|
+
looking: presence.looking(now),
|
|
3230
|
+
busy: activity.busy(now),
|
|
3231
|
+
quietMs: activity.quietMs(now),
|
|
3232
|
+
graceMs,
|
|
3233
|
+
})) return null;
|
|
3234
|
+
// A standing "nothing newer", unless the clock has moved back past it.
|
|
3235
|
+
if (now < _awayNothingUntil && _awayNothingUntil - now <= AWAY_RECHECK_MS) return null;
|
|
3236
|
+
const su = await import(pathToFileURL(join(PKG_ROOT, "src/server/self-update.mjs")).href);
|
|
3237
|
+
const report = await su.versionReport({ running: RUNNING_VERSION, pkgRoot: PKG_ROOT });
|
|
3238
|
+
if (!report.notice) {
|
|
3239
|
+
_awayNothingUntil = now + AWAY_RECHECK_MS;
|
|
3240
|
+
return null;
|
|
3241
|
+
}
|
|
3242
|
+
const step = awayUpdateStep({
|
|
3243
|
+
notice: report.notice, mode: report.upgradeMode,
|
|
3244
|
+
installing: report.upgrade?.state === "running", lastTry: _awayTry, now,
|
|
3245
|
+
});
|
|
3246
|
+
if (!step.act) return null;
|
|
3247
|
+
// Asked again, because the lookup above can take seconds on a slow line and
|
|
3248
|
+
// somebody may have sat down, or started a turn, in them.
|
|
3249
|
+
const again = Date.now();
|
|
3250
|
+
if (presence.looking(again) || activity.busy(again) || _restarting) return null;
|
|
3251
|
+
_awayTry = { target: step.target, at: again };
|
|
3252
|
+
if (step.act === "install") {
|
|
3253
|
+
su.startUpgrade({ pkgRoot: PKG_ROOT });
|
|
3254
|
+
return step.act;
|
|
3255
|
+
}
|
|
3256
|
+
// handleRestart's own check for the npx path, for the same reason: the mode
|
|
3257
|
+
// comes from the install on disk, and a spec it cannot name is no relaunch.
|
|
3258
|
+
if (step.act === "npx" && !su.npxRestartSpec(PKG_ROOT)) return null;
|
|
3259
|
+
_restarting = true;
|
|
3260
|
+
handOffRestart(step.act === "npx" ? "npx" : null);
|
|
3261
|
+
return step.act;
|
|
3262
|
+
}
|
|
3263
|
+
|
|
3264
|
+
/** POST {tab, looking} — a tab saying whether it is being looked at. See
|
|
3265
|
+
* presence.mjs, and src/web/presence.ts for the sender. */
|
|
3266
|
+
async function handlePresence(req, res) {
|
|
3267
|
+
const raw = await readBody(req).catch(() => null);
|
|
3268
|
+
let body = null;
|
|
3269
|
+
try { body = JSON.parse(raw ?? ""); } catch { /* handled below */ }
|
|
3270
|
+
if (!body || typeof body !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
|
|
3271
|
+
const ok = presence.report(body.tab, body.looking === true, Date.now());
|
|
3272
|
+
return send(res, ok ? 200 : 400, { ok });
|
|
3273
|
+
}
|
|
3274
|
+
|
|
3189
3275
|
// ── LAN sync ────────────────────────────────────────────────────────────────
|
|
3190
3276
|
//
|
|
3191
3277
|
// The engine is built once and told the settings; it opens and closes its own
|
|
3192
3278
|
// sockets as those change. Nothing here touches a credential — see
|
|
3193
3279
|
// lan-engine.mjs, which passes an opaque blob between two claude-swap commands.
|
|
3194
3280
|
const lanEngine = createEngine({
|
|
3281
|
+
// This deck's version and machine, for the decks it is paired with and
|
|
3282
|
+
// nobody else. RUNNING_VERSION rather than a fresh read, for the reason it
|
|
3283
|
+
// is read at import: it is what this process actually runs.
|
|
3284
|
+
about: aboutThisDeck({ version: RUNNING_VERSION }),
|
|
3195
3285
|
readAccounts: async () => {
|
|
3196
3286
|
const { fetchClaudeAccounts } = await import("./claude-accounts.mjs");
|
|
3197
3287
|
return fetchClaudeAccounts();
|
|
@@ -3324,6 +3414,9 @@ async function applyLanPrefs() {
|
|
|
3324
3414
|
port: lan.port || 0,
|
|
3325
3415
|
autoAsk: lan.autoAsk !== false,
|
|
3326
3416
|
autoAccept: lan.autoAccept !== false,
|
|
3417
|
+
// Names somebody here gave other decks. The engine only hands them to
|
|
3418
|
+
// the page, so a change never restarts anything.
|
|
3419
|
+
aliases: lan.aliases && typeof lan.aliases === "object" ? lan.aliases : {},
|
|
3327
3420
|
});
|
|
3328
3421
|
// Wholesale, so removing an address in the panel really stops it being
|
|
3329
3422
|
// dialled rather than only taking the row away.
|
|
@@ -3461,6 +3554,21 @@ async function handleLanPeer(req, res) {
|
|
|
3461
3554
|
return send(res, 200, { ok: lanEngine.allow(fp), ...lanEngine.status() });
|
|
3462
3555
|
case "unpair":
|
|
3463
3556
|
return send(res, 200, { ok: lanEngine.unpair(fp), ...lanEngine.status() });
|
|
3557
|
+
// WHAT THIS DECK CALLS THAT ONE, and nobody else sees it. Keyed by the
|
|
3558
|
+
// fingerprint, so it follows the machine across a new address or a new
|
|
3559
|
+
// name of its own choosing. An empty name takes the alias away.
|
|
3560
|
+
//
|
|
3561
|
+
// The whole map is rebuilt from the one on disk rather than sent by the
|
|
3562
|
+
// page, so two tabs renaming two decks cannot undo each other.
|
|
3563
|
+
case "alias": {
|
|
3564
|
+
if (!isAliasKey(fp)) return send(res, 400, { ok: false, reason: "bad_request" });
|
|
3565
|
+
const name = cleanAlias(body.name);
|
|
3566
|
+
const next = { ...(_prefs?.lan?.aliases ?? {}) };
|
|
3567
|
+
if (name) next[fp] = name; else delete next[fp];
|
|
3568
|
+
_prefs = await writePrefs({ lan: { aliases: next } });
|
|
3569
|
+
await lanEngine.apply({ aliases: _prefs.lan.aliases });
|
|
3570
|
+
return send(res, 200, { ok: true, ...lanEngine.status() });
|
|
3571
|
+
}
|
|
3464
3572
|
default:
|
|
3465
3573
|
return send(res, 400, { ok: false, reason: "unknown_action" });
|
|
3466
3574
|
}
|
|
@@ -3468,8 +3576,21 @@ async function handleLanPeer(req, res) {
|
|
|
3468
3576
|
|
|
3469
3577
|
/** Ask every peer now rather than at the next tick — the button beside the
|
|
3470
3578
|
* list, for somebody who has just fixed a login on the other machine and does
|
|
3471
|
-
* not want to wait a minute to see it arrive.
|
|
3579
|
+
* not want to wait a minute to see it arrive.
|
|
3580
|
+
*
|
|
3581
|
+
* Or ONE peer, when the body names it: the `check now` in a deck's own
|
|
3582
|
+
* dialog. A deck that only calls in has no address here, and says so rather
|
|
3583
|
+
* than reporting a round that asked nobody. */
|
|
3472
3584
|
async function handleLanSync(req, res) {
|
|
3585
|
+
const raw = await readBody(req).catch(() => null);
|
|
3586
|
+
let body = null;
|
|
3587
|
+
try { body = JSON.parse(raw ?? ""); } catch { /* the whole-list press sends nothing to read */ }
|
|
3588
|
+
const fp = body && typeof body.fp === "string" ? body.fp : null;
|
|
3589
|
+
if (fp) {
|
|
3590
|
+
const done = await lanEngine.roundOne(fp);
|
|
3591
|
+
if (done == null) return send(res, 409, { ok: false, reason: "no_address" });
|
|
3592
|
+
return send(res, 200, { ok: true, done, ...lanEngine.status() });
|
|
3593
|
+
}
|
|
3473
3594
|
const done = await lanEngine.round();
|
|
3474
3595
|
return send(res, 200, { ok: true, done, ...lanEngine.status() });
|
|
3475
3596
|
}
|
|
@@ -3624,6 +3745,10 @@ function pushEvent(raw, source, opts = {}) {
|
|
|
3624
3745
|
// know the other exists. block-notify.mjs holds the gates and the cooldown.
|
|
3625
3746
|
blockNotifier.consider(raw, { clients: sseClients.size, replay: !!opts.replay });
|
|
3626
3747
|
|
|
3748
|
+
// Whether a turn is running, for the away-update. Not from a replay: the log
|
|
3749
|
+
// is history, and a turn it shows open is one that ended in another process.
|
|
3750
|
+
if (!opts.replay) activity.note(raw, evt.receivedAt);
|
|
3751
|
+
|
|
3627
3752
|
if (persisting) {
|
|
3628
3753
|
// Fire-and-forget append. JSONL = newline-delimited JSON, so the whole line
|
|
3629
3754
|
// has to reach the file as one write — this used to be `appendFile`, which
|
|
@@ -4395,6 +4520,16 @@ async function handleRestart(req, res) {
|
|
|
4395
4520
|
if (_deckReady) send(res, 200, { ok: true, mode });
|
|
4396
4521
|
else send(res, 202, { ok: true, mode, booting: true, detail: "the deck is still starting up; the restart runs as soon as it has finished booting" });
|
|
4397
4522
|
// Let the response flush before the listener goes away.
|
|
4523
|
+
handOffRestart(mode);
|
|
4524
|
+
}
|
|
4525
|
+
|
|
4526
|
+
/**
|
|
4527
|
+
* Hand a restart the latch has already admitted to the launcher — from a press
|
|
4528
|
+
* (handleRestart, once its answer is on its way) or from the away-update
|
|
4529
|
+
* (awayUpdateTick). The delay is the press's: its response has to leave before
|
|
4530
|
+
* the listener does.
|
|
4531
|
+
*/
|
|
4532
|
+
function handOffRestart(mode) {
|
|
4398
4533
|
setTimeout(() => {
|
|
4399
4534
|
try { _onRestart(mode); }
|
|
4400
4535
|
catch (err) {
|
|
@@ -5648,6 +5783,22 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
5648
5783
|
// `!== false` rather than a cast: a caller that omits the field means "yes",
|
|
5649
5784
|
// which is how every embedder that predates this option keeps working.
|
|
5650
5785
|
_providers = { claude: claude !== false, codex: codex !== false };
|
|
5786
|
+
// The away-update's clock. The boot grace counts from here, and the timer is
|
|
5787
|
+
// unref'd so it never keeps a process alive on its own. It does nothing until
|
|
5788
|
+
// the launcher has handed down a restart — see awayGate's `supervised`.
|
|
5789
|
+
_bootedAt = Date.now();
|
|
5790
|
+
clearInterval(_awayTimer);
|
|
5791
|
+
_awayTimer = setInterval(() => { awayUpdateTick().catch(() => {}); }, AWAY_TICK_MS);
|
|
5792
|
+
_awayTimer.unref?.();
|
|
5793
|
+
// The repair a paused Claude account used to wait on a `resume` press for:
|
|
5794
|
+
// handed to the roster read here, by the server that is actually running,
|
|
5795
|
+
// rather than wired at import — see repairStaleCopyWith.
|
|
5796
|
+
if (_providers.claude) {
|
|
5797
|
+
void Promise.all([
|
|
5798
|
+
import(pathToFileURL(join(PKG_ROOT, "src/server/claude-accounts.mjs")).href),
|
|
5799
|
+
cswapAdminModule(),
|
|
5800
|
+
]).then(([accounts, admin]) => accounts.repairStaleCopyWith(admin.autoRecapture), () => {});
|
|
5801
|
+
}
|
|
5651
5802
|
const removed = await sweepStaleDiscovery();
|
|
5652
5803
|
if (removed > 0) console.log(` swept ${removed} stale discovery file(s)`);
|
|
5653
5804
|
if (persist) {
|
|
@@ -5725,6 +5876,7 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
5725
5876
|
if (req.method === "GET" && url.pathname === "/api/version") return guard(handleVersion(req, res), res);
|
|
5726
5877
|
if (req.method === "POST" && url.pathname === "/api/upgrade") return guard(handleUpgrade(req, res), res);
|
|
5727
5878
|
if (req.method === "POST" && url.pathname === "/api/restart") return guard(handleRestart(req, res), res);
|
|
5879
|
+
if (req.method === "POST" && url.pathname === "/api/presence") return guard(handlePresence(req, res), res);
|
|
5728
5880
|
if (req.method === "POST" && url.pathname === "/api/shutdown") return guard(handleStop(req, res), res);
|
|
5729
5881
|
if (req.method === "GET" && url.pathname === "/api/quota") return guard(handleQuota(req, res), res);
|
|
5730
5882
|
if (req.method === "GET" && url.pathname === "/api/codex-usage") return guard(handleCodexUsage(req, res), res);
|
|
@@ -5845,6 +5997,10 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
5845
5997
|
if (codex) startCodexWatcher(workspace);
|
|
5846
5998
|
// Both timers are unref'd, so this never holds the process open.
|
|
5847
5999
|
startSystemMetrics();
|
|
6000
|
+
// LAN sync, from the prefs the import read — and only from here, so a
|
|
6001
|
+
// deck that had it on comes back with it on, and a launcher that only
|
|
6002
|
+
// asked the registry never binds a port it is about to walk away from.
|
|
6003
|
+
_prefsRead.then(() => applyLanPrefs()).catch(() => {});
|
|
5848
6004
|
// Auto-switch resumes only if the user previously turned it on; the
|
|
5849
6005
|
// module reads its own persisted flag and does nothing otherwise.
|
|
5850
6006
|
cswapAutoModule().then(m => m.initCswapAuto()).catch(() => {});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// What a paired deck says about itself: the version it runs and the machine it
|
|
2
|
+
// runs on.
|
|
3
|
+
//
|
|
4
|
+
// ONLY TO A DECK THAT IS PAIRED, AND SEALED. The beacon is shouted at the whole
|
|
5
|
+
// network and the hello is sent before anybody has proved anything, so neither
|
|
6
|
+
// carries this: which build of which operating system a machine runs is the
|
|
7
|
+
// first thing somebody hunting for a known-broken version would want, and
|
|
8
|
+
// nobody on the network is owed it. It rides the manifest exchange, which only
|
|
9
|
+
// happens between two decks that have each accepted the other — and it is
|
|
10
|
+
// sealed with that connection's key, because the frames around it are JSON in
|
|
11
|
+
// the clear on the wire.
|
|
12
|
+
//
|
|
13
|
+
// BOTH WAYS. The deck that dials sends its own card with the question, and the
|
|
14
|
+
// deck that answers sends its own with the answer. A deck that only calls in —
|
|
15
|
+
// paired, with no address this one can reach — is never asked anything, so the
|
|
16
|
+
// card it sends when it calls is the only way this deck ever learns what it is.
|
|
17
|
+
//
|
|
18
|
+
// OLDER DECKS SAY NOTHING, AND NOTHING BREAKS. The field is extra on a frame
|
|
19
|
+
// both versions already send: a deck from before this reads the accounts and
|
|
20
|
+
// never looks at the rest, and a card that is absent is drawn as absent.
|
|
21
|
+
import { readFileSync } from "node:fs";
|
|
22
|
+
import { release as osRelease } from "node:os";
|
|
23
|
+
import { open, seal } from "./lan-sync.mjs";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The operating system, in the words its owner would use.
|
|
27
|
+
*
|
|
28
|
+
* Node reports the KERNEL — `darwin 25.5.0`, `win32 10.0.26100` — and nobody
|
|
29
|
+
* calls their laptop Darwin 25. So the numbers are translated on the machine
|
|
30
|
+
* they describe, where the translation is certain, rather than on the one
|
|
31
|
+
* reading them. Every input is a parameter, so all three platforms are tested
|
|
32
|
+
* on any one of them.
|
|
33
|
+
*/
|
|
34
|
+
export function osLabel({ platform, release = "", prettyName = "" } = {}) {
|
|
35
|
+
const [major, minor, build] = String(release).split(".").map(n => Number.parseInt(n, 10));
|
|
36
|
+
const dot = Number.isInteger(minor) ? `.${minor}` : "";
|
|
37
|
+
if (platform === "darwin") {
|
|
38
|
+
if (!Number.isInteger(major)) return "macOS";
|
|
39
|
+
// Darwin 20 was macOS 11 and every major after it was one more — until 25,
|
|
40
|
+
// which Apple shipped as macOS 26 to match the year.
|
|
41
|
+
if (major >= 25) return `macOS ${major + 1}${dot}`;
|
|
42
|
+
if (major >= 20) return `macOS ${major - 9}${dot}`;
|
|
43
|
+
// The 10.x years, where the kernel's major minus four is the second number.
|
|
44
|
+
return major >= 5 ? `macOS 10.${major - 4}` : "macOS";
|
|
45
|
+
}
|
|
46
|
+
if (platform === "win32") {
|
|
47
|
+
// Windows 11 kept the version 10.0 and moved only the build, and 22000 is
|
|
48
|
+
// the first one. From here it is the only way to tell the two apart.
|
|
49
|
+
if (major === 10 && Number.isInteger(build)) return build >= 22_000 ? "Windows 11" : "Windows 10";
|
|
50
|
+
return "Windows";
|
|
51
|
+
}
|
|
52
|
+
if (platform === "linux") return prettyName || "Linux";
|
|
53
|
+
return typeof platform === "string" && platform ? platform : "unknown";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The distribution's own name for itself, from the file every systemd-era
|
|
57
|
+
* distribution ships, with the fallback path the same spec names. Only ever
|
|
58
|
+
* read on Linux: the paths are Linux's and mean nothing anywhere else. */
|
|
59
|
+
export function linuxPrettyName(read = readFileSync) {
|
|
60
|
+
for (const file of ["/etc/os-release", "/usr/lib/os-release"]) {
|
|
61
|
+
try {
|
|
62
|
+
const line = /^PRETTY_NAME=(.*)$/m.exec(String(read(file, "utf8")))?.[1];
|
|
63
|
+
const name = line?.trim().replace(/^(["'])(.*)\1$/, "$2").trim();
|
|
64
|
+
if (name) return name;
|
|
65
|
+
} catch { /* not this one */ }
|
|
66
|
+
}
|
|
67
|
+
return "";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** This deck's own card. Built once, at start: nothing in it changes while the
|
|
71
|
+
* process lives, and an upgrade is a restart. */
|
|
72
|
+
export function aboutThisDeck({
|
|
73
|
+
version = null, platform = process.platform, release = osRelease(), arch = process.arch, read = readFileSync,
|
|
74
|
+
} = {}) {
|
|
75
|
+
return readAbout({
|
|
76
|
+
version,
|
|
77
|
+
os: osLabel({ platform, release, prettyName: platform === "linux" ? linuxPrettyName(read) : "" }),
|
|
78
|
+
arch,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** One field of a card, or null. It came off the network, so it is a string of
|
|
83
|
+
* bounded length with no control characters — the rule cleanName applies to a
|
|
84
|
+
* deck's name, for the same reason: it reaches a terminal as well as a page.
|
|
85
|
+
* `\p{Cc}` is the Unicode class for exactly those, C0 and C1 both. */
|
|
86
|
+
function field(v, max, shape = null) {
|
|
87
|
+
if (typeof v !== "string") return null;
|
|
88
|
+
const flat = v.replace(/\p{Cc}/gu, " ").replace(/\s+/g, " ").trim();
|
|
89
|
+
const s = [...flat].slice(0, max).join("").trim();
|
|
90
|
+
return s && (!shape || shape.test(s)) ? s : null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** A card, or null when there is nothing in it worth drawing. */
|
|
94
|
+
export function readAbout(raw) {
|
|
95
|
+
if (!raw || typeof raw !== "object") return null;
|
|
96
|
+
const version = field(raw.version, 32, /^[0-9A-Za-z][0-9A-Za-z.+-]*$/);
|
|
97
|
+
const os = field(raw.os, 48);
|
|
98
|
+
const arch = field(raw.arch, 16, /^[0-9A-Za-z_]+$/);
|
|
99
|
+
return version || os || arch ? { version, os, arch } : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Bound to one connection AND one direction, so a card cannot be replayed
|
|
103
|
+
* back at the deck that sent it as if it were the other deck's. */
|
|
104
|
+
const aboutAad = (fromFp, toFp) => `${fromFp}->${toFp}|about`;
|
|
105
|
+
|
|
106
|
+
/** This deck's card, sealed for the deck on the other end of `key`. */
|
|
107
|
+
export function sealAbout(key, about, fromFp, toFp) {
|
|
108
|
+
if (!key || !about) return null;
|
|
109
|
+
return seal(key, JSON.stringify(about), aboutAad(fromFp, toFp));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The other deck's card, or null — for a deck that sent none, and for one
|
|
113
|
+
* whose seal does not open, which mean the same thing to the panel. */
|
|
114
|
+
export function openAbout(key, sealed, fromFp, toFp) {
|
|
115
|
+
if (!key || !sealed || typeof sealed !== "object") return null;
|
|
116
|
+
const text = open(key, sealed, aboutAad(fromFp, toFp));
|
|
117
|
+
if (text == null) return null;
|
|
118
|
+
try { return readAbout(JSON.parse(text)); } catch { return null; }
|
|
119
|
+
}
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
import { accountKey, manifestFor, open, plan, seal, stillListed, transferChallenge } from "./lan-sync.mjs";
|
|
31
31
|
import { connectToPeer, createBeacon, createSyncServer, sendFrame } from "./lan-socket.mjs";
|
|
32
32
|
import { addTrusted, dropTrusted, identityFrom, mintInvite, pairable, readInvite, trustedPeer } from "./lan-sync.mjs";
|
|
33
|
+
import { openAbout, sealAbout } from "./lan-about.mjs";
|
|
33
34
|
import { randomBytes } from "node:crypto";
|
|
34
35
|
import { hostname, networkInterfaces } from "node:os";
|
|
35
36
|
|
|
@@ -95,6 +96,20 @@ export function localAddresses(faces = networkInterfaces()) {
|
|
|
95
96
|
return out;
|
|
96
97
|
}
|
|
97
98
|
|
|
99
|
+
/**
|
|
100
|
+
* The accounts a peer's manifest listed, as the panel may keep them.
|
|
101
|
+
*
|
|
102
|
+
* It arrived from another machine, so it is read rather than trusted: strings
|
|
103
|
+
* where strings belong, a boolean for the verdict, and no more rows than a
|
|
104
|
+
* manifest may carry. What is kept is only what the deck's dialog draws.
|
|
105
|
+
*/
|
|
106
|
+
export function offered(list) {
|
|
107
|
+
return (Array.isArray(list) ? list : [])
|
|
108
|
+
.filter(a => a && typeof a.key === "string" && typeof a.email === "string")
|
|
109
|
+
.slice(0, 50)
|
|
110
|
+
.map(a => ({ key: a.key.slice(0, 320), email: a.email.slice(0, 254), alive: a.alive === true }));
|
|
111
|
+
}
|
|
112
|
+
|
|
98
113
|
/**
|
|
99
114
|
* One deck's LAN sync, from settings to a healed account.
|
|
100
115
|
*
|
|
@@ -118,10 +133,13 @@ export function createEngine({
|
|
|
118
133
|
* with. Nothing secret leaves, and it is still a test shouting at an office.
|
|
119
134
|
*/
|
|
120
135
|
createSocket,
|
|
136
|
+
/** This deck's own card — its version and its machine — handed to every
|
|
137
|
+
* paired deck and to nobody else. See lan-about.mjs. */
|
|
138
|
+
about = null,
|
|
121
139
|
} = {}) {
|
|
122
140
|
let cfg = {
|
|
123
141
|
enabled: false, name: defaultName(), secret: "", shared: [], trusted: [], port: 0,
|
|
124
|
-
autoAsk: true, autoAccept: true,
|
|
142
|
+
autoAsk: true, autoAccept: true, aliases: {},
|
|
125
143
|
};
|
|
126
144
|
let identity = null;
|
|
127
145
|
let beacon = null;
|
|
@@ -166,6 +184,16 @@ export function createEngine({
|
|
|
166
184
|
/** What the last round did, for the panel. Not a log: one line per peer, most
|
|
167
185
|
* recent only, because "what happened" is a question about now. */
|
|
168
186
|
const lastRound = new Map();
|
|
187
|
+
/** What each paired deck said about itself — version, operating system,
|
|
188
|
+
* architecture — keyed by fingerprint, most recent only. Filled from both
|
|
189
|
+
* directions: the manifest a deck answers with, and the question a deck that
|
|
190
|
+
* calls in asks. See lan-about.mjs. */
|
|
191
|
+
const aboutBy = new Map();
|
|
192
|
+
/** The accounts each paired deck offered in its last manifest, keyed by
|
|
193
|
+
* fingerprint. Kept APART from lastRound on purpose: a round that fails
|
|
194
|
+
* replaces that line, and the list a deck offered a minute ago is still the
|
|
195
|
+
* best answer to "what does it share" while it is unreachable. */
|
|
196
|
+
const offersBy = new Map();
|
|
169
197
|
/** When the last round FINISHED, whatever it did or failed to do. The panel's
|
|
170
198
|
* `↻` fires one on demand and the loop fires one on its own; a reader who
|
|
171
199
|
* pressed it wants to know it happened, and a reader who did not wants to
|
|
@@ -212,6 +240,15 @@ export function createEngine({
|
|
|
212
240
|
}));
|
|
213
241
|
};
|
|
214
242
|
|
|
243
|
+
/** This deck's card for one connection, as a frame field — or nothing, for a
|
|
244
|
+
* deck built without one. Spread into the frame, so a deck from before this
|
|
245
|
+
* existed receives exactly the frame it always did plus one key it never
|
|
246
|
+
* reads. */
|
|
247
|
+
const cardFor = (key, toFp) => {
|
|
248
|
+
const sealed = sealAbout(key, about, identity.fp, toFp);
|
|
249
|
+
return sealed ? { about: sealed } : {};
|
|
250
|
+
};
|
|
251
|
+
|
|
215
252
|
/** Frames from a deck that finished the handshake AND that somebody here has
|
|
216
253
|
* accepted. Nothing reaches this before both, which is the whole point of
|
|
217
254
|
* where the two checks sit. `ctx.key` is this connection's key and no other
|
|
@@ -222,8 +259,16 @@ export function createEngine({
|
|
|
222
259
|
if (ctx?.peerFp) spokeAt.set(ctx.peerFp, now());
|
|
223
260
|
try {
|
|
224
261
|
if (msg.t === "manifest") {
|
|
262
|
+
// THE CALLER'S CARD RIDES THE QUESTION, which is the only way a deck
|
|
263
|
+
// that calls in ever says what it is: nothing here dials it, so nothing
|
|
264
|
+
// here ever asks. A seal that does not open is a deck that said nothing.
|
|
265
|
+
const card = openAbout(ctx.key, msg.about, ctx.peerFp, identity.fp);
|
|
266
|
+
if (card) aboutBy.set(ctx.peerFp, { ...card, at: now() });
|
|
225
267
|
const accounts = await localAccounts();
|
|
226
|
-
return ctx.send({
|
|
268
|
+
return ctx.send({
|
|
269
|
+
t: "manifest", accounts: manifestFor(accounts, cfg.shared),
|
|
270
|
+
...cardFor(ctx.key, ctx.peerFp),
|
|
271
|
+
});
|
|
227
272
|
}
|
|
228
273
|
if (msg.t === "want") {
|
|
229
274
|
// A SECOND PROOF, for the one operation that moves a credential. The
|
|
@@ -313,13 +358,18 @@ export function createEngine({
|
|
|
313
358
|
|
|
314
359
|
if (!trustedPeer(cfg.trusted, conn.peerFp)) {
|
|
315
360
|
const { list, added } = addTrusted(cfg.trusted, {
|
|
316
|
-
fp: conn.peerFp, pub: conn.peerPub, name: conn.peerName,
|
|
361
|
+
fp: conn.peerFp, pub: conn.peerPub, name: conn.peerName, at: now(),
|
|
317
362
|
});
|
|
318
363
|
if (added) { cfg = { ...cfg, trusted: list }; onTrust?.(list); }
|
|
319
364
|
}
|
|
320
365
|
|
|
321
|
-
|
|
366
|
+
// Our card goes with the question and theirs comes back with the answer
|
|
367
|
+
// — see lan-about.mjs for why it is here and nowhere earlier.
|
|
368
|
+
const theirs = await ask({ t: "manifest", ...cardFor(conn.key, conn.peerFp) });
|
|
322
369
|
if (theirs?.t !== "manifest" || !Array.isArray(theirs.accounts)) throw new Error("no manifest");
|
|
370
|
+
const card = openAbout(conn.key, theirs.about, conn.peerFp, identity.fp);
|
|
371
|
+
if (card) aboutBy.set(conn.peerFp, { ...card, at: now() });
|
|
372
|
+
offersBy.set(conn.peerFp, { at: now(), accounts: offered(theirs.accounts) });
|
|
323
373
|
const mine = await localAccounts();
|
|
324
374
|
// Only accounts I have also ticked. Sharing is mutual by construction:
|
|
325
375
|
// a peer cannot push an account at me that I never agreed to hold.
|
|
@@ -449,7 +499,7 @@ export function createEngine({
|
|
|
449
499
|
// Somebody used the token. They are pinned, and the token is retired —
|
|
450
500
|
// one that pairs twice is one worth stealing twice.
|
|
451
501
|
onInviteUsed: entry => {
|
|
452
|
-
const { list } = addTrusted(cfg.trusted, { fp: entry.fp, pub: entry.pub, name: entry.name });
|
|
502
|
+
const { list } = addTrusted(cfg.trusted, { fp: entry.fp, pub: entry.pub, name: entry.name, at: now() });
|
|
453
503
|
cfg = { ...cfg, trusted: list };
|
|
454
504
|
invite = null;
|
|
455
505
|
// AND DIAL IT BACK, KEPT. Accepting made it welcome and left this
|
|
@@ -612,7 +662,7 @@ export function createEngine({
|
|
|
612
662
|
name: cfg.name, myPort: server.port(), code: inv.code,
|
|
613
663
|
});
|
|
614
664
|
const { list } = addTrusted(cfg.trusted, {
|
|
615
|
-
fp: conn.peerFp, pub: conn.peerPub, name: conn.peerName || inv.name,
|
|
665
|
+
fp: conn.peerFp, pub: conn.peerPub, name: conn.peerName || inv.name, at: now(),
|
|
616
666
|
});
|
|
617
667
|
cfg = { ...cfg, trusted: list };
|
|
618
668
|
this.addPeer(at.addr, at.port);
|
|
@@ -667,7 +717,7 @@ export function createEngine({
|
|
|
667
717
|
onChange?.();
|
|
668
718
|
return { fp, name: seen.name, addr: seen.addr, port: seen.port, dialled: true };
|
|
669
719
|
}
|
|
670
|
-
const { list, added } = addTrusted(cfg.trusted, { fp, pub: seen.pub, name: seen.name });
|
|
720
|
+
const { list, added } = addTrusted(cfg.trusted, { fp, pub: seen.pub, name: seen.name, at: now() });
|
|
671
721
|
cfg = { ...cfg, trusted: list };
|
|
672
722
|
pending.delete(fp);
|
|
673
723
|
strangers.delete(fp);
|
|
@@ -724,6 +774,24 @@ export function createEngine({
|
|
|
724
774
|
return true;
|
|
725
775
|
},
|
|
726
776
|
round,
|
|
777
|
+
/**
|
|
778
|
+
* One deck, now — the `check now` in that deck's own dialog.
|
|
779
|
+
*
|
|
780
|
+
* Found the way the list found it: heard on the network under its own
|
|
781
|
+
* fingerprint, or dialled at an address whose answer was that fingerprint.
|
|
782
|
+
* Null when it is neither, which is a deck that only calls in — nothing
|
|
783
|
+
* here holds an address for it, so there is nobody to dial.
|
|
784
|
+
*
|
|
785
|
+
* `roundAt` is left alone: it says when EVERY paired deck was last asked,
|
|
786
|
+
* and asking one of them does not make that true.
|
|
787
|
+
*/
|
|
788
|
+
async roundOne(fp) {
|
|
789
|
+
if (!beacon || typeof fp !== "string" || !fp) return null;
|
|
790
|
+
const heard = [...beacon.peers.values()].find(p => p.fp === fp && stillListed(p, now()));
|
|
791
|
+
const typed = [...manual.values()].find(p => learned.get(`${p.addr}:${p.port}`)?.fp === fp);
|
|
792
|
+
const peer = heard ?? typed;
|
|
793
|
+
return peer ? roundWith(peer) : null;
|
|
794
|
+
},
|
|
727
795
|
/** Dial this address on every round from now on. Returns false for an
|
|
728
796
|
* address that is not one, rather than storing a row that can never
|
|
729
797
|
* connect and reports an error every minute forever. */
|
|
@@ -758,6 +826,11 @@ export function createEngine({
|
|
|
758
826
|
// which on a deck that has just started is the honest answer.
|
|
759
827
|
checkedAt: roundAt,
|
|
760
828
|
name: cfg.name,
|
|
829
|
+
// This deck's own card, so the panel can read a peer's version against
|
|
830
|
+
// it; and the names somebody here gave other decks, which the panel
|
|
831
|
+
// and the request dialog draw in place of the ones those decks chose.
|
|
832
|
+
about: about ?? null,
|
|
833
|
+
aliases: { ...(cfg.aliases ?? {}) },
|
|
761
834
|
// Whether this deck asks on its own, and whether a request that
|
|
762
835
|
// arrives is answered here or answered for you.
|
|
763
836
|
autoAsk: !!cfg.autoAsk,
|
|
@@ -820,6 +893,15 @@ export function createEngine({
|
|
|
820
893
|
had.met = had.met || row.met;
|
|
821
894
|
if (row.name && !had.name) had.name = row.name;
|
|
822
895
|
};
|
|
896
|
+
// WHAT THE DECK'S OWN DIALOG DRAWS, by identity: the card it sent,
|
|
897
|
+
// the logins it offered last, and when somebody here said yes. All
|
|
898
|
+
// three are keyed by the fingerprint that proved itself, so both
|
|
899
|
+
// halves of a merged row read the same answer.
|
|
900
|
+
const card = id => ({
|
|
901
|
+
about: aboutBy.get(id) ?? null,
|
|
902
|
+
offers: offersBy.get(id) ?? null,
|
|
903
|
+
pairedAt: trustedPeer(cfg.trusted, id)?.at ?? null,
|
|
904
|
+
});
|
|
823
905
|
for (const p of [...beacon.peers.values(), ...manual.values()]) {
|
|
824
906
|
if (!stillListed(p, now())) continue;
|
|
825
907
|
const met = p.manual ? learned.get(`${p.addr}:${p.port}`) : null;
|
|
@@ -834,6 +916,7 @@ export function createEngine({
|
|
|
834
916
|
met: !!met,
|
|
835
917
|
paired: !!trustedPeer(cfg.trusted, id),
|
|
836
918
|
last: lastRound.get(p.fp) ?? null,
|
|
919
|
+
...card(id),
|
|
837
920
|
});
|
|
838
921
|
}
|
|
839
922
|
// A DECK WE ARE PAIRED WITH AND DO NOT DIAL. It called us, we accepted
|
package/src/server/lan-sync.mjs
CHANGED
|
@@ -592,7 +592,11 @@ export function addTrusted(trusted, entry) {
|
|
|
592
592
|
added: false,
|
|
593
593
|
};
|
|
594
594
|
}
|
|
595
|
-
|
|
595
|
+
// WHEN, for the panel's "paired since". Only a new pin gets one: a deck that
|
|
596
|
+
// was already trusted keeps whatever date it had, and one pinned before this
|
|
597
|
+
// field existed keeps having none rather than being given today's.
|
|
598
|
+
const at = Number.isFinite(entry.at) && entry.at > 0 ? { at: entry.at } : {};
|
|
599
|
+
return { list: [...list, { fp: entry.fp, pub: entry.pub, name: entry.name ?? "", ...at }], added: true };
|
|
596
600
|
}
|
|
597
601
|
|
|
598
602
|
/** Take one back out. Unpairing stops what has not happened yet and takes back
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Whether somebody is looking at the deck right now, as its tabs say.
|
|
2
|
+
//
|
|
3
|
+
// The server can tell whether a page is CONNECTED — `sseClients.size` — but not
|
|
4
|
+
// whether anybody is looking at one: a tab behind other windows keeps its
|
|
5
|
+
// stream open all day. The away-update (auto-update.mjs) needs the second fact,
|
|
6
|
+
// so each tab reports it (src/web/presence.ts), and a claim expires here unless
|
|
7
|
+
// it is renewed, because a tab that crashed or lost its network never gets to
|
|
8
|
+
// take it back.
|
|
9
|
+
export const PRESENCE_TTL_MS = 45_000;
|
|
10
|
+
/** More tabs than anybody opens on one deck. A bound because the ids arrive
|
|
11
|
+
* from pages; past it the oldest claim goes. */
|
|
12
|
+
const MAX_TABS = 32;
|
|
13
|
+
const TAB_ID = /^[\w-]{1,64}$/;
|
|
14
|
+
|
|
15
|
+
export function createPresence({ ttlMs = PRESENCE_TTL_MS } = {}) {
|
|
16
|
+
/** tab id → when it last said it was looking */
|
|
17
|
+
const seen = new Map();
|
|
18
|
+
return {
|
|
19
|
+
/** A tab saying whether it is being looked at. False for an id no page
|
|
20
|
+
* could have made, which the route answers with a 400. */
|
|
21
|
+
report(tab, looking, now) {
|
|
22
|
+
if (typeof tab !== "string" || !TAB_ID.test(tab)) return false;
|
|
23
|
+
seen.delete(tab);
|
|
24
|
+
if (looking) {
|
|
25
|
+
seen.set(tab, now);
|
|
26
|
+
while (seen.size > MAX_TABS) seen.delete(seen.keys().next().value);
|
|
27
|
+
}
|
|
28
|
+
return true;
|
|
29
|
+
},
|
|
30
|
+
/** Whether any tab's claim is still live. A claim stamped by a clock that
|
|
31
|
+
* has since moved either way is as expired as an old one. */
|
|
32
|
+
looking(now) {
|
|
33
|
+
for (const [tab, at] of seen) {
|
|
34
|
+
if (Math.abs(now - at) > ttlMs) seen.delete(tab);
|
|
35
|
+
else return true;
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|