claude-code-session-manager 0.109.0 → 0.111.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/assets/{AgentLibrary-Br4Ispr4.js → AgentLibrary-rBO1daqM.js} +1 -1
- package/dist/assets/{Button-C8Mc6MEP.js → Button-DsEYRZg0.js} +1 -1
- package/dist/assets/{DataModel-Bz0MypYO.js → DataModel-D8sbvvBC.js} +1 -1
- package/dist/assets/{History-DT26Iy38.js → History-BEHLtxia.js} +1 -1
- package/dist/assets/{Hooks-e4xUGEf-.js → Hooks-CKpvlXrg.js} +3 -3
- package/dist/assets/{Library-B3rzEhvN.js → Library-CeF4qh9d.js} +1 -1
- package/dist/assets/{ListDetail-BAvci_aP.js → ListDetail-DcfmxPFk.js} +1 -1
- package/dist/assets/{MarkdownEditor-DBWTGgRz.js → MarkdownEditor-CvK1pyto.js} +1 -1
- package/dist/assets/{McpServers-C-grBN21.js → McpServers-D-h2ihzJ.js} +2 -2
- package/dist/assets/{Memory-BmM0Fz-C.js → Memory-BnF89x8v.js} +1 -1
- package/dist/assets/{Panel-Dc6F8wb4.js → Panel-CQY5zano.js} +1 -1
- package/dist/assets/{Permissions-m_NnldFh.js → Permissions-DLukmmsz.js} +3 -3
- package/dist/assets/{Plugins-BB0TLUC9.js → Plugins-0Pf0OZ4l.js} +2 -2
- package/dist/assets/{ProvenanceBadge-k9PH0S2n.js → ProvenanceBadge-vC7Tj-JP.js} +1 -1
- package/dist/assets/RemoteMachines-CWnKbFHc.js +3 -0
- package/dist/assets/{SaveBar-DSMnbUVI.js → SaveBar-DjijiUb0.js} +1 -1
- package/dist/assets/{Scheduler-CbIcJEfM.js → Scheduler-Cm3EXZvE.js} +7 -7
- package/dist/assets/{ScopeSwitcher-tHtloWbN.js → ScopeSwitcher-B-P-kSfq.js} +1 -1
- package/dist/assets/Settings-DYDlp2m_.js +4 -0
- package/dist/assets/{SkillReferenceGraph-Dndl1tzr.js → SkillReferenceGraph-Epv2GJmj.js} +1 -1
- package/dist/assets/{Skills-CGnxroik.js → Skills-DHO0N0C2.js} +1 -1
- package/dist/assets/{SystemPrompt-B3A3rHNp.js → SystemPrompt-DxeNofL9.js} +1 -1
- package/dist/assets/{TagLibrary-k4CTiAYo.js → TagLibrary-QOAs7ISO.js} +1 -1
- package/dist/assets/{TiptapBody-C2LfNQXw.js → TiptapBody-DAMO3DhO.js} +1 -1
- package/dist/assets/{Toggle-CTXTU1q5.js → Toggle-Ba_f7q_m.js} +1 -1
- package/dist/assets/{ViewTabs-CfEfOSE1.js → ViewTabs-CGX6MAhc.js} +1 -1
- package/dist/assets/{historyTotals-Db0xy2Fg.js → historyTotals-vgfk8EOL.js} +1 -1
- package/dist/assets/index-CPieAFsl.js +3078 -0
- package/dist/assets/{index-C8Smiz7z.css → index-Dutcsly-.css} +1 -1
- package/dist/assets/{settingsSchema-xxO9p4yF.js → settingsSchema-D0FxNrTy.js} +1 -1
- package/dist/assets/{useAgentsDirWatch-DLS1_moj.js → useAgentsDirWatch-DCu6BNYN.js} +1 -1
- package/dist/assets/useRemotePeers-D4cdJDyh.js +1 -0
- package/dist/index.html +2 -2
- package/package.json +2 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +3 -1
- package/plugins/session-manager-dev/skills/send-feedback/SKILL.md +48 -15
- package/scripts/scheduler-mcp-server.cjs +29 -2
- package/src/main/build-info.json +4 -4
- package/src/main/chatRunner.cjs +7 -0
- package/src/main/index.cjs +6 -0
- package/src/main/ipcSchemas.cjs +28 -0
- package/src/main/lib/autoUpdater.cjs +190 -0
- package/src/main/lib/builtinMacros.cjs +2 -2
- package/src/main/lib/crossProjectFeedback.cjs +143 -6
- package/src/main/lib/mcpToolCatalog.cjs +12 -3
- package/src/main/lib/reaperHelpers.cjs +13 -1
- package/src/main/lib/relayClientAuth.cjs +133 -0
- package/src/main/lib/remoteClientSessions.cjs +84 -0
- package/src/main/lib/shippedPersonaSeeds.cjs +11 -0
- package/src/main/lib/updaterSettings.cjs +64 -0
- package/src/main/remoteClient.cjs +42 -13
- package/src/main/remoteClientIpc.cjs +33 -1
- package/src/main/scheduler.cjs +16 -0
- package/src/main/updaterIpc.cjs +110 -0
- package/src/main/webRemote.cjs +276 -132
- package/src/preload/api.d.ts +55 -2
- package/src/preload/index.cjs +24 -1
- package/src/seed/agents/demo-video-builder.md +62 -19
- package/dist/assets/Settings-CW3w7knp.js +0 -6
- package/dist/assets/index-fJxuoRvi.js +0 -3077
package/src/main/webRemote.cjs
CHANGED
|
@@ -31,6 +31,7 @@ const { claudeHome } = require('./lib/schedulerPaths.cjs');
|
|
|
31
31
|
const { schemas } = require('./ipcSchemas.cjs');
|
|
32
32
|
const { summarize: _sharedSummarize } = require('./lib/summarize.cjs');
|
|
33
33
|
const { makeState, confirmSas: confirmSasLogic } = require('./lib/e2eStateMachine.cjs');
|
|
34
|
+
const { createClientSessions, LEGACY_CLIENT_KEY } = require('./lib/remoteClientSessions.cjs');
|
|
34
35
|
|
|
35
36
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
36
37
|
|
|
@@ -86,12 +87,10 @@ let _destroyed = false; // set at app shutdown to stop reconnect loops
|
|
|
86
87
|
|
|
87
88
|
// E2E session state — reset on each new WS connection.
|
|
88
89
|
// .state: 'idle' | 'pending_sas' | 'authenticated' | 'failed'
|
|
89
|
-
|
|
90
|
-
//
|
|
91
|
-
// deviceId it
|
|
92
|
-
|
|
93
|
-
let _e2ePeerPubKey = null;
|
|
94
|
-
let _e2eDeviceId = null;
|
|
90
|
+
// One record per connected client (keyed by relay-stamped fromConn, else 'legacy').
|
|
91
|
+
// Each holds its own ECDH session key + SAS state, the browser pubkey that produced
|
|
92
|
+
// it, and the deviceId it arrived on (for TOFU pinning on manual SAS confirmation).
|
|
93
|
+
const _clients = createClientSessions();
|
|
95
94
|
|
|
96
95
|
// ─── Config helpers ───────────────────────────────────────────────────────────
|
|
97
96
|
|
|
@@ -143,14 +142,14 @@ async function saveConfig(data) {
|
|
|
143
142
|
|
|
144
143
|
// ─── Audit log ────────────────────────────────────────────────────────────────
|
|
145
144
|
|
|
146
|
-
// Format: <ISO> <type> deviceId=<id> msgId=<uuid> result=ok|error:<code>
|
|
145
|
+
// Format: <ISO> <type> deviceId=<id> client=<clientId|connKey|-> msgId=<uuid> result=ok|error:<code>
|
|
147
146
|
// NEVER log token values or payload content.
|
|
148
|
-
async function auditLog(ts, type, deviceId, msgId, result) {
|
|
147
|
+
async function auditLog(ts, type, deviceId, msgId, result, client) {
|
|
149
148
|
try {
|
|
150
149
|
const ymd = ts.slice(0, 10);
|
|
151
150
|
const logPath = path.join(AUDIT_LOG_DIR, `remote-audit-${ymd}.log`);
|
|
152
151
|
const clean = (s) => String(s ?? '').replace(/[\r\n\t]+/g, ' ').slice(0, 200);
|
|
153
|
-
const line = `${ts} ${clean(type)} deviceId=${clean(deviceId) || '-'} msgId=${clean(msgId) || '-'} result=${result}\n`;
|
|
152
|
+
const line = `${ts} ${clean(type)} deviceId=${clean(deviceId) || '-'} client=${clean(client && (client.clientId || client.key)) || '-'} msgId=${clean(msgId) || '-'} result=${result}\n`;
|
|
154
153
|
const handle = await fsp.open(logPath, 'a', 0o600);
|
|
155
154
|
try {
|
|
156
155
|
await handle.write(line);
|
|
@@ -218,43 +217,73 @@ async function getDeviceTicket(deviceToken) {
|
|
|
218
217
|
|
|
219
218
|
// ─── E2E state helpers ────────────────────────────────────────────────────────
|
|
220
219
|
|
|
221
|
-
//
|
|
222
|
-
|
|
220
|
+
// Tear down every client session (connection-level reset).
|
|
221
|
+
function resetE2e() {
|
|
222
|
+
teardownAllClients();
|
|
223
|
+
}
|
|
223
224
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
225
|
+
// Mark one client's session failed without touching any other client.
|
|
226
|
+
function failClient(client) {
|
|
227
|
+
client.e2e = makeState('failed');
|
|
228
|
+
client.peerPubKey = null;
|
|
229
|
+
client.deviceId = null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function makeClientlessState() {
|
|
233
|
+
return { e2e: makeState() };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function anyAuthenticated() {
|
|
237
|
+
return _clients.authenticated().length > 0;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Every client currently awaiting SAS confirmation.
|
|
241
|
+
function pendingClients() {
|
|
242
|
+
return _clients.values().filter((r) => r.e2e.state === 'pending_sas');
|
|
230
243
|
}
|
|
231
244
|
|
|
232
|
-
|
|
233
|
-
|
|
245
|
+
const MAX_PINNED_PEER_KEYS = 20;
|
|
246
|
+
|
|
247
|
+
// True when `pubKey` is pinned on `device` (new list, or the legacy single key — read-only compat).
|
|
248
|
+
function isPinnedPeerKey(device, pubKey) {
|
|
249
|
+
if (!pubKey) return false;
|
|
250
|
+
if (device.verifiedPeerPubKey && device.verifiedPeerPubKey === pubKey) return true;
|
|
251
|
+
return Array.isArray(device.verifiedPeerPubKeys) && device.verifiedPeerPubKeys.some((p) => p && p.pubKey === pubKey);
|
|
234
252
|
}
|
|
235
253
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
254
|
+
// Append a pin (never removing another client's), dedupe by pubKey, keep the newest MAX.
|
|
255
|
+
function addPinnedPeerKey(device, pubKey, clientId) {
|
|
256
|
+
const prev = Array.isArray(device.verifiedPeerPubKeys) ? device.verifiedPeerPubKeys : [];
|
|
257
|
+
const next = prev.filter((p) => p && p.pubKey !== pubKey);
|
|
258
|
+
next.push({ pubKey, clientId: clientId ?? null, pinnedAt: new Date().toISOString() });
|
|
259
|
+
return next.slice(-MAX_PINNED_PEER_KEYS);
|
|
241
260
|
}
|
|
242
261
|
|
|
243
262
|
// ─── WebSocket lifecycle ──────────────────────────────────────────────────────
|
|
244
263
|
|
|
245
264
|
// Single source of truth for the status payload shape shared by the
|
|
246
265
|
// broadcast push (webRemote:status) and the get-status IPC pull — both must
|
|
247
|
-
// report the same connected/e2e/device view of `cfg` + `_ws`/`
|
|
266
|
+
// report the same connected/e2e/device view of `cfg` + `_ws`/`_clients` module state.
|
|
248
267
|
function computeStatus(cfg) {
|
|
249
268
|
const connected = _ws !== null && _ws.readyState === WebSocket.OPEN;
|
|
269
|
+
const authed = _clients.authenticated()[0] || null;
|
|
270
|
+
const pending = _clients.values().find((r) => r.e2e.state === 'pending_sas') || null;
|
|
271
|
+
const shown = authed || pending || _clients.values()[0] || null;
|
|
272
|
+
const e2eState = shown ? shown.e2e.state : 'idle';
|
|
250
273
|
return {
|
|
251
274
|
enabled: cfg.remoteEnabled,
|
|
252
275
|
remoteControlEnabled: cfg.remoteControlEnabled ?? false,
|
|
253
276
|
connected,
|
|
254
|
-
e2eActive: connected &&
|
|
255
|
-
e2eAuthenticated: connected &&
|
|
256
|
-
e2eState: connected ?
|
|
257
|
-
pendingSas: connected &&
|
|
277
|
+
e2eActive: connected && _clients.values().some((r) => r.e2e.sessionKey !== null),
|
|
278
|
+
e2eAuthenticated: connected && authed !== null,
|
|
279
|
+
e2eState: connected ? e2eState : 'idle',
|
|
280
|
+
pendingSas: connected && !authed && pending ? pending.e2e.pendingSas : null,
|
|
281
|
+
clients: _clients.values().map((r) => ({
|
|
282
|
+
key: r.key,
|
|
283
|
+
clientId: r.clientId ?? null,
|
|
284
|
+
state: r.e2e.state,
|
|
285
|
+
pendingSas: r.e2e.state === 'pending_sas' ? r.e2e.pendingSas : null,
|
|
286
|
+
})),
|
|
258
287
|
devices: (cfg.devices || []).map(({ deviceId, deviceName, issuedAt, lastConnectedAt }) => ({
|
|
259
288
|
deviceId, deviceName, issuedAt, lastConnectedAt,
|
|
260
289
|
})),
|
|
@@ -324,7 +353,6 @@ function scheduleReconnect() {
|
|
|
324
353
|
async function disconnect() {
|
|
325
354
|
cancelReconnect();
|
|
326
355
|
stopHeartbeat();
|
|
327
|
-
stopAllRemotePtyStreams();
|
|
328
356
|
resetE2e();
|
|
329
357
|
if (_ws) {
|
|
330
358
|
const ws = _ws;
|
|
@@ -399,7 +427,7 @@ async function connect() {
|
|
|
399
427
|
broadcastStatus();
|
|
400
428
|
// v2: begin pushing the live session list once connected.
|
|
401
429
|
// Reset diff-guard so the new client always receives a full session-list push.
|
|
402
|
-
|
|
430
|
+
for (const c of _clients.values()) c.lastSessionListJson = null;
|
|
403
431
|
startSessionListPush();
|
|
404
432
|
});
|
|
405
433
|
|
|
@@ -444,7 +472,7 @@ async function connect() {
|
|
|
444
472
|
}
|
|
445
473
|
|
|
446
474
|
async function handleTokenRevoked(deviceId) {
|
|
447
|
-
|
|
475
|
+
teardownAllClients();
|
|
448
476
|
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'token revoked', meta: { deviceId } });
|
|
449
477
|
const cfg = await loadConfig();
|
|
450
478
|
const devices = (cfg.devices || []).filter((d) => d.deviceId !== deviceId);
|
|
@@ -466,24 +494,48 @@ const SESSION_INIT_TAIL_BYTES = 512 * 1024; // bound the initial read
|
|
|
466
494
|
|
|
467
495
|
const _sessionWatchers = new Map(); // tabId → watcher
|
|
468
496
|
let _sessionListTimer = null;
|
|
469
|
-
let _lastSessionListJson = null; // diff-guard: skip push when snapshot unchanged
|
|
470
497
|
|
|
471
|
-
/** Push an unsolicited event to
|
|
472
|
-
function
|
|
498
|
+
/** Push an unsolicited event to ONE client, E2E-encrypted with its key and unicast via toConn. */
|
|
499
|
+
function pushEventTo(client, type, payload) {
|
|
473
500
|
if (!_ws || _ws.readyState !== WebSocket.OPEN) return;
|
|
501
|
+
if (!client || client.e2e.state !== 'authenticated' || !client.e2e.sessionKey) return;
|
|
474
502
|
const inner = { type, id: crypto.randomUUID(), payload, ts: Date.now() };
|
|
475
503
|
try {
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
_ws.send(JSON.stringify(inner));
|
|
481
|
-
}
|
|
504
|
+
const { nonce, ciphertext } = encryptBox(JSON.stringify(inner), client.e2e.sessionKey);
|
|
505
|
+
const out = { type: 'e2e:box', id: inner.id, payload: { nonce, ciphertext }, ts: Date.now() };
|
|
506
|
+
if (client.key !== LEGACY_CLIENT_KEY) out.toConn = client.key;
|
|
507
|
+
_ws.send(JSON.stringify(out));
|
|
482
508
|
} catch (e) {
|
|
483
509
|
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'pushEvent failed', meta: { type, error: e?.message } });
|
|
484
510
|
}
|
|
485
511
|
}
|
|
486
512
|
|
|
513
|
+
/** Authenticated clients subscribed to a tab's session state/summary. */
|
|
514
|
+
function subscribersOf(tabId) {
|
|
515
|
+
return _clients.authenticated().filter((c) => c.subscriptions.has(tabId));
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** Stop every shared session watcher that no client subscribes to any more. */
|
|
519
|
+
function pruneSessionWatchers() {
|
|
520
|
+
for (const tabId of Array.from(_sessionWatchers.keys())) {
|
|
521
|
+
if (!_clients.values().some((c) => c.subscriptions.has(tabId))) stopSessionWatch(tabId);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** Tear down one client (its PTY streams + subscriptions) without touching others. */
|
|
526
|
+
function teardownClient(key) {
|
|
527
|
+
const rec = _clients.teardown(key);
|
|
528
|
+
pruneSessionWatchers();
|
|
529
|
+
return rec;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** Tear down every client — no client keeps a PTY stream or subscription afterwards. */
|
|
533
|
+
function teardownAllClients() {
|
|
534
|
+
const n = _clients.teardownAll();
|
|
535
|
+
pruneSessionWatchers();
|
|
536
|
+
return n;
|
|
537
|
+
}
|
|
538
|
+
|
|
487
539
|
/** Extract concatenated text from an assistant transcript line, or '' if none. */
|
|
488
540
|
function extractAssistantText(raw) {
|
|
489
541
|
const msg = raw?.message || raw;
|
|
@@ -579,8 +631,8 @@ async function pollSessionWatcher(w) {
|
|
|
579
631
|
if (nextState && nextState !== w.state) {
|
|
580
632
|
w.state = nextState;
|
|
581
633
|
// Guard: don't push state if SAS not yet confirmed (watcher may outlive an auth reset).
|
|
582
|
-
|
|
583
|
-
|
|
634
|
+
for (const c of subscribersOf(w.tabId)) {
|
|
635
|
+
pushEventTo(c, 'event:session:state', { tabId: w.tabId, state: w.state, since: Date.now() });
|
|
584
636
|
}
|
|
585
637
|
}
|
|
586
638
|
if (newAssistantText && newMsgId !== w.lastMsgId) {
|
|
@@ -677,8 +729,8 @@ async function pushSessionList() {
|
|
|
677
729
|
// Don't push before SAS is confirmed — session cwds/titles are sensitive user data.
|
|
678
730
|
// A relay that completes e2e:hello before the user confirms the SAS would otherwise
|
|
679
731
|
// receive the full session list immediately (same threat SAS_GATED_READS blocks for
|
|
680
|
-
// cmd:sessions:load). Guard here so
|
|
681
|
-
if (
|
|
732
|
+
// cmd:sessions:load). Guard here so client.lastSessionListJson is not poisoned either.
|
|
733
|
+
if (!anyAuthenticated()) return;
|
|
682
734
|
const sessionsStore = require('./sessionsStore.cjs');
|
|
683
735
|
const data = await sessionsStore.load();
|
|
684
736
|
// Tabs + their ACTIVE Epics → SessionMeta. tabId === claude sessionId so it
|
|
@@ -690,9 +742,11 @@ async function pushSessionList() {
|
|
|
690
742
|
}));
|
|
691
743
|
const payload = { sessions, activeTabId: data?.activeTabId ?? null };
|
|
692
744
|
const json = JSON.stringify(payload);
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
745
|
+
for (const c of _clients.authenticated()) {
|
|
746
|
+
if (json === c.lastSessionListJson) continue;
|
|
747
|
+
c.lastSessionListJson = json;
|
|
748
|
+
pushEventTo(c, 'event:session:list', payload);
|
|
749
|
+
}
|
|
696
750
|
} catch (e) {
|
|
697
751
|
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'pushSessionList failed', meta: { error: e?.message } });
|
|
698
752
|
}
|
|
@@ -717,14 +771,16 @@ const SUMMARY_MIN_CHARS = 280; // below this, push raw — not worth an API call
|
|
|
717
771
|
* completed turn per subscribed tab (~$1/$5 per 1M tokens).
|
|
718
772
|
*/
|
|
719
773
|
async function maybeSummarize(w) {
|
|
720
|
-
// Guard:
|
|
721
|
-
if (
|
|
774
|
+
// Guard: summaries (session transcript content) go only to SAS-confirmed subscribers.
|
|
775
|
+
if (subscribersOf(w.tabId).length === 0) return;
|
|
722
776
|
const text = w.lastAssistantText;
|
|
723
777
|
if (!text) return;
|
|
724
778
|
const ofMessageId = w.lastMsgId;
|
|
725
779
|
|
|
726
780
|
if (text.length < SUMMARY_MIN_CHARS) {
|
|
727
|
-
|
|
781
|
+
for (const c of subscribersOf(w.tabId)) {
|
|
782
|
+
pushEventTo(c, 'event:session:summary', { tabId: w.tabId, summary: text, ofMessageId, model: 'raw', ts: Date.now() });
|
|
783
|
+
}
|
|
728
784
|
return;
|
|
729
785
|
}
|
|
730
786
|
|
|
@@ -732,23 +788,25 @@ async function maybeSummarize(w) {
|
|
|
732
788
|
if (degraded) {
|
|
733
789
|
logs.writeLine({ scope: 'webRemote', level: 'warn', message: `summary degraded: ${degraded}`, meta: {} });
|
|
734
790
|
}
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
791
|
+
for (const c of subscribersOf(w.tabId)) {
|
|
792
|
+
pushEventTo(c, 'event:session:summary', {
|
|
793
|
+
tabId: w.tabId, summary, ofMessageId, model,
|
|
794
|
+
...(degraded ? { degraded } : {}),
|
|
795
|
+
ts: Date.now(),
|
|
796
|
+
});
|
|
797
|
+
}
|
|
740
798
|
}
|
|
741
799
|
|
|
742
800
|
/**
|
|
743
|
-
* Shared finish-up for any path that transitions
|
|
801
|
+
* Shared finish-up for any path that transitions a client's e2e state into 'authenticated'
|
|
744
802
|
* (manual SAS confirmation or pinned-key auto-trust): log, broadcast the new
|
|
745
803
|
* status, and flush the session list immediately rather than waiting for the
|
|
746
804
|
* next SESSION_LIST_PUSH_MS tick.
|
|
747
805
|
*/
|
|
748
|
-
function finalizeE2eAuthenticated(logMessage) {
|
|
806
|
+
function finalizeE2eAuthenticated(logMessage, client) {
|
|
749
807
|
logs.writeLine({ scope: 'webRemote', level: 'info', message: logMessage });
|
|
750
808
|
broadcastStatus();
|
|
751
|
-
|
|
809
|
+
if (client) client.lastSessionListJson = null;
|
|
752
810
|
pushSessionList().catch(() => {});
|
|
753
811
|
}
|
|
754
812
|
|
|
@@ -792,6 +850,22 @@ async function handleMessage(raw, device) {
|
|
|
792
850
|
return;
|
|
793
851
|
}
|
|
794
852
|
|
|
853
|
+
// Relay notice that a browser closed: tear down only that client. Never a command.
|
|
854
|
+
if (type === 'event:client:disconnected') {
|
|
855
|
+
const key = _clients.clientKeyOf({ fromConn: envelope.fromConn ?? payload?.fromConn });
|
|
856
|
+
if (key !== LEGACY_CLIENT_KEY) {
|
|
857
|
+
const reason = envelope.reason ?? payload?.reason;
|
|
858
|
+
const rec = teardownClient(key);
|
|
859
|
+
await auditLog(new Date().toISOString(), 'client:disconnected', device.deviceId, id,
|
|
860
|
+
`reason=${String(reason ?? '-').replace(/[^\w:.-]/g, '_').slice(0, 40)}`, rec || { key });
|
|
861
|
+
broadcastStatus();
|
|
862
|
+
}
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// Per-client E2E record, keyed by the relay-stamped fromConn (else 'legacy').
|
|
867
|
+
const client = _clients.getOrCreate(_clients.clientKeyOf(envelope), { clientId: envelope.fromClientId ?? null });
|
|
868
|
+
|
|
795
869
|
// ── E2E key exchange ───────────────────────────────────────────────────────
|
|
796
870
|
// Browser sends e2e:hello with its ephemeral P-256 public key (SPKI base64url).
|
|
797
871
|
// We compute the shared session key and acknowledge.
|
|
@@ -836,8 +910,8 @@ async function handleMessage(raw, device) {
|
|
|
836
910
|
}
|
|
837
911
|
} catch (e) {
|
|
838
912
|
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'e2e:hello peer key validation failed — session dropped', meta: { error: e?.message } });
|
|
839
|
-
await auditLog(new Date().toISOString(), 'e2e:hello', device.deviceId, undefined, 'error:invalid_peer_key');
|
|
840
|
-
|
|
913
|
+
await auditLog(new Date().toISOString(), 'e2e:hello', device.deviceId, undefined, 'error:invalid_peer_key', client);
|
|
914
|
+
failClient(client); // surface key-validation failure to UI — user must reconnect
|
|
841
915
|
broadcastStatus();
|
|
842
916
|
return;
|
|
843
917
|
}
|
|
@@ -849,31 +923,31 @@ async function handleMessage(raw, device) {
|
|
|
849
923
|
} catch (sasErr) {
|
|
850
924
|
// SAS derivation failed — session cannot be verified by the user.
|
|
851
925
|
// Mark failed so confirm-sas returns ok:false and the UI prompts retry.
|
|
852
|
-
|
|
926
|
+
failClient(client);
|
|
853
927
|
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'E2E SAS derivation failed — session marked failed', meta: { error: sasErr?.message } });
|
|
854
928
|
broadcastStatus();
|
|
855
929
|
return;
|
|
856
930
|
}
|
|
857
|
-
|
|
858
|
-
|
|
931
|
+
client.peerPubKey = browserPubKey;
|
|
932
|
+
client.deviceId = device.deviceId;
|
|
859
933
|
// TOFU pinning: if this exact (deviceId, browserPubKey) pair was already
|
|
860
934
|
// manually SAS-confirmed once, skip pending_sas and auto-authenticate — the
|
|
861
935
|
// user already verified this browser. A different/first-seen pubKey always
|
|
862
936
|
// falls through to the manual pending_sas flow below, unchanged.
|
|
863
|
-
if (device
|
|
864
|
-
|
|
865
|
-
finalizeE2eAuthenticated('E2E session auto-authenticated — pinned browser key matched');
|
|
866
|
-
respond(id, undefined, 'e2e:ready');
|
|
937
|
+
if (isPinnedPeerKey(device, browserPubKey)) {
|
|
938
|
+
client.e2e = makeState('authenticated', sessionKey, null);
|
|
939
|
+
finalizeE2eAuthenticated('E2E session auto-authenticated — pinned browser key matched', client);
|
|
940
|
+
respond(id, undefined, 'e2e:ready', client);
|
|
867
941
|
return;
|
|
868
942
|
}
|
|
869
|
-
|
|
943
|
+
client.e2e = makeState('pending_sas', sessionKey, pendingSas);
|
|
870
944
|
logs.writeLine({ scope: 'webRemote', level: 'info', message: 'E2E session key established — SAS pending confirmation' });
|
|
871
945
|
broadcastStatus();
|
|
872
946
|
// Acknowledge with e2e:ready (unencrypted — session just started)
|
|
873
|
-
respond(id, undefined, 'e2e:ready');
|
|
947
|
+
respond(id, undefined, 'e2e:ready', client);
|
|
874
948
|
} catch (e) {
|
|
875
949
|
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'E2E key derivation failed', meta: { error: e?.message } });
|
|
876
|
-
|
|
950
|
+
failClient(client);
|
|
877
951
|
broadcastStatus();
|
|
878
952
|
}
|
|
879
953
|
return;
|
|
@@ -881,13 +955,13 @@ async function handleMessage(raw, device) {
|
|
|
881
955
|
|
|
882
956
|
// ── Decrypt e2e:box messages ──────────────────────────────────────────────
|
|
883
957
|
if (type === 'e2e:box') {
|
|
884
|
-
if (!
|
|
958
|
+
if (!client.e2e.sessionKey) {
|
|
885
959
|
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'e2e:box received but no session key — dropping' });
|
|
886
960
|
return;
|
|
887
961
|
}
|
|
888
962
|
const { nonce, ciphertext } = payload || {};
|
|
889
963
|
if (!nonce || !ciphertext) return;
|
|
890
|
-
const plaintext = decryptBox(nonce, ciphertext,
|
|
964
|
+
const plaintext = decryptBox(nonce, ciphertext, client.e2e.sessionKey);
|
|
891
965
|
if (!plaintext) {
|
|
892
966
|
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'e2e:box decryption failed (auth tag mismatch)' });
|
|
893
967
|
return;
|
|
@@ -900,7 +974,7 @@ async function handleMessage(raw, device) {
|
|
|
900
974
|
return;
|
|
901
975
|
}
|
|
902
976
|
// Dispatch as if it arrived unencrypted
|
|
903
|
-
await dispatchEnvelope(inner, device);
|
|
977
|
+
await dispatchEnvelope(inner, device, client);
|
|
904
978
|
return;
|
|
905
979
|
}
|
|
906
980
|
|
|
@@ -908,14 +982,14 @@ async function handleMessage(raw, device) {
|
|
|
908
982
|
if (!type.startsWith('cmd:')) return;
|
|
909
983
|
// After E2E is established, reject plaintext commands — a malicious relay cannot
|
|
910
984
|
// silently downgrade the session by stripping e2e:hello (PENTEST.md §H1).
|
|
911
|
-
if (
|
|
985
|
+
if (client.e2e.sessionKey) {
|
|
912
986
|
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'plaintext cmd rejected — e2e session active' });
|
|
913
987
|
return;
|
|
914
988
|
}
|
|
915
|
-
await dispatchEnvelope(envelope, device);
|
|
989
|
+
await dispatchEnvelope(envelope, device, client);
|
|
916
990
|
}
|
|
917
991
|
|
|
918
|
-
async function dispatchEnvelope(envelope, device) {
|
|
992
|
+
async function dispatchEnvelope(envelope, device, client) {
|
|
919
993
|
const { type, id, payload } = envelope;
|
|
920
994
|
if (typeof type !== 'string' || !type.startsWith('cmd:')) return;
|
|
921
995
|
|
|
@@ -924,22 +998,22 @@ async function dispatchEnvelope(envelope, device) {
|
|
|
924
998
|
// Kill switch — re-reads config with 1s TTL
|
|
925
999
|
const cfg = await loadConfig();
|
|
926
1000
|
if (!cfg.remoteEnabled) {
|
|
927
|
-
await auditLog(ts, type, device.deviceId, id, 'error:disabled');
|
|
928
|
-
respond(id, { error: 'rejected' }); // opaque — reason stays in audit log only
|
|
1001
|
+
await auditLog(ts, type, device.deviceId, id, 'error:disabled', client);
|
|
1002
|
+
respond(id, { error: 'rejected' }, undefined, client); // opaque — reason stays in audit log only
|
|
929
1003
|
return;
|
|
930
1004
|
}
|
|
931
1005
|
|
|
932
1006
|
// Allowlist check — reject unknown cmd:* types with opaque error (oracle prevention)
|
|
933
1007
|
if (!ALLOWED_COMMANDS.has(type)) {
|
|
934
|
-
await auditLog(ts, type, device.deviceId, id, 'error:not_allowed');
|
|
935
|
-
respond(id, { error: 'rejected' });
|
|
1008
|
+
await auditLog(ts, type, device.deviceId, id, 'error:not_allowed', client);
|
|
1009
|
+
respond(id, { error: 'rejected' }, undefined, client);
|
|
936
1010
|
return;
|
|
937
1011
|
}
|
|
938
1012
|
|
|
939
1013
|
// MUTATE tier gate — write/exec commands require remoteControlEnabled=true (default false)
|
|
940
1014
|
if (MUTATE_COMMANDS.has(type) && !cfg.remoteControlEnabled) {
|
|
941
|
-
await auditLog(ts, type, device.deviceId, id, 'error:not_allowed');
|
|
942
|
-
respond(id, { error: 'rejected' }); // opaque — reason stays in audit log only
|
|
1015
|
+
await auditLog(ts, type, device.deviceId, id, 'error:not_allowed', client);
|
|
1016
|
+
respond(id, { error: 'rejected' }, undefined, client); // opaque — reason stays in audit log only
|
|
943
1017
|
return;
|
|
944
1018
|
}
|
|
945
1019
|
|
|
@@ -947,27 +1021,27 @@ async function dispatchEnvelope(envelope, device) {
|
|
|
947
1021
|
// user confirms the SAS on the desktop. This prevents a compromised relay
|
|
948
1022
|
// from exfiltrating session lists, PRDs, run logs, or transcript summaries
|
|
949
1023
|
// by completing the ECDH handshake without the user's knowledge.
|
|
950
|
-
if ((MUTATE_COMMANDS.has(type) || SAS_GATED_READS.has(type)) &&
|
|
951
|
-
await auditLog(ts, type, device.deviceId, id, 'error:e2e_not_authenticated');
|
|
952
|
-
respond(id, { error: 'rejected' }); // opaque — reason stays in audit log only
|
|
1024
|
+
if ((MUTATE_COMMANDS.has(type) || SAS_GATED_READS.has(type)) && client.e2e.state !== 'authenticated') {
|
|
1025
|
+
await auditLog(ts, type, device.deviceId, id, 'error:e2e_not_authenticated', client);
|
|
1026
|
+
respond(id, { error: 'rejected' }, undefined, client); // opaque — reason stays in audit log only
|
|
953
1027
|
return;
|
|
954
1028
|
}
|
|
955
1029
|
|
|
956
1030
|
// Dispatch
|
|
957
1031
|
let result;
|
|
958
1032
|
try {
|
|
959
|
-
result = await dispatchCommand(type, payload ?? {});
|
|
960
|
-
await auditLog(ts, type, device.deviceId, id, 'ok');
|
|
1033
|
+
result = await dispatchCommand(type, payload ?? {}, client);
|
|
1034
|
+
await auditLog(ts, type, device.deviceId, id, 'ok', client);
|
|
961
1035
|
} catch (e) {
|
|
962
1036
|
const code = e?.name === 'ZodError' ? 'schema_invalid' : 'dispatch_error';
|
|
963
|
-
await auditLog(ts, type, device.deviceId, id, `error:${code}
|
|
1037
|
+
await auditLog(ts, type, device.deviceId, id, `error:${code}`, client);
|
|
964
1038
|
result = { error: 'rejected' }; // opaque on-wire — reason stays in audit log only
|
|
965
1039
|
}
|
|
966
1040
|
|
|
967
|
-
respond(id, result);
|
|
1041
|
+
respond(id, result, undefined, client);
|
|
968
1042
|
}
|
|
969
1043
|
|
|
970
|
-
function respond(msgId, payload, typeOverride) {
|
|
1044
|
+
function respond(msgId, payload, typeOverride, client) {
|
|
971
1045
|
if (!_ws || _ws.readyState !== WebSocket.OPEN) return;
|
|
972
1046
|
const responseType = typeOverride || (msgId ? `resp:${msgId}` : undefined);
|
|
973
1047
|
if (!responseType) return;
|
|
@@ -980,23 +1054,28 @@ function respond(msgId, payload, typeOverride) {
|
|
|
980
1054
|
};
|
|
981
1055
|
|
|
982
1056
|
try {
|
|
983
|
-
// Encrypt the
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
} else {
|
|
993
|
-
_ws.send(JSON.stringify(inner));
|
|
994
|
-
}
|
|
1057
|
+
// Encrypt with the requesting client's key; unicast via toConn for relay-stamped clients.
|
|
1058
|
+
const out = client && client.e2e.sessionKey && !typeOverride
|
|
1059
|
+
? (() => {
|
|
1060
|
+
const { nonce, ciphertext } = encryptBox(JSON.stringify(inner), client.e2e.sessionKey);
|
|
1061
|
+
return { type: 'e2e:box', id: msgId, payload: { nonce, ciphertext }, ts: Date.now() };
|
|
1062
|
+
})()
|
|
1063
|
+
: inner;
|
|
1064
|
+
if (client && client.key !== LEGACY_CLIENT_KEY) out.toConn = client.key;
|
|
1065
|
+
_ws.send(JSON.stringify(out));
|
|
995
1066
|
} catch (e) {
|
|
996
1067
|
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'respond send failed', meta: { error: e?.message } });
|
|
997
1068
|
}
|
|
998
1069
|
}
|
|
999
1070
|
|
|
1071
|
+
function stopClientPtyStream(client, tabId) {
|
|
1072
|
+
const unsub = client.ptyStreams.get(tabId);
|
|
1073
|
+
client.ptyStreams.delete(tabId);
|
|
1074
|
+
if (unsub) {
|
|
1075
|
+
try { unsub(); } catch { /* listener already gone */ }
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1000
1079
|
// Lazy-loaded dispatch map — avoids circular require at module load time.
|
|
1001
1080
|
let _dispatchMap = null;
|
|
1002
1081
|
|
|
@@ -1012,12 +1091,22 @@ function getDispatchMap() {
|
|
|
1012
1091
|
'cmd:sessions:load': async () =>
|
|
1013
1092
|
sessionsStore.load(),
|
|
1014
1093
|
|
|
1094
|
+
'cmd:sessions:list': async () => {
|
|
1095
|
+
const { readActiveIndex } = require('./lib/epicMint.cjs');
|
|
1096
|
+
const data = await sessionsStore.load();
|
|
1097
|
+
return {
|
|
1098
|
+
sessions: buildRemoteSessionList(data?.tabs ?? [], readActiveIndex),
|
|
1099
|
+
activeTabId: data?.activeTabId ?? null,
|
|
1100
|
+
};
|
|
1101
|
+
},
|
|
1102
|
+
|
|
1015
1103
|
'cmd:sessions:save': async (payload) => {
|
|
1016
1104
|
const parsed = schemas.sessionsPayload.parse(payload);
|
|
1017
1105
|
return sessionsStore.save(parsed);
|
|
1018
1106
|
},
|
|
1019
1107
|
|
|
1020
|
-
'cmd:pty:spawn': async (payload) => {
|
|
1108
|
+
'cmd:pty:spawn': async (payload, reqClient) => {
|
|
1109
|
+
const client = reqClient || _clients.getOrCreate(LEGACY_CLIENT_KEY);
|
|
1021
1110
|
const parsed = schemas.ptySpawn.parse(payload);
|
|
1022
1111
|
// Path safety — validatePath rejects anything outside home dir.
|
|
1023
1112
|
validatePath(parsed.cwd);
|
|
@@ -1029,20 +1118,20 @@ function getDispatchMap() {
|
|
|
1029
1118
|
// Stream output to the peer — replaces any prior subscription for this tab.
|
|
1030
1119
|
// Chunk contents are sensitive: never logged.
|
|
1031
1120
|
const tabId = safePayload.tabId;
|
|
1032
|
-
|
|
1121
|
+
stopClientPtyStream(client, tabId);
|
|
1033
1122
|
const unsub = ptyManager.addOutputListener(
|
|
1034
1123
|
tabId,
|
|
1035
1124
|
(_id, data) => {
|
|
1036
|
-
if (
|
|
1037
|
-
|
|
1125
|
+
if (client.e2e.state !== 'authenticated' || !loadConfigSync().remoteEnabled) return;
|
|
1126
|
+
pushEventTo(client, 'event:pty:data', { tabId, data });
|
|
1038
1127
|
},
|
|
1039
1128
|
(_id, info) => {
|
|
1040
|
-
if (
|
|
1041
|
-
if (
|
|
1042
|
-
|
|
1129
|
+
if (client.ptyStreams.get(tabId) === unsub) client.ptyStreams.delete(tabId);
|
|
1130
|
+
if (client.e2e.state !== 'authenticated' || !loadConfigSync().remoteEnabled) return;
|
|
1131
|
+
pushEventTo(client, 'event:pty:exit', { tabId, exitCode: info?.exitCode, signal: info?.signal });
|
|
1043
1132
|
},
|
|
1044
1133
|
);
|
|
1045
|
-
|
|
1134
|
+
client.ptyStreams.set(tabId, unsub);
|
|
1046
1135
|
return result;
|
|
1047
1136
|
},
|
|
1048
1137
|
|
|
@@ -1057,9 +1146,11 @@ function getDispatchMap() {
|
|
|
1057
1146
|
return { ok: true };
|
|
1058
1147
|
},
|
|
1059
1148
|
|
|
1060
|
-
'cmd:pty:kill': async (payload) => {
|
|
1149
|
+
'cmd:pty:kill': async (payload, reqClient) => {
|
|
1061
1150
|
const parsed = schemas.ptyTabId.parse(payload);
|
|
1062
|
-
|
|
1151
|
+
// Kill ends the PTY itself, so every client's stream for it ends.
|
|
1152
|
+
for (const c of _clients.values()) stopClientPtyStream(c, parsed.tabId);
|
|
1153
|
+
void reqClient;
|
|
1063
1154
|
ptyManager.kill(parsed.tabId);
|
|
1064
1155
|
return { ok: true };
|
|
1065
1156
|
},
|
|
@@ -1072,17 +1163,24 @@ function getDispatchMap() {
|
|
|
1072
1163
|
'cmd:app:version': async () =>
|
|
1073
1164
|
app.getVersion(),
|
|
1074
1165
|
|
|
1166
|
+
'cmd:app:info': async () =>
|
|
1167
|
+
({ version: app.getVersion(), platform: process.platform }),
|
|
1168
|
+
|
|
1075
1169
|
// v2 mobile: start/stop pushing live state + summary for a session.
|
|
1076
|
-
'cmd:session:subscribe': async (payload) => {
|
|
1170
|
+
'cmd:session:subscribe': async (payload, reqClient) => {
|
|
1171
|
+
const client = reqClient || _clients.getOrCreate(LEGACY_CLIENT_KEY);
|
|
1077
1172
|
const parsed = schemas.sessionSubscribe.parse(payload);
|
|
1078
1173
|
validatePath(parsed.cwd); // home-dir boundary before any fs access
|
|
1079
1174
|
startSessionWatch(parsed.tabId, parsed.cwd);
|
|
1175
|
+
client.subscriptions.add(parsed.tabId);
|
|
1080
1176
|
return { ok: true };
|
|
1081
1177
|
},
|
|
1082
1178
|
|
|
1083
|
-
'cmd:session:unsubscribe': async (payload) => {
|
|
1179
|
+
'cmd:session:unsubscribe': async (payload, reqClient) => {
|
|
1180
|
+
const client = reqClient || _clients.getOrCreate(LEGACY_CLIENT_KEY);
|
|
1084
1181
|
const parsed = schemas.ptyTabId.parse(payload);
|
|
1085
|
-
|
|
1182
|
+
client.subscriptions.delete(parsed.tabId);
|
|
1183
|
+
pruneSessionWatchers();
|
|
1086
1184
|
return { ok: true };
|
|
1087
1185
|
},
|
|
1088
1186
|
|
|
@@ -1094,16 +1192,43 @@ function getDispatchMap() {
|
|
|
1094
1192
|
chatRunner.enqueueExternalPrompt(parsed.tabId, parsed.prompt);
|
|
1095
1193
|
return { ok: true };
|
|
1096
1194
|
},
|
|
1195
|
+
|
|
1196
|
+
'cmd:session:options': async () => {
|
|
1197
|
+
const { EpicTagSchema } = require('./lib/promptSessionSchema.cjs');
|
|
1198
|
+
const agentLibrary = require('./agentLibrary.cjs');
|
|
1199
|
+
const macroLibrary = require('./lib/macroLibrary.cjs');
|
|
1200
|
+
const { tabs } = await sessionsStore.load();
|
|
1201
|
+
const personas = await agentLibrary.listPersonas();
|
|
1202
|
+
return {
|
|
1203
|
+
projects: tabs.map((t) => ({ cwd: t.cwd, title: t.label })),
|
|
1204
|
+
agents: personas.map((a) => ({ name: a.name, description: a.description })),
|
|
1205
|
+
tags: EpicTagSchema.options,
|
|
1206
|
+
macros: await macroLibrary.listMacros(),
|
|
1207
|
+
};
|
|
1208
|
+
},
|
|
1209
|
+
|
|
1210
|
+
// Input boundary: cwd must be exactly an open tab's cwd and agentName a
|
|
1211
|
+
// listed persona, checked before the payload goes anywhere near the fs.
|
|
1212
|
+
'cmd:session:create': async (payload) => {
|
|
1213
|
+
const parsed = schemas.remoteSessionCreate.parse(payload);
|
|
1214
|
+
const agentLibrary = require('./agentLibrary.cjs');
|
|
1215
|
+
const { tabs } = await sessionsStore.load();
|
|
1216
|
+
if (!tabs.some((t) => t.cwd === parsed.cwd)) throw new Error('cwd is not an open project');
|
|
1217
|
+
const personas = await agentLibrary.listPersonas();
|
|
1218
|
+
if (!personas.some((a) => a.name === parsed.agentName)) throw new Error('unknown agent');
|
|
1219
|
+
chatRunner.requestExternalNewSession(parsed);
|
|
1220
|
+
return { ok: true };
|
|
1221
|
+
},
|
|
1097
1222
|
};
|
|
1098
1223
|
|
|
1099
1224
|
return _dispatchMap;
|
|
1100
1225
|
}
|
|
1101
1226
|
|
|
1102
|
-
async function dispatchCommand(type, payload) {
|
|
1227
|
+
async function dispatchCommand(type, payload, client) {
|
|
1103
1228
|
const map = getDispatchMap();
|
|
1104
1229
|
const handler = map[type];
|
|
1105
1230
|
if (!handler) throw new Error(`no handler for ${type}`);
|
|
1106
|
-
return handler(payload);
|
|
1231
|
+
return handler(payload, client);
|
|
1107
1232
|
}
|
|
1108
1233
|
|
|
1109
1234
|
// ─── Pairing ─────────────────────────────────────────────────────────────────
|
|
@@ -1149,6 +1274,7 @@ async function pair(otp) {
|
|
|
1149
1274
|
// Set once a browser's SAS is manually confirmed; TOFU-pinned for auto-trust
|
|
1150
1275
|
// on future reconnects from the same (deviceId, browserPubKey) pair.
|
|
1151
1276
|
verifiedPeerPubKey: null,
|
|
1277
|
+
verifiedPeerPubKeys: [],
|
|
1152
1278
|
}];
|
|
1153
1279
|
|
|
1154
1280
|
await saveConfig({ ...cfg, devices });
|
|
@@ -1162,7 +1288,7 @@ async function pair(otp) {
|
|
|
1162
1288
|
}
|
|
1163
1289
|
|
|
1164
1290
|
async function revokeDevice(deviceId) {
|
|
1165
|
-
|
|
1291
|
+
teardownAllClients();
|
|
1166
1292
|
invalidateConfigCache();
|
|
1167
1293
|
const cfg = await loadConfig();
|
|
1168
1294
|
const devices = (cfg.devices || []).filter((d) => d.deviceId !== deviceId);
|
|
@@ -1187,7 +1313,7 @@ async function revokeDevice(deviceId) {
|
|
|
1187
1313
|
* This is the local-side "kill everything" action (ADR §4.1).
|
|
1188
1314
|
*/
|
|
1189
1315
|
async function revokeAllDevices() {
|
|
1190
|
-
|
|
1316
|
+
teardownAllClients();
|
|
1191
1317
|
const cfg = await loadConfig();
|
|
1192
1318
|
const revokedCount = (cfg.devices || []).length;
|
|
1193
1319
|
await saveConfig({ ...cfg, remoteEnabled: false, devices: [] });
|
|
@@ -1211,20 +1337,34 @@ function registerRemoteHandlers() {
|
|
|
1211
1337
|
// User confirmed that the SAS shown on both desktop and browser match.
|
|
1212
1338
|
// Returns ok:false if the session is not in the pending_sas state (e.g. key
|
|
1213
1339
|
// missing, already failed, already authenticated, or reconnected).
|
|
1214
|
-
ipcMain.handle('webRemote:confirm-sas', async () => {
|
|
1215
|
-
const
|
|
1340
|
+
ipcMain.handle('webRemote:confirm-sas', validated(schemas.webRemoteConfirmSas, async (arg) => {
|
|
1341
|
+
const clientKey = arg?.clientKey;
|
|
1342
|
+
let target;
|
|
1343
|
+
if (clientKey !== undefined) {
|
|
1344
|
+
target = _clients.get(clientKey) || makeClientlessState();
|
|
1345
|
+
} else {
|
|
1346
|
+
const pending = pendingClients();
|
|
1347
|
+
if (pending.length > 1) {
|
|
1348
|
+
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'confirm-sas rejected — multiple clients pending', meta: { pending: pending.length } });
|
|
1349
|
+
return { ok: false, error: 'ambiguous_pending_sas' };
|
|
1350
|
+
}
|
|
1351
|
+
target = pending[0] || _clients.get(LEGACY_CLIENT_KEY) || makeClientlessState();
|
|
1352
|
+
}
|
|
1353
|
+
const { ok, error, next } = confirmSasLogic(target.e2e);
|
|
1216
1354
|
if (!ok) {
|
|
1217
|
-
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'confirm-sas rejected — wrong state', meta: { state:
|
|
1355
|
+
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'confirm-sas rejected — wrong state', meta: { state: target.e2e.state, error } });
|
|
1218
1356
|
return { ok: false, error };
|
|
1219
1357
|
}
|
|
1220
|
-
|
|
1358
|
+
target.e2e = next;
|
|
1221
1359
|
// Pin this browser's pubkey to the device so future reconnects with the same
|
|
1222
|
-
// key auto-authenticate (see e2e:hello above)
|
|
1223
|
-
if (
|
|
1360
|
+
// key auto-authenticate (see e2e:hello above). Appends; other clients' pins stay.
|
|
1361
|
+
if (target.peerPubKey && target.deviceId) {
|
|
1224
1362
|
try {
|
|
1225
1363
|
const cfg = await loadConfig();
|
|
1226
1364
|
const devices = (cfg.devices || []).map((d) =>
|
|
1227
|
-
d.deviceId ===
|
|
1365
|
+
d.deviceId === target.deviceId
|
|
1366
|
+
? { ...d, verifiedPeerPubKeys: addPinnedPeerKey(d, target.peerPubKey, target.clientId) }
|
|
1367
|
+
: d
|
|
1228
1368
|
);
|
|
1229
1369
|
await saveConfig({ ...cfg, devices });
|
|
1230
1370
|
} catch (e) {
|
|
@@ -1234,9 +1374,9 @@ function registerRemoteHandlers() {
|
|
|
1234
1374
|
// Flush the session list immediately — the push loop was suppressed while
|
|
1235
1375
|
// state !== 'authenticated', so the mobile app would otherwise wait up to
|
|
1236
1376
|
// SESSION_LIST_PUSH_MS for the first useful data.
|
|
1237
|
-
finalizeE2eAuthenticated('E2E session authenticated — SAS confirmed by user');
|
|
1377
|
+
finalizeE2eAuthenticated('E2E session authenticated — SAS confirmed by user', target);
|
|
1238
1378
|
return { ok: true };
|
|
1239
|
-
});
|
|
1379
|
+
}));
|
|
1240
1380
|
|
|
1241
1381
|
ipcMain.handle('webRemote:enable', async () => {
|
|
1242
1382
|
const cfg = await loadConfig();
|
|
@@ -1264,6 +1404,7 @@ function registerRemoteHandlers() {
|
|
|
1264
1404
|
ipcMain.handle('webRemote:disable-control', async () => {
|
|
1265
1405
|
const cfg = await loadConfig();
|
|
1266
1406
|
await saveConfig({ ...cfg, remoteControlEnabled: false });
|
|
1407
|
+
teardownAllClients(); // no client keeps a PTY stream once control is off
|
|
1267
1408
|
broadcastStatus();
|
|
1268
1409
|
return { ok: true };
|
|
1269
1410
|
});
|
|
@@ -1333,14 +1474,17 @@ module.exports = {
|
|
|
1333
1474
|
_internal: {
|
|
1334
1475
|
handleMessage,
|
|
1335
1476
|
resetE2e,
|
|
1336
|
-
getE2eState: () =>
|
|
1477
|
+
getE2eState: (clientKey = LEGACY_CLIENT_KEY) => (_clients.get(clientKey) || { e2e: makeState() }).e2e,
|
|
1337
1478
|
},
|
|
1338
1479
|
__test: {
|
|
1339
1480
|
getDispatchMap,
|
|
1340
1481
|
buildRemoteSessionList,
|
|
1341
1482
|
setWs: (ws) => { _ws = ws; },
|
|
1342
|
-
|
|
1483
|
+
invalidateConfigCache,
|
|
1484
|
+
setE2eAuthenticated: (key, clientKey = LEGACY_CLIENT_KEY) => {
|
|
1485
|
+
_clients.getOrCreate(clientKey).e2e = makeState('authenticated', key, null);
|
|
1486
|
+
},
|
|
1343
1487
|
revokeAllDevices,
|
|
1344
|
-
streamCount: () =>
|
|
1488
|
+
streamCount: () => _clients.values().reduce((n, c) => n + c.ptyStreams.size, 0),
|
|
1345
1489
|
},
|
|
1346
1490
|
};
|