agent-dag 3.14.0 → 3.16.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/dist/web/assets/index-C7_uf54q.js +254 -0
- package/dist/web/assets/{index-Xyt_GsRj.css → index-DPOZaaRr.css} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +20 -0
- package/src/server/claude-accounts.mjs +13 -0
- package/src/server/deck-prefs.mjs +87 -4
- package/src/server/index.mjs +202 -3
- package/src/server/lan-engine.mjs +667 -0
- package/src/server/lan-socket.mjs +577 -0
- package/src/server/lan-sync.mjs +855 -0
- package/dist/web/assets/index-CinWEMD2.js +0 -244
package/dist/web/index.html
CHANGED
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
document.documentElement.setAttribute("data-theme", stored === "light" ? "light" : "dark");
|
|
41
41
|
})();
|
|
42
42
|
</script>
|
|
43
|
-
<script type="module" crossorigin src="/assets/index-
|
|
44
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
43
|
+
<script type="module" crossorigin src="/assets/index-C7_uf54q.js"></script>
|
|
44
|
+
<link rel="stylesheet" crossorigin href="/assets/index-DPOZaaRr.css">
|
|
45
45
|
</head>
|
|
46
46
|
<body>
|
|
47
47
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-dag",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.16.0",
|
|
4
4
|
"description": "Live deck of Claude Code and Codex agents — watch 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
|
@@ -31,6 +31,16 @@
|
|
|
31
31
|
"about a defect in the type, and the suite refuses both it and no space at",
|
|
32
32
|
"all."
|
|
33
33
|
],
|
|
34
|
+
"3.15.0": [
|
|
35
|
+
{
|
|
36
|
+
"title": "🔗 Decks on one network heal each other's dead logins",
|
|
37
|
+
"body": "A login dies on the machine that has not used it while the same account keeps working on the machine that has. The fix was a blob copied out of one deck and pasted into another. Now: **Accounts ▸ Local network**, the same passphrase on each deck — it offers you six words — and they find each other and repair themselves.\n\nA deck only **adds** an account it does not have, or **replaces one it has proven dead** — proven by Anthropic refusing the token, not guessed. A credential that works is never touched. The passphrase is the only gate; a stranger on the same wifi sees that a deck exists and nothing else."
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"title": "⚠️ What sharing a login actually means",
|
|
41
|
+
"body": "**A login you share is a live one, and it cannot be taken back.** Turning the switch off stops what has not happened yet; a credential that has left your machine is gone, and the only real revocation is signing in again at Anthropic — which ends the session everywhere at once.\n\nSharing a Claude account may also be against Anthropic's terms. Your call; the deck says it once and does not nag."
|
|
42
|
+
}
|
|
43
|
+
],
|
|
34
44
|
"3.14.0": [
|
|
35
45
|
{
|
|
36
46
|
"title": "📊 The machine comes back beside the list that covers it",
|
|
@@ -326,5 +336,15 @@
|
|
|
326
336
|
"title": "🔕 The deck no longer puts anything in your settings.json",
|
|
327
337
|
"body": "It used to add a hook there so Claude Code would play a sound when a turn ended. That entry is removed the first time you start this version, and any sound hook you wrote yourself is put back exactly where you had it. Nothing else in that file is touched. The deck makes the sound itself now — which is also why you finally hear one when a Codex turn ends, not just a Claude one."
|
|
328
338
|
}
|
|
339
|
+
],
|
|
340
|
+
"3.16.0": [
|
|
341
|
+
{
|
|
342
|
+
"title": "🤝 Pair two decks with one piece of text",
|
|
343
|
+
"body": "**Accounts ▸ Local network ▸ setup… ▸ make an invite.** Send the text however you already talk to them; they paste it and the two decks are paired. Nobody presses accept, and neither of you has to know which of your addresses the other can reach — the invite carries them all and their deck tries each one.\n\nDecks on the same router still find each other on their own and appear under **on this network right now**. An invite is for the case that does not work: another subnet, a VPN, a firewall.\n\nThe invite lasts 10 minutes and is used once. Anyone who gets hold of the text can pair with your deck until it runs out, so send it the way you would send anything else you would not want forwarded."
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
"title": "⚠️ Both machines have to be on this version",
|
|
347
|
+
"body": "The two decks no longer speak the old protocol, and there is no fallback: a 3.15 deck and a 3.16 deck cannot see each other at all. Update both.\n\nThe group passphrase is gone. If you had one set, that pairing is not carried over — make an invite and pair again. It was replaced because a passphrase that differs by one character produced a closed socket and no other symptom on either machine, and a secret is the one thing a panel must never print, so neither person could check theirs against the other's."
|
|
348
|
+
}
|
|
329
349
|
]
|
|
330
350
|
}
|
|
@@ -464,6 +464,19 @@ async function readRoster(now, gen) {
|
|
|
464
464
|
email: acct.email ?? null,
|
|
465
465
|
alias: acct.alias ?? null,
|
|
466
466
|
org: acct.organizationName ?? null,
|
|
467
|
+
// The other half of an account's identity, and the reason a slot number
|
|
468
|
+
// is not one: claude-swap keys on `(email, organizationUuid)` — the same
|
|
469
|
+
// email under two orgs is two accounts on purpose — and assigns slots
|
|
470
|
+
// max+1 per store, so the account that is 4 here is 2 on another machine.
|
|
471
|
+
// Surfaced for LAN sync, which has to match accounts across two stores
|
|
472
|
+
// that grew in a different order.
|
|
473
|
+
orgUuid: acct.organizationUuid ?? null,
|
|
474
|
+
// Whether CLAUDE-SWAP'S STORED COPY works — not whether the user is
|
|
475
|
+
// signed in. The two differ, and #721 is the whole argument: a
|
|
476
|
+
// `stale-copy` row means the live session is fine while the copy in the
|
|
477
|
+
// store is dead, and the copy is what a share would carry and what a
|
|
478
|
+
// peer's copy would heal. So both kinds of trouble read as not alive.
|
|
479
|
+
alive: trouble == null,
|
|
467
480
|
active: String(seq.activeAccountNumber) === num,
|
|
468
481
|
disabled: acct.disabled === true,
|
|
469
482
|
lanes,
|
|
@@ -50,7 +50,60 @@ export const prefsPath = (home = claudeConfigDir()) => join(prefsDir(home), "pre
|
|
|
50
50
|
* release notes describe; a switch that quietly turned an existing feature off
|
|
51
51
|
* on upgrade would be a worse surprise than the noise it is meant to stop.
|
|
52
52
|
*/
|
|
53
|
-
export const DEFAULTS = Object.freeze({
|
|
53
|
+
export const DEFAULTS = Object.freeze({
|
|
54
|
+
notifications: true,
|
|
55
|
+
// LAN sync, off until somebody turns it on. `passphrase` is the only secret
|
|
56
|
+
// this file has ever held, which is why the write below now names a mode.
|
|
57
|
+
lan: Object.freeze({ enabled: false, name: "", secret: "", shared: [], manual: [], trusted: [], port: 0 }),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/** The mode prefs.json is created with.
|
|
61
|
+
*
|
|
62
|
+
* It held nothing but booleans until LAN sync, and a booleans file at the
|
|
63
|
+
* umask default is unremarkable. A group passphrase is not: on a shared
|
|
64
|
+
* machine the default mode hands it to every other account on the box, and
|
|
65
|
+
* from it they can decrypt any credential that crosses the network.
|
|
66
|
+
*
|
|
67
|
+
* Passed to `writeFile` rather than applied with a follow-up chmod, and the
|
|
68
|
+
* difference is the whole point — claude-swap's transfer.py makes the same
|
|
69
|
+
* argument at length: a write-then-chmod leaves the file readable for the
|
|
70
|
+
* window between the two, which is exactly when a secret is in it. */
|
|
71
|
+
export const PREFS_MODE = 0o600;
|
|
72
|
+
|
|
73
|
+
/** One LAN section, coerced. Unknown keys dropped like everything else here,
|
|
74
|
+
* and the two lists forced to arrays of strings — they arrive from a page and
|
|
75
|
+
* are then compared against account keys and dialled as addresses. */
|
|
76
|
+
function normaliseLan(raw) {
|
|
77
|
+
const src = raw && typeof raw === "object" ? raw : {};
|
|
78
|
+
const strings = v => (Array.isArray(v) ? v.filter(x => typeof x === "string") : []);
|
|
79
|
+
return {
|
|
80
|
+
enabled: typeof src.enabled === "boolean" ? src.enabled : false,
|
|
81
|
+
name: typeof src.name === "string" ? src.name : "",
|
|
82
|
+
// THIS DECK'S PRIVATE KEY, and the only secret this file has ever held —
|
|
83
|
+
// which is why the write below names a mode rather than taking the umask's.
|
|
84
|
+
// It is an X25519 private key in base64 pkcs8; anything else is replaced on
|
|
85
|
+
// the next start rather than refused, because a corrupt key is not a thing
|
|
86
|
+
// anybody can act on and refusing to start would take the feature away.
|
|
87
|
+
secret: typeof src.secret === "string" ? src.secret : "",
|
|
88
|
+
shared: strings(src.shared),
|
|
89
|
+
manual: strings(src.manual),
|
|
90
|
+
// THE DECKS SOMEBODY PRESSED ACCEPT ON. The public key is the load-bearing
|
|
91
|
+
// half: a fingerprint is a hash of it, so an entry without one cannot be
|
|
92
|
+
// checked against whatever answers at an address later, and an entry that
|
|
93
|
+
// cannot be checked is worse than no entry at all.
|
|
94
|
+
trusted: (Array.isArray(src.trusted) ? src.trusted : [])
|
|
95
|
+
.filter(t => t && typeof t.fp === "string" && typeof t.pub === "string")
|
|
96
|
+
.map(t => ({ fp: t.fp, pub: t.pub, name: typeof t.name === "string" ? t.name : "" })),
|
|
97
|
+
// The port this deck listened on last time, so an address somebody typed on
|
|
98
|
+
// the other machine still works after a restart. It asked the OS for a new
|
|
99
|
+
// one every start, which is invisible while broadcast works and is exactly
|
|
100
|
+
// what does not work in the case the address field exists for.
|
|
101
|
+
//
|
|
102
|
+
// A PREFERENCE, never a requirement: a port already taken falls through to
|
|
103
|
+
// an OS-chosen one and this is rewritten. 0 means "none yet".
|
|
104
|
+
port: Number.isInteger(src.port) && src.port > 0 && src.port < 65_536 ? src.port : 0,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
54
107
|
|
|
55
108
|
/** Coerce whatever is on disk into a whole, known-shaped prefs object.
|
|
56
109
|
*
|
|
@@ -60,7 +113,10 @@ export const DEFAULTS = Object.freeze({ notifications: true });
|
|
|
60
113
|
* a setting nothing here can see, which reads as support and is not. */
|
|
61
114
|
export function normalise(raw) {
|
|
62
115
|
const src = raw && typeof raw === "object" ? raw : {};
|
|
63
|
-
return {
|
|
116
|
+
return {
|
|
117
|
+
notifications: typeof src.notifications === "boolean" ? src.notifications : DEFAULTS.notifications,
|
|
118
|
+
lan: normaliseLan(src.lan),
|
|
119
|
+
};
|
|
64
120
|
}
|
|
65
121
|
|
|
66
122
|
/** What is on disk, or the defaults. A corrupt or absent file is not an error
|
|
@@ -97,10 +153,15 @@ export async function writePrefs(patch, home = claudeConfigDir(), deps = {}) {
|
|
|
97
153
|
// 500s through `guard`, and the notifications switch silently does not
|
|
98
154
|
// stick. POSIX rename(2) has no such rule, which is why this shipped green.
|
|
99
155
|
const mv = deps.rename ?? renameWithRetry;
|
|
100
|
-
const
|
|
156
|
+
const prev = await readPrefs(home, deps);
|
|
157
|
+
// The LAN section merges rather than replaces, so a page toggling the
|
|
158
|
+
// switch does not have to send the passphrase back to keep it — and so
|
|
159
|
+
// nothing has to send a secret it was never given.
|
|
160
|
+
const merged = { ...prev, ...patch, lan: { ...prev.lan, ...(patch?.lan ?? {}) } };
|
|
161
|
+
const next = normalise(merged);
|
|
101
162
|
await mk(prefsDir(home), { recursive: true });
|
|
102
163
|
const tmp = `${prefsPath(home)}.${process.pid}.tmp`;
|
|
103
|
-
await write(tmp, JSON.stringify(next, null, 2) + "\n", "utf8");
|
|
164
|
+
await write(tmp, JSON.stringify(next, null, 2) + "\n", { encoding: "utf8", mode: PREFS_MODE });
|
|
104
165
|
await mv(tmp, prefsPath(home));
|
|
105
166
|
return next;
|
|
106
167
|
};
|
|
@@ -118,6 +179,28 @@ export async function writePrefs(patch, home = claudeConfigDir(), deps = {}) {
|
|
|
118
179
|
* the person who wrote the launch script are not always the same person, and
|
|
119
180
|
* only one of them is making a claim about the machine.
|
|
120
181
|
*/
|
|
182
|
+
/**
|
|
183
|
+
* The preferences as a PAGE may see them.
|
|
184
|
+
*
|
|
185
|
+
* THE PRIVATE KEY NEVER LEAVES THIS PROCESS. `GET /api/prefs` is readable by
|
|
186
|
+
* anything that can reach the loopback port — which is the whole point of the
|
|
187
|
+
* deck's own threat model, and is why the share envelope is not served there
|
|
188
|
+
* either. A page has no use for the key: what it needs is this deck's
|
|
189
|
+
* fingerprint, which is a hash of the PUBLIC half and comes from `/api/lan`.
|
|
190
|
+
*
|
|
191
|
+
* The pinned public keys of trusted decks go the same way, for a smaller
|
|
192
|
+
* reason: they are not secret, and they are forty characters of base64 that no
|
|
193
|
+
* page draws. What a page shows is a name and a fingerprint.
|
|
194
|
+
*/
|
|
195
|
+
export function publicPrefs(prefs) {
|
|
196
|
+
const p = normalise(prefs);
|
|
197
|
+
const { secret, trusted, ...lan } = p.lan;
|
|
198
|
+
return {
|
|
199
|
+
...p,
|
|
200
|
+
lan: { ...lan, trusted: trusted.map(t => ({ fp: t.fp, name: t.name })) },
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
121
204
|
export function notificationsOn(prefs, env = process.env) {
|
|
122
205
|
if (env[OFF_ENV] === "1") return false;
|
|
123
206
|
return normalise(prefs).notifications;
|
package/src/server/index.mjs
CHANGED
|
@@ -16,7 +16,8 @@ import { claudeConfigDir } from "./claude-dir.mjs";
|
|
|
16
16
|
import { CODEX_HOME, CODEX_SESSIONS_DIR, STOP, walkRolloutDays } from "./codex-dir.mjs";
|
|
17
17
|
import { PRODUCT } from "./brand.mjs";
|
|
18
18
|
import { createBlockNotifier } from "./block-notify.mjs";
|
|
19
|
-
import { DEFAULTS as PREF_DEFAULTS, notificationsOn, notificationsVetoed, readPrefs, writePrefs } from "./deck-prefs.mjs";
|
|
19
|
+
import { DEFAULTS as PREF_DEFAULTS, notificationsOn, notificationsVetoed, publicPrefs, readPrefs, writePrefs } from "./deck-prefs.mjs";
|
|
20
|
+
import { createEngine, defaultName } from "./lan-engine.mjs";
|
|
20
21
|
import { notify as osNotify } from "./browser-react.mjs";
|
|
21
22
|
import { invokedName, renameNotice } from "./invoked-as.mjs";
|
|
22
23
|
import { appendLogLine, codexCwdInWorkspace, electWriters, foldsCase, writesCodexLog } from "./log-writer.mjs";
|
|
@@ -3169,7 +3170,10 @@ function redactDeckToken(raw) {
|
|
|
3169
3170
|
* same freshness every other cross-deck setting has.
|
|
3170
3171
|
*/
|
|
3171
3172
|
let _prefs = { ...PREF_DEFAULTS };
|
|
3172
|
-
|
|
3173
|
+
// And LAN sync starts from the same read, so a deck that had it on comes back
|
|
3174
|
+
// with it on. `applyLanPrefs` is defined below — this runs after module
|
|
3175
|
+
// evaluation, which is what makes the forward reference fine.
|
|
3176
|
+
readPrefs().then(async p => { _prefs = p; await applyLanPrefs(); }).catch(() => {});
|
|
3173
3177
|
|
|
3174
3178
|
const blockNotifier = createBlockNotifier({
|
|
3175
3179
|
notify: osNotify,
|
|
@@ -3197,7 +3201,11 @@ const blockNotifier = createBlockNotifier({
|
|
|
3197
3201
|
function prefsPayload() {
|
|
3198
3202
|
return {
|
|
3199
3203
|
ok: true,
|
|
3200
|
-
|
|
3204
|
+
// `publicPrefs`, never `_prefs`. This route answers anything that can reach
|
|
3205
|
+
// the loopback port, and the LAN group passphrase is in the stored object —
|
|
3206
|
+
// one field, and sending it here would put it in front of every page on the
|
|
3207
|
+
// machine. What goes out is whether one is set.
|
|
3208
|
+
prefs: publicPrefs(_prefs),
|
|
3201
3209
|
notificationsAllowed: notificationsOn(_prefs),
|
|
3202
3210
|
notificationsVetoed: notificationsVetoed(),
|
|
3203
3211
|
};
|
|
@@ -3214,9 +3222,196 @@ async function handlePrefsWrite(req, res) {
|
|
|
3214
3222
|
try { body = JSON.parse(raw ?? ""); } catch { /* handled below */ }
|
|
3215
3223
|
if (!body || typeof body !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
|
|
3216
3224
|
_prefs = await writePrefs(body);
|
|
3225
|
+
// The engine reads its settings from here rather than holding its own copy,
|
|
3226
|
+
// so turning the switch off in the panel really does stop the sockets rather
|
|
3227
|
+
// than only changing what the panel says.
|
|
3228
|
+
await applyLanPrefs();
|
|
3217
3229
|
return send(res, 200, prefsPayload());
|
|
3218
3230
|
}
|
|
3219
3231
|
|
|
3232
|
+
// ── LAN sync ────────────────────────────────────────────────────────────────
|
|
3233
|
+
//
|
|
3234
|
+
// The engine is built once and told the settings; it opens and closes its own
|
|
3235
|
+
// sockets as those change. Nothing here touches a credential — see
|
|
3236
|
+
// lan-engine.mjs, which passes an opaque blob between two claude-swap commands.
|
|
3237
|
+
const lanEngine = createEngine({
|
|
3238
|
+
readAccounts: async () => {
|
|
3239
|
+
const { fetchClaudeAccounts } = await import("./claude-accounts.mjs");
|
|
3240
|
+
return fetchClaudeAccounts();
|
|
3241
|
+
},
|
|
3242
|
+
exportAccount: async num => {
|
|
3243
|
+
const { shareAccounts } = await import("./cswap-admin.mjs");
|
|
3244
|
+
const out = await shareAccounts([String(num)]);
|
|
3245
|
+
return out?.ok ? out.blob : null;
|
|
3246
|
+
},
|
|
3247
|
+
importAccount: async blob => {
|
|
3248
|
+
const { importAccount } = await import("./cswap-admin.mjs");
|
|
3249
|
+
// NO `force`, ever, and this is the line where that promise is kept. A
|
|
3250
|
+
// plain import adds an account that is missing and replaces exactly one
|
|
3251
|
+
// claude-swap has quarantined; it SKIPS one that is present and healthy.
|
|
3252
|
+
// So a peer cannot overwrite a working credential of this deck's even by
|
|
3253
|
+
// lying about its own, because the flag that would allow it is not passed.
|
|
3254
|
+
const out = await importAccount(blob);
|
|
3255
|
+
return !!out?.ok;
|
|
3256
|
+
},
|
|
3257
|
+
// The deck's own long-term key, kept so a restart is the same deck rather
|
|
3258
|
+
// than a stranger to everybody who has paired with it. Written once, on the
|
|
3259
|
+
// first start that has none. It IS a secret — prefs.json is 0600 for this.
|
|
3260
|
+
// The port it actually got, kept so an address typed on the other machine
|
|
3261
|
+
// still reaches this deck after it restarts. Written only when it differs
|
|
3262
|
+
// from what is stored, so a normal start writes nothing.
|
|
3263
|
+
onPort: async port => {
|
|
3264
|
+
try { _prefs = await writePrefs({ lan: { port } }); }
|
|
3265
|
+
catch { /* the address field still works this session; next start re-pins */ }
|
|
3266
|
+
},
|
|
3267
|
+
// A deck was accepted or unpaired. Written straight through, because the
|
|
3268
|
+
// trusted list is the whole of who this deck will talk to and a list that
|
|
3269
|
+
// only existed in memory would drop every pairing on restart.
|
|
3270
|
+
onTrust: async trusted => {
|
|
3271
|
+
try { _prefs = await writePrefs({ lan: { trusted } }); }
|
|
3272
|
+
catch (err) { console.error(`${PRODUCT}: lan sync could not save the pairing:`, err?.message ?? err); }
|
|
3273
|
+
},
|
|
3274
|
+
// An address this deck must keep dialling: the far end of a pairing that
|
|
3275
|
+
// happened over an invite. Kept in prefs, because setPeers replaces the dial
|
|
3276
|
+
// list wholesale on every settings write and a row that lives only in memory
|
|
3277
|
+
// is a pairing that goes one-way at the next restart.
|
|
3278
|
+
onDial: async entry => {
|
|
3279
|
+
try {
|
|
3280
|
+
const manual = Array.isArray(_prefs?.lan?.manual) ? _prefs.lan.manual : [];
|
|
3281
|
+
if (manual.includes(entry)) return;
|
|
3282
|
+
_prefs = await writePrefs({ lan: { manual: [...manual, entry] } });
|
|
3283
|
+
} catch { /* dialled this session; the next round re-adds it */ }
|
|
3284
|
+
},
|
|
3285
|
+
onIdentity: async secret => {
|
|
3286
|
+
try {
|
|
3287
|
+
_prefs = await writePrefs({ lan: { secret } });
|
|
3288
|
+
// AND PUT IT TO WORK. Writing it alone was not enough: a clash was
|
|
3289
|
+
// detected, a new id was stored, and both decks kept broadcasting the old
|
|
3290
|
+
// one — so they stayed invisible to each other with a correct file on
|
|
3291
|
+
// disk. `apply` restarts only when the id it is holding differs from the
|
|
3292
|
+
// one it is given, so this settles after one pass rather than looping.
|
|
3293
|
+
await applyLanPrefs();
|
|
3294
|
+
} catch { /* the next start picks it up; a shared id is the cost until then */ }
|
|
3295
|
+
},
|
|
3296
|
+
onError: (what, err) => {
|
|
3297
|
+
// Reported, never thrown. A machine with no route, a firewall that refuses
|
|
3298
|
+
// the bind, an interface that comes and goes with a VPN — none of them is a
|
|
3299
|
+
// reason for the deck to fall over.
|
|
3300
|
+
if (what !== "other-group") console.error(`${PRODUCT}: lan sync (${what}):`, err?.message ?? err);
|
|
3301
|
+
},
|
|
3302
|
+
});
|
|
3303
|
+
|
|
3304
|
+
/** Push whatever is in prefs at the engine. Called at boot and after every
|
|
3305
|
+
* write, so there is one source of truth and it is the file. */
|
|
3306
|
+
async function applyLanPrefs() {
|
|
3307
|
+
const lan = _prefs?.lan ?? {};
|
|
3308
|
+
try {
|
|
3309
|
+
await lanEngine.apply({
|
|
3310
|
+
enabled: !!lan.enabled,
|
|
3311
|
+
name: lan.name || defaultName(),
|
|
3312
|
+
secret: lan.secret || "",
|
|
3313
|
+
shared: Array.isArray(lan.shared) ? lan.shared : [],
|
|
3314
|
+
trusted: Array.isArray(lan.trusted) ? lan.trusted : [],
|
|
3315
|
+
port: lan.port || 0,
|
|
3316
|
+
});
|
|
3317
|
+
// Wholesale, so removing an address in the panel really stops it being
|
|
3318
|
+
// dialled rather than only taking the row away.
|
|
3319
|
+
lanEngine.setPeers(lan.manual);
|
|
3320
|
+
} catch (err) {
|
|
3321
|
+
console.error(`${PRODUCT}: lan sync could not start:`, err?.message ?? err);
|
|
3322
|
+
}
|
|
3323
|
+
}
|
|
3324
|
+
|
|
3325
|
+
/** What the panel draws: the switch, this deck's own name and address, and who
|
|
3326
|
+
* else is in the group. No passphrase, for the reason prefsPayload gives. */
|
|
3327
|
+
function handleLanStatus(req, res) {
|
|
3328
|
+
return send(res, 200, { ok: true, ...lanEngine.status() });
|
|
3329
|
+
}
|
|
3330
|
+
|
|
3331
|
+
/**
|
|
3332
|
+
* Accept a deck, dismiss a request, or unpair one.
|
|
3333
|
+
*
|
|
3334
|
+
* ONE ROUTE, THREE VERBS, in the shape the accounts panel already uses for its
|
|
3335
|
+
* own multi-verb writes. Each one takes a fingerprint and nothing else: the key
|
|
3336
|
+
* being pinned comes from what this deck actually saw on the wire, never from
|
|
3337
|
+
* the page, so a page cannot pair this deck with a key nobody has met.
|
|
3338
|
+
*/
|
|
3339
|
+
/**
|
|
3340
|
+
* Make an invite, put one away, or join on somebody else's.
|
|
3341
|
+
*
|
|
3342
|
+
* The token is the secret and it is never stored: minted on request, held in
|
|
3343
|
+
* memory until it expires or is used, and gone with the process. Nothing about
|
|
3344
|
+
* it reaches prefs.json, so a deck that restarts is offering nothing rather
|
|
3345
|
+
* than offering something its owner has forgotten they sent.
|
|
3346
|
+
*/
|
|
3347
|
+
async function handleLanInvite(req, res) {
|
|
3348
|
+
const raw = await readBody(req).catch(() => null);
|
|
3349
|
+
let body = null;
|
|
3350
|
+
try { body = JSON.parse(raw ?? ""); } catch { /* handled below */ }
|
|
3351
|
+
switch (body?.action) {
|
|
3352
|
+
case "make": {
|
|
3353
|
+
const made = lanEngine.invite();
|
|
3354
|
+
if (!made) return send(res, 409, { ok: false, reason: "not_running" });
|
|
3355
|
+
// AFTER the spread, not before: status carries its own `invite` — the
|
|
3356
|
+
// token and its expiry, which is all a panel needs — and letting that
|
|
3357
|
+
// overwrite this one dropped the address list the caller asked for.
|
|
3358
|
+
return send(res, 200, { ok: true, ...lanEngine.status(), invite: made });
|
|
3359
|
+
}
|
|
3360
|
+
case "withdraw":
|
|
3361
|
+
lanEngine.withdraw();
|
|
3362
|
+
return send(res, 200, { ok: true, ...lanEngine.status() });
|
|
3363
|
+
case "join": {
|
|
3364
|
+
const out = await lanEngine.join(body.token);
|
|
3365
|
+
if (!out.ok) return send(res, 200, { ok: false, ...out, ...lanEngine.status() });
|
|
3366
|
+
return send(res, 200, { ok: true, ...out, ...lanEngine.status() });
|
|
3367
|
+
}
|
|
3368
|
+
default:
|
|
3369
|
+
return send(res, 400, { ok: false, reason: "unknown_action" });
|
|
3370
|
+
}
|
|
3371
|
+
}
|
|
3372
|
+
|
|
3373
|
+
async function handleLanPeer(req, res) {
|
|
3374
|
+
const raw = await readBody(req).catch(() => null);
|
|
3375
|
+
let body = null;
|
|
3376
|
+
try { body = JSON.parse(raw ?? ""); } catch { /* handled below */ }
|
|
3377
|
+
const fp = body && typeof body.fp === "string" ? body.fp : null;
|
|
3378
|
+
if (!fp) return send(res, 400, { ok: false, reason: "bad_request" });
|
|
3379
|
+
switch (body.action) {
|
|
3380
|
+
case "accept": {
|
|
3381
|
+
const added = lanEngine.accept(fp);
|
|
3382
|
+
if (!added) return send(res, 409, { ok: false, reason: "not_seen" });
|
|
3383
|
+
// A deck we only HEARD is not pinned, it is dialled — see accept. The
|
|
3384
|
+
// address has to reach prefs or the next write of the settings wipes it:
|
|
3385
|
+
// setPeers replaces the dial list wholesale, on purpose, so an address
|
|
3386
|
+
// that lives only in memory disappears the first time anything else is
|
|
3387
|
+
// saved.
|
|
3388
|
+
if (added.dialled) {
|
|
3389
|
+
const entry = `${added.addr}:${added.port}`;
|
|
3390
|
+
const manual = Array.isArray(_prefs?.lan?.manual) ? _prefs.lan.manual : [];
|
|
3391
|
+
if (!manual.includes(entry)) {
|
|
3392
|
+
try { _prefs = await writePrefs({ lan: { manual: [...manual, entry] } }); }
|
|
3393
|
+
catch { /* it is dialled this session; the next accept re-adds it */ }
|
|
3394
|
+
}
|
|
3395
|
+
}
|
|
3396
|
+
return send(res, 200, { ok: true, added, ...lanEngine.status() });
|
|
3397
|
+
}
|
|
3398
|
+
case "dismiss":
|
|
3399
|
+
return send(res, 200, { ok: lanEngine.dismiss(fp), ...lanEngine.status() });
|
|
3400
|
+
case "unpair":
|
|
3401
|
+
return send(res, 200, { ok: lanEngine.unpair(fp), ...lanEngine.status() });
|
|
3402
|
+
default:
|
|
3403
|
+
return send(res, 400, { ok: false, reason: "unknown_action" });
|
|
3404
|
+
}
|
|
3405
|
+
}
|
|
3406
|
+
|
|
3407
|
+
/** Ask every peer now rather than at the next tick — the button beside the
|
|
3408
|
+
* list, for somebody who has just fixed a login on the other machine and does
|
|
3409
|
+
* not want to wait a minute to see it arrive. */
|
|
3410
|
+
async function handleLanSync(req, res) {
|
|
3411
|
+
const done = await lanEngine.round();
|
|
3412
|
+
return send(res, 200, { ok: true, done, ...lanEngine.status() });
|
|
3413
|
+
}
|
|
3414
|
+
|
|
3220
3415
|
function pushEvent(raw, source, opts = {}) {
|
|
3221
3416
|
// First, before anything below can see it: the deck's own credential does not
|
|
3222
3417
|
// belong in a store that is served without one. Every entry point to the
|
|
@@ -5444,6 +5639,10 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
5444
5639
|
// Machine state, not session state: sampled on the server's own timer and
|
|
5445
5640
|
// deliberately kept out of the event stream. See src/server/system-metrics.mjs.
|
|
5446
5641
|
if (req.method === "GET" && url.pathname === "/api/prefs") return handlePrefsRead(req, res);
|
|
5642
|
+
if (req.method === "GET" && url.pathname === "/api/lan") return handleLanStatus(req, res);
|
|
5643
|
+
if (req.method === "POST" && url.pathname === "/api/lan/peer") return guard(handleLanPeer(req, res), res);
|
|
5644
|
+
if (req.method === "POST" && url.pathname === "/api/lan/invite") return guard(handleLanInvite(req, res), res);
|
|
5645
|
+
if (req.method === "POST" && url.pathname === "/api/lan/sync") return guard(handleLanSync(req, res), res);
|
|
5447
5646
|
if (req.method === "POST" && url.pathname === "/api/prefs") return guard(handlePrefsWrite(req, res), res);
|
|
5448
5647
|
if (req.method === "GET" && url.pathname === "/api/system") return send(res, 200, systemSnapshot());
|
|
5449
5648
|
// On demand only — the process list costs a subprocess on every platform,
|