claude-code-session-manager 0.108.0 → 0.110.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-Cm6MrOnF.js +2 -0
- package/dist/assets/{Button-DC6VzovQ.js → Button-CGVncJ6a.js} +1 -1
- package/dist/assets/DataModel-C3hlMoa8.js +1 -0
- package/dist/assets/History-CTyfGNKn.js +2 -0
- package/dist/assets/Hooks-BXxCBibx.js +3 -0
- package/dist/assets/{Library-DLmnlfQt.js → Library-aCs7zxAg.js} +24 -24
- package/dist/assets/{ListDetail-YylbvUj_.js → ListDetail-B9Hqk1_f.js} +1 -1
- package/dist/assets/{MarkdownEditor-DMPKWHTF.js → MarkdownEditor-Djfgp32k.js} +1 -1
- package/dist/assets/{McpServers-Da-Ty5nc.js → McpServers-8HQC6QgR.js} +2 -2
- package/dist/assets/{Memory-C3rUQ_cg.js → Memory-c4SVE3l2.js} +5 -5
- package/dist/assets/{Panel-CC3JTnOI.js → Panel-BEQTwE4z.js} +1 -1
- package/dist/assets/Permissions-BaYzgZtc.js +3 -0
- package/dist/assets/Plugins-DnXsVau7.js +2 -0
- package/dist/assets/ProvenanceBadge-CT2vWps1.js +1 -0
- package/dist/assets/RemoteMachines-DHJZfdJC.js +3 -0
- package/dist/assets/{SaveBar-C06rFT34.js → SaveBar-DwcHt3iR.js} +1 -1
- package/dist/assets/Scheduler-D6Gcuo0M.js +16 -0
- package/dist/assets/{ScopeSwitcher-Cp5mxblN.js → ScopeSwitcher-CgvWDc9Y.js} +1 -1
- package/dist/assets/Settings-CJoogVAB.js +4 -0
- package/dist/assets/{SkillReferenceGraph-DuWRmT_f.js → SkillReferenceGraph-4fq01kxq.js} +14 -14
- package/dist/assets/Skills-BU4ZX4h4.js +2 -0
- package/dist/assets/SystemPrompt-BCEyMSEq.js +1 -0
- package/dist/assets/TagLibrary-m3It8Oal.js +1 -0
- package/dist/assets/{TiptapBody-1r4ODLJ7.js → TiptapBody-DVyBsfsT.js} +1 -1
- package/dist/assets/{Toggle-BGT68XHP.js → Toggle-In_AExPV.js} +1 -1
- package/dist/assets/{ViewTabs-T0NBc8uv.js → ViewTabs-BLHjKUv-.js} +1 -1
- package/dist/assets/historyTotals-BXDd3EzP.js +1 -0
- package/dist/assets/index-CuiU3I-8.js +3077 -0
- package/dist/assets/{index-tr1w3hHB.css → index-Dutcsly-.css} +1 -1
- package/dist/assets/listSkills-C1aWKf0T.js +1 -0
- package/dist/assets/{settingsSchema-CvoNk3Ye.js → settingsSchema-DD4bc5cT.js} +1 -1
- package/dist/assets/useAgentsDirWatch-DMQ_XZtr.js +1 -0
- package/dist/assets/useRemotePeers-C9NfteSD.js +1 -0
- package/dist/index.html +2 -2
- package/package.json +6 -2
- package/plugins/session-manager-dev/skills/develop/SKILL.md +3 -1
- package/plugins/session-manager-dev/skills/develop/standards.md +5 -0
- package/plugins/session-manager-dev/skills/send-feedback/SKILL.md +48 -15
- package/scripts/README.md +7 -0
- package/scripts/scheduler-mcp-server.cjs +29 -2
- package/src/main/agentLibrary.cjs +20 -9
- package/src/main/build-info.json +4 -4
- package/src/main/chatRunner.cjs +7 -0
- package/src/main/config.cjs +20 -10
- package/src/main/historyAggregator.cjs +46 -3
- package/src/main/index.cjs +8 -0
- package/src/main/ipcSchemas.cjs +30 -0
- package/src/main/lib/autoUpdater.cjs +190 -0
- 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/remoteE2eCrypto.cjs +109 -0
- package/src/main/lib/updaterSettings.cjs +64 -0
- package/src/main/pty.cjs +53 -0
- package/src/main/remoteClient.cjs +334 -0
- package/src/main/remoteClientIpc.cjs +133 -0
- package/src/main/scheduler.cjs +16 -0
- package/src/main/updaterIpc.cjs +110 -0
- package/src/main/webRemote.cjs +341 -215
- package/src/preload/api.d.ts +97 -7
- package/src/preload/index.cjs +51 -3
- package/dist/assets/AgentLibrary-Co54GThg.js +0 -2
- package/dist/assets/DataModel-DZ0vyJau.js +0 -1
- package/dist/assets/History-Df3YwpPe.js +0 -2
- package/dist/assets/Hooks-DplG6roH.js +0 -3
- package/dist/assets/Permissions-XvBiMj1-.js +0 -3
- package/dist/assets/Plugins-CzHY1mgS.js +0 -2
- package/dist/assets/ProvenanceBadge-DaJIWVcg.js +0 -1
- package/dist/assets/Scheduler-BehadU-0.js +0 -16
- package/dist/assets/Settings-Cg9LIni0.js +0 -4
- package/dist/assets/Skills-CMmCZfIv.js +0 -3
- package/dist/assets/SystemPrompt-D_-MV025.js +0 -1
- package/dist/assets/TagLibrary-D9Tv3yOJ.js +0 -1
- package/dist/assets/index-3_AtCAyd.js +0 -3077
- package/dist/assets/listSkills-QORduPIk.js +0 -1
- package/dist/assets/useAgentsDirWatch-1C2CBBya.js +0 -1
- package/dist/assets/useConfigDirWatch-Cd5PnTkB.js +0 -1
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
|
|
|
@@ -68,107 +69,8 @@ const MSG_MAX_BYTES = 256 * 1024;
|
|
|
68
69
|
const { ALLOWED_COMMANDS, MUTATE_COMMANDS, SAS_GATED_READS } = require('./ipcSchemas.cjs');
|
|
69
70
|
|
|
70
71
|
// ─── E2E encryption helpers (P-256 ECDH + AES-256-GCM, ADR §5.2) ────────────
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
* Generate a P-256 ECDH keypair. Public key is SPKI DER base64url; private key
|
|
74
|
-
* is PKCS8 DER base64url. Both are stored in web-remote.json at 0600.
|
|
75
|
-
*/
|
|
76
|
-
function generateE2EKeyPair() {
|
|
77
|
-
const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', {
|
|
78
|
-
namedCurve: 'P-256',
|
|
79
|
-
publicKeyEncoding: { type: 'spki', format: 'der' },
|
|
80
|
-
privateKeyEncoding: { type: 'pkcs8', format: 'der' },
|
|
81
|
-
});
|
|
82
|
-
return {
|
|
83
|
-
e2ePrivateKey: privateKey.toString('base64url'),
|
|
84
|
-
e2ePublicKey: publicKey.toString('base64url'),
|
|
85
|
-
};
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Derive an AES-256-GCM session key from two P-256 public keys via ECDH + HKDF.
|
|
90
|
-
* @param myPrivateKeyB64 Agent's PKCS8 private key, base64url DER
|
|
91
|
-
* @param peerPublicKeyB64 Browser's SPKI public key, base64url DER
|
|
92
|
-
* @param deviceId Used as HKDF salt for domain separation
|
|
93
|
-
* @returns 32-byte Buffer (AES-256-GCM key)
|
|
94
|
-
*/
|
|
95
|
-
function deriveSessionKey(myPrivateKeyB64, peerPublicKeyB64, deviceId) {
|
|
96
|
-
const myPrivKey = crypto.createPrivateKey({
|
|
97
|
-
key: Buffer.from(myPrivateKeyB64, 'base64url'),
|
|
98
|
-
format: 'der',
|
|
99
|
-
type: 'pkcs8',
|
|
100
|
-
});
|
|
101
|
-
const peerPubKey = crypto.createPublicKey({
|
|
102
|
-
key: Buffer.from(peerPublicKeyB64, 'base64url'),
|
|
103
|
-
format: 'der',
|
|
104
|
-
type: 'spki',
|
|
105
|
-
});
|
|
106
|
-
const sharedSecret = crypto.diffieHellman({ privateKey: myPrivKey, publicKey: peerPubKey });
|
|
107
|
-
// HKDF: salt = deviceId bytes for domain separation, info = fixed protocol label.
|
|
108
|
-
const salt = Buffer.from(deviceId, 'utf8');
|
|
109
|
-
const info = Buffer.from('sm-e2e-v1', 'utf8');
|
|
110
|
-
return Buffer.from(crypto.hkdfSync('sha256', sharedSecret, salt, info, 32));
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Derive a 6-digit Short Authentication String from the ECDH shared secret.
|
|
115
|
-
* Uses a separate HKDF info label ('sm-sas-v1') so the SAS is independent of
|
|
116
|
-
* the session key. Both sides compute the same value; user confirms they match.
|
|
117
|
-
*/
|
|
118
|
-
function deriveSas(myPrivateKeyB64, peerPublicKeyB64, deviceId) {
|
|
119
|
-
const myPrivKey = crypto.createPrivateKey({
|
|
120
|
-
key: Buffer.from(myPrivateKeyB64, 'base64url'),
|
|
121
|
-
format: 'der',
|
|
122
|
-
type: 'pkcs8',
|
|
123
|
-
});
|
|
124
|
-
const peerPubKey = crypto.createPublicKey({
|
|
125
|
-
key: Buffer.from(peerPublicKeyB64, 'base64url'),
|
|
126
|
-
format: 'der',
|
|
127
|
-
type: 'spki',
|
|
128
|
-
});
|
|
129
|
-
const sharedSecret = crypto.diffieHellman({ privateKey: myPrivKey, publicKey: peerPubKey });
|
|
130
|
-
const salt = Buffer.from(deviceId, 'utf8');
|
|
131
|
-
const info = Buffer.from('sm-sas-v1', 'utf8');
|
|
132
|
-
const sasBytes = Buffer.from(crypto.hkdfSync('sha256', sharedSecret, salt, info, 3));
|
|
133
|
-
const sasNum = ((sasBytes[0] << 16) | (sasBytes[1] << 8) | sasBytes[2]) % 1_000_000;
|
|
134
|
-
return sasNum.toString().padStart(6, '0');
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Encrypt a plaintext JSON string into an AES-256-GCM box.
|
|
139
|
-
* @returns { nonce: base64url, ciphertext: base64url } — the nonce is 12 random bytes
|
|
140
|
-
*/
|
|
141
|
-
function encryptBox(plaintext, sessionKey) {
|
|
142
|
-
const nonce = crypto.randomBytes(12);
|
|
143
|
-
const cipher = crypto.createCipheriv('aes-256-gcm', sessionKey, nonce);
|
|
144
|
-
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
145
|
-
const tag = cipher.getAuthTag(); // 16-byte GCM authentication tag
|
|
146
|
-
return {
|
|
147
|
-
nonce: nonce.toString('base64url'),
|
|
148
|
-
// Append the GCM tag to the ciphertext so decryptBox can locate it.
|
|
149
|
-
ciphertext: Buffer.concat([encrypted, tag]).toString('base64url'),
|
|
150
|
-
};
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* Decrypt an AES-256-GCM box produced by encryptBox (or the browser's equivalent).
|
|
155
|
-
* @returns Decrypted UTF-8 string, or null if authentication fails.
|
|
156
|
-
*/
|
|
157
|
-
function decryptBox(nonceB64, ciphertextB64, sessionKey) {
|
|
158
|
-
try {
|
|
159
|
-
const nonce = Buffer.from(nonceB64, 'base64url');
|
|
160
|
-
const data = Buffer.from(ciphertextB64, 'base64url');
|
|
161
|
-
if (data.length < 16) return null; // too short to contain tag
|
|
162
|
-
const tag = data.subarray(data.length - 16);
|
|
163
|
-
const ciphertext = data.subarray(0, data.length - 16);
|
|
164
|
-
const decipher = crypto.createDecipheriv('aes-256-gcm', sessionKey, nonce);
|
|
165
|
-
decipher.setAuthTag(tag);
|
|
166
|
-
const plain = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
|
|
167
|
-
return plain;
|
|
168
|
-
} catch {
|
|
169
|
-
return null; // authentication failed — drop the message
|
|
170
|
-
}
|
|
171
|
-
}
|
|
72
|
+
// Implemented in ./lib/remoteE2eCrypto.cjs (shared with the desktop client role).
|
|
73
|
+
const { generateE2EKeyPair, deriveSessionKey, deriveSas, encryptBox, decryptBox } = require('./lib/remoteE2eCrypto.cjs');
|
|
172
74
|
|
|
173
75
|
// ─── Module state ────────────────────────────────────────────────────────────
|
|
174
76
|
|
|
@@ -185,12 +87,10 @@ let _destroyed = false; // set at app shutdown to stop reconnect loops
|
|
|
185
87
|
|
|
186
88
|
// E2E session state — reset on each new WS connection.
|
|
187
89
|
// .state: 'idle' | 'pending_sas' | 'authenticated' | 'failed'
|
|
188
|
-
|
|
189
|
-
//
|
|
190
|
-
// deviceId it
|
|
191
|
-
|
|
192
|
-
let _e2ePeerPubKey = null;
|
|
193
|
-
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();
|
|
194
94
|
|
|
195
95
|
// ─── Config helpers ───────────────────────────────────────────────────────────
|
|
196
96
|
|
|
@@ -242,14 +142,14 @@ async function saveConfig(data) {
|
|
|
242
142
|
|
|
243
143
|
// ─── Audit log ────────────────────────────────────────────────────────────────
|
|
244
144
|
|
|
245
|
-
// 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>
|
|
246
146
|
// NEVER log token values or payload content.
|
|
247
|
-
async function auditLog(ts, type, deviceId, msgId, result) {
|
|
147
|
+
async function auditLog(ts, type, deviceId, msgId, result, client) {
|
|
248
148
|
try {
|
|
249
149
|
const ymd = ts.slice(0, 10);
|
|
250
150
|
const logPath = path.join(AUDIT_LOG_DIR, `remote-audit-${ymd}.log`);
|
|
251
151
|
const clean = (s) => String(s ?? '').replace(/[\r\n\t]+/g, ' ').slice(0, 200);
|
|
252
|
-
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`;
|
|
253
153
|
const handle = await fsp.open(logPath, 'a', 0o600);
|
|
254
154
|
try {
|
|
255
155
|
await handle.write(line);
|
|
@@ -317,27 +217,73 @@ async function getDeviceTicket(deviceToken) {
|
|
|
317
217
|
|
|
318
218
|
// ─── E2E state helpers ────────────────────────────────────────────────────────
|
|
319
219
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
220
|
+
// Tear down every client session (connection-level reset).
|
|
221
|
+
function resetE2e() {
|
|
222
|
+
teardownAllClients();
|
|
223
|
+
}
|
|
224
|
+
|
|
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');
|
|
243
|
+
}
|
|
244
|
+
|
|
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);
|
|
252
|
+
}
|
|
253
|
+
|
|
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);
|
|
324
260
|
}
|
|
325
261
|
|
|
326
262
|
// ─── WebSocket lifecycle ──────────────────────────────────────────────────────
|
|
327
263
|
|
|
328
264
|
// Single source of truth for the status payload shape shared by the
|
|
329
265
|
// broadcast push (webRemote:status) and the get-status IPC pull — both must
|
|
330
|
-
// report the same connected/e2e/device view of `cfg` + `_ws`/`
|
|
266
|
+
// report the same connected/e2e/device view of `cfg` + `_ws`/`_clients` module state.
|
|
331
267
|
function computeStatus(cfg) {
|
|
332
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';
|
|
333
273
|
return {
|
|
334
274
|
enabled: cfg.remoteEnabled,
|
|
335
275
|
remoteControlEnabled: cfg.remoteControlEnabled ?? false,
|
|
336
276
|
connected,
|
|
337
|
-
e2eActive: connected &&
|
|
338
|
-
e2eAuthenticated: connected &&
|
|
339
|
-
e2eState: connected ?
|
|
340
|
-
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
|
+
})),
|
|
341
287
|
devices: (cfg.devices || []).map(({ deviceId, deviceName, issuedAt, lastConnectedAt }) => ({
|
|
342
288
|
deviceId, deviceName, issuedAt, lastConnectedAt,
|
|
343
289
|
})),
|
|
@@ -481,7 +427,7 @@ async function connect() {
|
|
|
481
427
|
broadcastStatus();
|
|
482
428
|
// v2: begin pushing the live session list once connected.
|
|
483
429
|
// Reset diff-guard so the new client always receives a full session-list push.
|
|
484
|
-
|
|
430
|
+
for (const c of _clients.values()) c.lastSessionListJson = null;
|
|
485
431
|
startSessionListPush();
|
|
486
432
|
});
|
|
487
433
|
|
|
@@ -526,6 +472,7 @@ async function connect() {
|
|
|
526
472
|
}
|
|
527
473
|
|
|
528
474
|
async function handleTokenRevoked(deviceId) {
|
|
475
|
+
teardownAllClients();
|
|
529
476
|
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'token revoked', meta: { deviceId } });
|
|
530
477
|
const cfg = await loadConfig();
|
|
531
478
|
const devices = (cfg.devices || []).filter((d) => d.deviceId !== deviceId);
|
|
@@ -547,24 +494,48 @@ const SESSION_INIT_TAIL_BYTES = 512 * 1024; // bound the initial read
|
|
|
547
494
|
|
|
548
495
|
const _sessionWatchers = new Map(); // tabId → watcher
|
|
549
496
|
let _sessionListTimer = null;
|
|
550
|
-
let _lastSessionListJson = null; // diff-guard: skip push when snapshot unchanged
|
|
551
497
|
|
|
552
|
-
/** Push an unsolicited event to
|
|
553
|
-
function
|
|
498
|
+
/** Push an unsolicited event to ONE client, E2E-encrypted with its key and unicast via toConn. */
|
|
499
|
+
function pushEventTo(client, type, payload) {
|
|
554
500
|
if (!_ws || _ws.readyState !== WebSocket.OPEN) return;
|
|
501
|
+
if (!client || client.e2e.state !== 'authenticated' || !client.e2e.sessionKey) return;
|
|
555
502
|
const inner = { type, id: crypto.randomUUID(), payload, ts: Date.now() };
|
|
556
503
|
try {
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
_ws.send(JSON.stringify(inner));
|
|
562
|
-
}
|
|
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));
|
|
563
508
|
} catch (e) {
|
|
564
509
|
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'pushEvent failed', meta: { type, error: e?.message } });
|
|
565
510
|
}
|
|
566
511
|
}
|
|
567
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
|
+
|
|
568
539
|
/** Extract concatenated text from an assistant transcript line, or '' if none. */
|
|
569
540
|
function extractAssistantText(raw) {
|
|
570
541
|
const msg = raw?.message || raw;
|
|
@@ -660,8 +631,8 @@ async function pollSessionWatcher(w) {
|
|
|
660
631
|
if (nextState && nextState !== w.state) {
|
|
661
632
|
w.state = nextState;
|
|
662
633
|
// Guard: don't push state if SAS not yet confirmed (watcher may outlive an auth reset).
|
|
663
|
-
|
|
664
|
-
|
|
634
|
+
for (const c of subscribersOf(w.tabId)) {
|
|
635
|
+
pushEventTo(c, 'event:session:state', { tabId: w.tabId, state: w.state, since: Date.now() });
|
|
665
636
|
}
|
|
666
637
|
}
|
|
667
638
|
if (newAssistantText && newMsgId !== w.lastMsgId) {
|
|
@@ -712,6 +683,41 @@ function stopAllSessionWatches() {
|
|
|
712
683
|
if (_sessionListTimer) { clearInterval(_sessionListTimer); _sessionListTimer = null; }
|
|
713
684
|
}
|
|
714
685
|
|
|
686
|
+
/**
|
|
687
|
+
* Pure builder for the phone's session list: one project entry per tab, then
|
|
688
|
+
* each tab's ACTIVE Epics (from active-index.json via readIndex). Never throws.
|
|
689
|
+
*/
|
|
690
|
+
function buildRemoteSessionList(tabs, readIndex) {
|
|
691
|
+
const out = [];
|
|
692
|
+
const seenEpics = new Set();
|
|
693
|
+
for (const tab of Array.isArray(tabs) ? tabs : []) {
|
|
694
|
+
if (!tab) continue;
|
|
695
|
+
const project = tab.label || path.basename(tab.cwd || '');
|
|
696
|
+
out.push({ kind: 'project', tabId: tab.sessionId, cwd: tab.cwd, title: tab.label || tab.cwd, project });
|
|
697
|
+
let sessions = null;
|
|
698
|
+
try { sessions = readIndex(tab.cwd)?.sessions; } catch { sessions = null; }
|
|
699
|
+
if (!sessions || typeof sessions !== 'object') continue;
|
|
700
|
+
for (const epic of Object.values(sessions)) {
|
|
701
|
+
if (!epic || epic.status !== 'active') continue;
|
|
702
|
+
if (typeof epic.claudeSessionId !== 'string' || !epic.claudeSessionId) continue;
|
|
703
|
+
if (seenEpics.has(epic.id)) continue;
|
|
704
|
+
seenEpics.add(epic.id);
|
|
705
|
+
const firstLine = typeof epic.goalText === 'string' ? epic.goalText.split('\n')[0].trim().slice(0, 80).trim() : '';
|
|
706
|
+
out.push({
|
|
707
|
+
kind: 'epic',
|
|
708
|
+
tabId: epic.claudeSessionId,
|
|
709
|
+
epicId: epic.id,
|
|
710
|
+
cwd: epic.worktree?.dir || epic.cwd,
|
|
711
|
+
title: firstLine || epic.id,
|
|
712
|
+
project,
|
|
713
|
+
tag: epic.tag,
|
|
714
|
+
agentType: epic.agentType,
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
return out;
|
|
719
|
+
}
|
|
720
|
+
|
|
715
721
|
/** Push the current session list (reuses sessionsStore — the canonical source). */
|
|
716
722
|
async function pushSessionList() {
|
|
717
723
|
try {
|
|
@@ -723,23 +729,24 @@ async function pushSessionList() {
|
|
|
723
729
|
// Don't push before SAS is confirmed — session cwds/titles are sensitive user data.
|
|
724
730
|
// A relay that completes e2e:hello before the user confirms the SAS would otherwise
|
|
725
731
|
// receive the full session list immediately (same threat SAS_GATED_READS blocks for
|
|
726
|
-
// cmd:sessions:load). Guard here so
|
|
727
|
-
if (
|
|
732
|
+
// cmd:sessions:load). Guard here so client.lastSessionListJson is not poisoned either.
|
|
733
|
+
if (!anyAuthenticated()) return;
|
|
728
734
|
const sessionsStore = require('./sessionsStore.cjs');
|
|
729
735
|
const data = await sessionsStore.load();
|
|
730
|
-
//
|
|
736
|
+
// Tabs + their ACTIVE Epics → SessionMeta. tabId === claude sessionId so it
|
|
731
737
|
// matches the transcript JSONL name used by cmd:session:subscribe.
|
|
732
|
-
const
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
state: _sessionWatchers.get(t.sessionId)?.state ?? null,
|
|
738
|
+
const { readActiveIndex } = require('./lib/epicMint.cjs');
|
|
739
|
+
const sessions = buildRemoteSessionList(data?.tabs ?? [], readActiveIndex).map((s) => ({
|
|
740
|
+
...s,
|
|
741
|
+
state: _sessionWatchers.get(s.tabId)?.state ?? null,
|
|
737
742
|
}));
|
|
738
743
|
const payload = { sessions, activeTabId: data?.activeTabId ?? null };
|
|
739
744
|
const json = JSON.stringify(payload);
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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
|
+
}
|
|
743
750
|
} catch (e) {
|
|
744
751
|
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'pushSessionList failed', meta: { error: e?.message } });
|
|
745
752
|
}
|
|
@@ -764,14 +771,16 @@ const SUMMARY_MIN_CHARS = 280; // below this, push raw — not worth an API call
|
|
|
764
771
|
* completed turn per subscribed tab (~$1/$5 per 1M tokens).
|
|
765
772
|
*/
|
|
766
773
|
async function maybeSummarize(w) {
|
|
767
|
-
// Guard:
|
|
768
|
-
if (
|
|
774
|
+
// Guard: summaries (session transcript content) go only to SAS-confirmed subscribers.
|
|
775
|
+
if (subscribersOf(w.tabId).length === 0) return;
|
|
769
776
|
const text = w.lastAssistantText;
|
|
770
777
|
if (!text) return;
|
|
771
778
|
const ofMessageId = w.lastMsgId;
|
|
772
779
|
|
|
773
780
|
if (text.length < SUMMARY_MIN_CHARS) {
|
|
774
|
-
|
|
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
|
+
}
|
|
775
784
|
return;
|
|
776
785
|
}
|
|
777
786
|
|
|
@@ -779,23 +788,25 @@ async function maybeSummarize(w) {
|
|
|
779
788
|
if (degraded) {
|
|
780
789
|
logs.writeLine({ scope: 'webRemote', level: 'warn', message: `summary degraded: ${degraded}`, meta: {} });
|
|
781
790
|
}
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
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
|
+
}
|
|
787
798
|
}
|
|
788
799
|
|
|
789
800
|
/**
|
|
790
|
-
* Shared finish-up for any path that transitions
|
|
801
|
+
* Shared finish-up for any path that transitions a client's e2e state into 'authenticated'
|
|
791
802
|
* (manual SAS confirmation or pinned-key auto-trust): log, broadcast the new
|
|
792
803
|
* status, and flush the session list immediately rather than waiting for the
|
|
793
804
|
* next SESSION_LIST_PUSH_MS tick.
|
|
794
805
|
*/
|
|
795
|
-
function finalizeE2eAuthenticated(logMessage) {
|
|
806
|
+
function finalizeE2eAuthenticated(logMessage, client) {
|
|
796
807
|
logs.writeLine({ scope: 'webRemote', level: 'info', message: logMessage });
|
|
797
808
|
broadcastStatus();
|
|
798
|
-
|
|
809
|
+
if (client) client.lastSessionListJson = null;
|
|
799
810
|
pushSessionList().catch(() => {});
|
|
800
811
|
}
|
|
801
812
|
|
|
@@ -839,6 +850,22 @@ async function handleMessage(raw, device) {
|
|
|
839
850
|
return;
|
|
840
851
|
}
|
|
841
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
|
+
|
|
842
869
|
// ── E2E key exchange ───────────────────────────────────────────────────────
|
|
843
870
|
// Browser sends e2e:hello with its ephemeral P-256 public key (SPKI base64url).
|
|
844
871
|
// We compute the shared session key and acknowledge.
|
|
@@ -883,8 +910,8 @@ async function handleMessage(raw, device) {
|
|
|
883
910
|
}
|
|
884
911
|
} catch (e) {
|
|
885
912
|
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'e2e:hello peer key validation failed — session dropped', meta: { error: e?.message } });
|
|
886
|
-
await auditLog(new Date().toISOString(), 'e2e:hello', device.deviceId, undefined, 'error:invalid_peer_key');
|
|
887
|
-
|
|
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
|
|
888
915
|
broadcastStatus();
|
|
889
916
|
return;
|
|
890
917
|
}
|
|
@@ -896,31 +923,31 @@ async function handleMessage(raw, device) {
|
|
|
896
923
|
} catch (sasErr) {
|
|
897
924
|
// SAS derivation failed — session cannot be verified by the user.
|
|
898
925
|
// Mark failed so confirm-sas returns ok:false and the UI prompts retry.
|
|
899
|
-
|
|
926
|
+
failClient(client);
|
|
900
927
|
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'E2E SAS derivation failed — session marked failed', meta: { error: sasErr?.message } });
|
|
901
928
|
broadcastStatus();
|
|
902
929
|
return;
|
|
903
930
|
}
|
|
904
|
-
|
|
905
|
-
|
|
931
|
+
client.peerPubKey = browserPubKey;
|
|
932
|
+
client.deviceId = device.deviceId;
|
|
906
933
|
// TOFU pinning: if this exact (deviceId, browserPubKey) pair was already
|
|
907
934
|
// manually SAS-confirmed once, skip pending_sas and auto-authenticate — the
|
|
908
935
|
// user already verified this browser. A different/first-seen pubKey always
|
|
909
936
|
// falls through to the manual pending_sas flow below, unchanged.
|
|
910
|
-
if (device
|
|
911
|
-
|
|
912
|
-
finalizeE2eAuthenticated('E2E session auto-authenticated — pinned browser key matched');
|
|
913
|
-
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);
|
|
914
941
|
return;
|
|
915
942
|
}
|
|
916
|
-
|
|
943
|
+
client.e2e = makeState('pending_sas', sessionKey, pendingSas);
|
|
917
944
|
logs.writeLine({ scope: 'webRemote', level: 'info', message: 'E2E session key established — SAS pending confirmation' });
|
|
918
945
|
broadcastStatus();
|
|
919
946
|
// Acknowledge with e2e:ready (unencrypted — session just started)
|
|
920
|
-
respond(id, undefined, 'e2e:ready');
|
|
947
|
+
respond(id, undefined, 'e2e:ready', client);
|
|
921
948
|
} catch (e) {
|
|
922
949
|
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'E2E key derivation failed', meta: { error: e?.message } });
|
|
923
|
-
|
|
950
|
+
failClient(client);
|
|
924
951
|
broadcastStatus();
|
|
925
952
|
}
|
|
926
953
|
return;
|
|
@@ -928,13 +955,13 @@ async function handleMessage(raw, device) {
|
|
|
928
955
|
|
|
929
956
|
// ── Decrypt e2e:box messages ──────────────────────────────────────────────
|
|
930
957
|
if (type === 'e2e:box') {
|
|
931
|
-
if (!
|
|
958
|
+
if (!client.e2e.sessionKey) {
|
|
932
959
|
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'e2e:box received but no session key — dropping' });
|
|
933
960
|
return;
|
|
934
961
|
}
|
|
935
962
|
const { nonce, ciphertext } = payload || {};
|
|
936
963
|
if (!nonce || !ciphertext) return;
|
|
937
|
-
const plaintext = decryptBox(nonce, ciphertext,
|
|
964
|
+
const plaintext = decryptBox(nonce, ciphertext, client.e2e.sessionKey);
|
|
938
965
|
if (!plaintext) {
|
|
939
966
|
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'e2e:box decryption failed (auth tag mismatch)' });
|
|
940
967
|
return;
|
|
@@ -947,7 +974,7 @@ async function handleMessage(raw, device) {
|
|
|
947
974
|
return;
|
|
948
975
|
}
|
|
949
976
|
// Dispatch as if it arrived unencrypted
|
|
950
|
-
await dispatchEnvelope(inner, device);
|
|
977
|
+
await dispatchEnvelope(inner, device, client);
|
|
951
978
|
return;
|
|
952
979
|
}
|
|
953
980
|
|
|
@@ -955,14 +982,14 @@ async function handleMessage(raw, device) {
|
|
|
955
982
|
if (!type.startsWith('cmd:')) return;
|
|
956
983
|
// After E2E is established, reject plaintext commands — a malicious relay cannot
|
|
957
984
|
// silently downgrade the session by stripping e2e:hello (PENTEST.md §H1).
|
|
958
|
-
if (
|
|
985
|
+
if (client.e2e.sessionKey) {
|
|
959
986
|
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'plaintext cmd rejected — e2e session active' });
|
|
960
987
|
return;
|
|
961
988
|
}
|
|
962
|
-
await dispatchEnvelope(envelope, device);
|
|
989
|
+
await dispatchEnvelope(envelope, device, client);
|
|
963
990
|
}
|
|
964
991
|
|
|
965
|
-
async function dispatchEnvelope(envelope, device) {
|
|
992
|
+
async function dispatchEnvelope(envelope, device, client) {
|
|
966
993
|
const { type, id, payload } = envelope;
|
|
967
994
|
if (typeof type !== 'string' || !type.startsWith('cmd:')) return;
|
|
968
995
|
|
|
@@ -971,22 +998,22 @@ async function dispatchEnvelope(envelope, device) {
|
|
|
971
998
|
// Kill switch — re-reads config with 1s TTL
|
|
972
999
|
const cfg = await loadConfig();
|
|
973
1000
|
if (!cfg.remoteEnabled) {
|
|
974
|
-
await auditLog(ts, type, device.deviceId, id, 'error:disabled');
|
|
975
|
-
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
|
|
976
1003
|
return;
|
|
977
1004
|
}
|
|
978
1005
|
|
|
979
1006
|
// Allowlist check — reject unknown cmd:* types with opaque error (oracle prevention)
|
|
980
1007
|
if (!ALLOWED_COMMANDS.has(type)) {
|
|
981
|
-
await auditLog(ts, type, device.deviceId, id, 'error:not_allowed');
|
|
982
|
-
respond(id, { error: 'rejected' });
|
|
1008
|
+
await auditLog(ts, type, device.deviceId, id, 'error:not_allowed', client);
|
|
1009
|
+
respond(id, { error: 'rejected' }, undefined, client);
|
|
983
1010
|
return;
|
|
984
1011
|
}
|
|
985
1012
|
|
|
986
1013
|
// MUTATE tier gate — write/exec commands require remoteControlEnabled=true (default false)
|
|
987
1014
|
if (MUTATE_COMMANDS.has(type) && !cfg.remoteControlEnabled) {
|
|
988
|
-
await auditLog(ts, type, device.deviceId, id, 'error:not_allowed');
|
|
989
|
-
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
|
|
990
1017
|
return;
|
|
991
1018
|
}
|
|
992
1019
|
|
|
@@ -994,27 +1021,27 @@ async function dispatchEnvelope(envelope, device) {
|
|
|
994
1021
|
// user confirms the SAS on the desktop. This prevents a compromised relay
|
|
995
1022
|
// from exfiltrating session lists, PRDs, run logs, or transcript summaries
|
|
996
1023
|
// by completing the ECDH handshake without the user's knowledge.
|
|
997
|
-
if ((MUTATE_COMMANDS.has(type) || SAS_GATED_READS.has(type)) &&
|
|
998
|
-
await auditLog(ts, type, device.deviceId, id, 'error:e2e_not_authenticated');
|
|
999
|
-
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
|
|
1000
1027
|
return;
|
|
1001
1028
|
}
|
|
1002
1029
|
|
|
1003
1030
|
// Dispatch
|
|
1004
1031
|
let result;
|
|
1005
1032
|
try {
|
|
1006
|
-
result = await dispatchCommand(type, payload ?? {});
|
|
1007
|
-
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);
|
|
1008
1035
|
} catch (e) {
|
|
1009
1036
|
const code = e?.name === 'ZodError' ? 'schema_invalid' : 'dispatch_error';
|
|
1010
|
-
await auditLog(ts, type, device.deviceId, id, `error:${code}
|
|
1037
|
+
await auditLog(ts, type, device.deviceId, id, `error:${code}`, client);
|
|
1011
1038
|
result = { error: 'rejected' }; // opaque on-wire — reason stays in audit log only
|
|
1012
1039
|
}
|
|
1013
1040
|
|
|
1014
|
-
respond(id, result);
|
|
1041
|
+
respond(id, result, undefined, client);
|
|
1015
1042
|
}
|
|
1016
1043
|
|
|
1017
|
-
function respond(msgId, payload, typeOverride) {
|
|
1044
|
+
function respond(msgId, payload, typeOverride, client) {
|
|
1018
1045
|
if (!_ws || _ws.readyState !== WebSocket.OPEN) return;
|
|
1019
1046
|
const responseType = typeOverride || (msgId ? `resp:${msgId}` : undefined);
|
|
1020
1047
|
if (!responseType) return;
|
|
@@ -1027,23 +1054,28 @@ function respond(msgId, payload, typeOverride) {
|
|
|
1027
1054
|
};
|
|
1028
1055
|
|
|
1029
1056
|
try {
|
|
1030
|
-
// Encrypt the
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
} else {
|
|
1040
|
-
_ws.send(JSON.stringify(inner));
|
|
1041
|
-
}
|
|
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));
|
|
1042
1066
|
} catch (e) {
|
|
1043
1067
|
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'respond send failed', meta: { error: e?.message } });
|
|
1044
1068
|
}
|
|
1045
1069
|
}
|
|
1046
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
|
+
|
|
1047
1079
|
// Lazy-loaded dispatch map — avoids circular require at module load time.
|
|
1048
1080
|
let _dispatchMap = null;
|
|
1049
1081
|
|
|
@@ -1059,12 +1091,22 @@ function getDispatchMap() {
|
|
|
1059
1091
|
'cmd:sessions:load': async () =>
|
|
1060
1092
|
sessionsStore.load(),
|
|
1061
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
|
+
|
|
1062
1103
|
'cmd:sessions:save': async (payload) => {
|
|
1063
1104
|
const parsed = schemas.sessionsPayload.parse(payload);
|
|
1064
1105
|
return sessionsStore.save(parsed);
|
|
1065
1106
|
},
|
|
1066
1107
|
|
|
1067
|
-
'cmd:pty:spawn': async (payload) => {
|
|
1108
|
+
'cmd:pty:spawn': async (payload, reqClient) => {
|
|
1109
|
+
const client = reqClient || _clients.getOrCreate(LEGACY_CLIENT_KEY);
|
|
1068
1110
|
const parsed = schemas.ptySpawn.parse(payload);
|
|
1069
1111
|
// Path safety — validatePath rejects anything outside home dir.
|
|
1070
1112
|
validatePath(parsed.cwd);
|
|
@@ -1072,7 +1114,25 @@ function getDispatchMap() {
|
|
|
1072
1114
|
// remotely-controlled 8 KiB string to spawn is a landmine if pty.cjs
|
|
1073
1115
|
// ever uses it. Remote callers have no legitimate need for it.
|
|
1074
1116
|
const { startupCommand: _ignored, ...safePayload } = parsed;
|
|
1075
|
-
|
|
1117
|
+
const result = await ptyManager.spawn(safePayload);
|
|
1118
|
+
// Stream output to the peer — replaces any prior subscription for this tab.
|
|
1119
|
+
// Chunk contents are sensitive: never logged.
|
|
1120
|
+
const tabId = safePayload.tabId;
|
|
1121
|
+
stopClientPtyStream(client, tabId);
|
|
1122
|
+
const unsub = ptyManager.addOutputListener(
|
|
1123
|
+
tabId,
|
|
1124
|
+
(_id, data) => {
|
|
1125
|
+
if (client.e2e.state !== 'authenticated' || !loadConfigSync().remoteEnabled) return;
|
|
1126
|
+
pushEventTo(client, 'event:pty:data', { tabId, data });
|
|
1127
|
+
},
|
|
1128
|
+
(_id, info) => {
|
|
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 });
|
|
1132
|
+
},
|
|
1133
|
+
);
|
|
1134
|
+
client.ptyStreams.set(tabId, unsub);
|
|
1135
|
+
return result;
|
|
1076
1136
|
},
|
|
1077
1137
|
|
|
1078
1138
|
'cmd:pty:write': async (payload) => {
|
|
@@ -1086,8 +1146,11 @@ function getDispatchMap() {
|
|
|
1086
1146
|
return { ok: true };
|
|
1087
1147
|
},
|
|
1088
1148
|
|
|
1089
|
-
'cmd:pty:kill': async (payload) => {
|
|
1149
|
+
'cmd:pty:kill': async (payload, reqClient) => {
|
|
1090
1150
|
const parsed = schemas.ptyTabId.parse(payload);
|
|
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;
|
|
1091
1154
|
ptyManager.kill(parsed.tabId);
|
|
1092
1155
|
return { ok: true };
|
|
1093
1156
|
},
|
|
@@ -1100,17 +1163,24 @@ function getDispatchMap() {
|
|
|
1100
1163
|
'cmd:app:version': async () =>
|
|
1101
1164
|
app.getVersion(),
|
|
1102
1165
|
|
|
1166
|
+
'cmd:app:info': async () =>
|
|
1167
|
+
({ version: app.getVersion(), platform: process.platform }),
|
|
1168
|
+
|
|
1103
1169
|
// v2 mobile: start/stop pushing live state + summary for a session.
|
|
1104
|
-
'cmd:session:subscribe': async (payload) => {
|
|
1170
|
+
'cmd:session:subscribe': async (payload, reqClient) => {
|
|
1171
|
+
const client = reqClient || _clients.getOrCreate(LEGACY_CLIENT_KEY);
|
|
1105
1172
|
const parsed = schemas.sessionSubscribe.parse(payload);
|
|
1106
1173
|
validatePath(parsed.cwd); // home-dir boundary before any fs access
|
|
1107
1174
|
startSessionWatch(parsed.tabId, parsed.cwd);
|
|
1175
|
+
client.subscriptions.add(parsed.tabId);
|
|
1108
1176
|
return { ok: true };
|
|
1109
1177
|
},
|
|
1110
1178
|
|
|
1111
|
-
'cmd:session:unsubscribe': async (payload) => {
|
|
1179
|
+
'cmd:session:unsubscribe': async (payload, reqClient) => {
|
|
1180
|
+
const client = reqClient || _clients.getOrCreate(LEGACY_CLIENT_KEY);
|
|
1112
1181
|
const parsed = schemas.ptyTabId.parse(payload);
|
|
1113
|
-
|
|
1182
|
+
client.subscriptions.delete(parsed.tabId);
|
|
1183
|
+
pruneSessionWatchers();
|
|
1114
1184
|
return { ok: true };
|
|
1115
1185
|
},
|
|
1116
1186
|
|
|
@@ -1122,16 +1192,43 @@ function getDispatchMap() {
|
|
|
1122
1192
|
chatRunner.enqueueExternalPrompt(parsed.tabId, parsed.prompt);
|
|
1123
1193
|
return { ok: true };
|
|
1124
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
|
+
},
|
|
1125
1222
|
};
|
|
1126
1223
|
|
|
1127
1224
|
return _dispatchMap;
|
|
1128
1225
|
}
|
|
1129
1226
|
|
|
1130
|
-
async function dispatchCommand(type, payload) {
|
|
1227
|
+
async function dispatchCommand(type, payload, client) {
|
|
1131
1228
|
const map = getDispatchMap();
|
|
1132
1229
|
const handler = map[type];
|
|
1133
1230
|
if (!handler) throw new Error(`no handler for ${type}`);
|
|
1134
|
-
return handler(payload);
|
|
1231
|
+
return handler(payload, client);
|
|
1135
1232
|
}
|
|
1136
1233
|
|
|
1137
1234
|
// ─── Pairing ─────────────────────────────────────────────────────────────────
|
|
@@ -1177,6 +1274,7 @@ async function pair(otp) {
|
|
|
1177
1274
|
// Set once a browser's SAS is manually confirmed; TOFU-pinned for auto-trust
|
|
1178
1275
|
// on future reconnects from the same (deviceId, browserPubKey) pair.
|
|
1179
1276
|
verifiedPeerPubKey: null,
|
|
1277
|
+
verifiedPeerPubKeys: [],
|
|
1180
1278
|
}];
|
|
1181
1279
|
|
|
1182
1280
|
await saveConfig({ ...cfg, devices });
|
|
@@ -1190,6 +1288,7 @@ async function pair(otp) {
|
|
|
1190
1288
|
}
|
|
1191
1289
|
|
|
1192
1290
|
async function revokeDevice(deviceId) {
|
|
1291
|
+
teardownAllClients();
|
|
1193
1292
|
invalidateConfigCache();
|
|
1194
1293
|
const cfg = await loadConfig();
|
|
1195
1294
|
const devices = (cfg.devices || []).filter((d) => d.deviceId !== deviceId);
|
|
@@ -1214,6 +1313,7 @@ async function revokeDevice(deviceId) {
|
|
|
1214
1313
|
* This is the local-side "kill everything" action (ADR §4.1).
|
|
1215
1314
|
*/
|
|
1216
1315
|
async function revokeAllDevices() {
|
|
1316
|
+
teardownAllClients();
|
|
1217
1317
|
const cfg = await loadConfig();
|
|
1218
1318
|
const revokedCount = (cfg.devices || []).length;
|
|
1219
1319
|
await saveConfig({ ...cfg, remoteEnabled: false, devices: [] });
|
|
@@ -1237,20 +1337,34 @@ function registerRemoteHandlers() {
|
|
|
1237
1337
|
// User confirmed that the SAS shown on both desktop and browser match.
|
|
1238
1338
|
// Returns ok:false if the session is not in the pending_sas state (e.g. key
|
|
1239
1339
|
// missing, already failed, already authenticated, or reconnected).
|
|
1240
|
-
ipcMain.handle('webRemote:confirm-sas', async () => {
|
|
1241
|
-
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);
|
|
1242
1354
|
if (!ok) {
|
|
1243
|
-
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 } });
|
|
1244
1356
|
return { ok: false, error };
|
|
1245
1357
|
}
|
|
1246
|
-
|
|
1358
|
+
target.e2e = next;
|
|
1247
1359
|
// Pin this browser's pubkey to the device so future reconnects with the same
|
|
1248
|
-
// key auto-authenticate (see e2e:hello above)
|
|
1249
|
-
if (
|
|
1360
|
+
// key auto-authenticate (see e2e:hello above). Appends; other clients' pins stay.
|
|
1361
|
+
if (target.peerPubKey && target.deviceId) {
|
|
1250
1362
|
try {
|
|
1251
1363
|
const cfg = await loadConfig();
|
|
1252
1364
|
const devices = (cfg.devices || []).map((d) =>
|
|
1253
|
-
d.deviceId ===
|
|
1365
|
+
d.deviceId === target.deviceId
|
|
1366
|
+
? { ...d, verifiedPeerPubKeys: addPinnedPeerKey(d, target.peerPubKey, target.clientId) }
|
|
1367
|
+
: d
|
|
1254
1368
|
);
|
|
1255
1369
|
await saveConfig({ ...cfg, devices });
|
|
1256
1370
|
} catch (e) {
|
|
@@ -1260,9 +1374,9 @@ function registerRemoteHandlers() {
|
|
|
1260
1374
|
// Flush the session list immediately — the push loop was suppressed while
|
|
1261
1375
|
// state !== 'authenticated', so the mobile app would otherwise wait up to
|
|
1262
1376
|
// SESSION_LIST_PUSH_MS for the first useful data.
|
|
1263
|
-
finalizeE2eAuthenticated('E2E session authenticated — SAS confirmed by user');
|
|
1377
|
+
finalizeE2eAuthenticated('E2E session authenticated — SAS confirmed by user', target);
|
|
1264
1378
|
return { ok: true };
|
|
1265
|
-
});
|
|
1379
|
+
}));
|
|
1266
1380
|
|
|
1267
1381
|
ipcMain.handle('webRemote:enable', async () => {
|
|
1268
1382
|
const cfg = await loadConfig();
|
|
@@ -1290,6 +1404,7 @@ function registerRemoteHandlers() {
|
|
|
1290
1404
|
ipcMain.handle('webRemote:disable-control', async () => {
|
|
1291
1405
|
const cfg = await loadConfig();
|
|
1292
1406
|
await saveConfig({ ...cfg, remoteControlEnabled: false });
|
|
1407
|
+
teardownAllClients(); // no client keeps a PTY stream once control is off
|
|
1293
1408
|
broadcastStatus();
|
|
1294
1409
|
return { ok: true };
|
|
1295
1410
|
});
|
|
@@ -1359,6 +1474,17 @@ module.exports = {
|
|
|
1359
1474
|
_internal: {
|
|
1360
1475
|
handleMessage,
|
|
1361
1476
|
resetE2e,
|
|
1362
|
-
getE2eState: () =>
|
|
1477
|
+
getE2eState: (clientKey = LEGACY_CLIENT_KEY) => (_clients.get(clientKey) || { e2e: makeState() }).e2e,
|
|
1478
|
+
},
|
|
1479
|
+
__test: {
|
|
1480
|
+
getDispatchMap,
|
|
1481
|
+
buildRemoteSessionList,
|
|
1482
|
+
setWs: (ws) => { _ws = ws; },
|
|
1483
|
+
invalidateConfigCache,
|
|
1484
|
+
setE2eAuthenticated: (key, clientKey = LEGACY_CLIENT_KEY) => {
|
|
1485
|
+
_clients.getOrCreate(clientKey).e2e = makeState('authenticated', key, null);
|
|
1486
|
+
},
|
|
1487
|
+
revokeAllDevices,
|
|
1488
|
+
streamCount: () => _clients.values().reduce((n, c) => n + c.ptyStreams.size, 0),
|
|
1363
1489
|
},
|
|
1364
1490
|
};
|