claude-code-session-manager 0.108.0 → 0.109.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 +2 -0
- package/dist/assets/{Button-DC6VzovQ.js → Button-C8Mc6MEP.js} +1 -1
- package/dist/assets/DataModel-Bz0MypYO.js +1 -0
- package/dist/assets/History-DT26Iy38.js +2 -0
- package/dist/assets/Hooks-e4xUGEf-.js +3 -0
- package/dist/assets/{Library-DLmnlfQt.js → Library-B3rzEhvN.js} +24 -24
- package/dist/assets/{ListDetail-YylbvUj_.js → ListDetail-BAvci_aP.js} +1 -1
- package/dist/assets/{MarkdownEditor-DMPKWHTF.js → MarkdownEditor-DBWTGgRz.js} +1 -1
- package/dist/assets/{McpServers-Da-Ty5nc.js → McpServers-C-grBN21.js} +2 -2
- package/dist/assets/{Memory-C3rUQ_cg.js → Memory-BmM0Fz-C.js} +5 -5
- package/dist/assets/{Panel-CC3JTnOI.js → Panel-Dc6F8wb4.js} +1 -1
- package/dist/assets/Permissions-m_NnldFh.js +3 -0
- package/dist/assets/Plugins-BB0TLUC9.js +2 -0
- package/dist/assets/ProvenanceBadge-k9PH0S2n.js +1 -0
- package/dist/assets/{SaveBar-C06rFT34.js → SaveBar-DSMnbUVI.js} +1 -1
- package/dist/assets/Scheduler-CbIcJEfM.js +16 -0
- package/dist/assets/{ScopeSwitcher-Cp5mxblN.js → ScopeSwitcher-tHtloWbN.js} +1 -1
- package/dist/assets/Settings-CW3w7knp.js +6 -0
- package/dist/assets/{SkillReferenceGraph-DuWRmT_f.js → SkillReferenceGraph-Dndl1tzr.js} +14 -14
- package/dist/assets/Skills-CGnxroik.js +2 -0
- package/dist/assets/SystemPrompt-B3A3rHNp.js +1 -0
- package/dist/assets/TagLibrary-k4CTiAYo.js +1 -0
- package/dist/assets/{TiptapBody-1r4ODLJ7.js → TiptapBody-C2LfNQXw.js} +1 -1
- package/dist/assets/{Toggle-BGT68XHP.js → Toggle-CTXTU1q5.js} +1 -1
- package/dist/assets/{ViewTabs-T0NBc8uv.js → ViewTabs-CfEfOSE1.js} +1 -1
- package/dist/assets/historyTotals-Db0xy2Fg.js +1 -0
- package/dist/assets/{index-tr1w3hHB.css → index-C8Smiz7z.css} +1 -1
- package/dist/assets/{index-3_AtCAyd.js → index-fJxuoRvi.js} +697 -697
- package/dist/assets/listSkills-C1aWKf0T.js +1 -0
- package/dist/assets/{settingsSchema-CvoNk3Ye.js → settingsSchema-xxO9p4yF.js} +1 -1
- package/dist/assets/useAgentsDirWatch-DLS1_moj.js +1 -0
- package/dist/index.html +2 -2
- package/package.json +5 -2
- package/plugins/session-manager-dev/skills/develop/standards.md +5 -0
- package/scripts/README.md +7 -0
- package/src/main/agentLibrary.cjs +20 -9
- package/src/main/build-info.json +4 -4
- package/src/main/config.cjs +20 -10
- package/src/main/historyAggregator.cjs +46 -3
- package/src/main/index.cjs +2 -0
- package/src/main/ipcSchemas.cjs +2 -0
- package/src/main/lib/remoteE2eCrypto.cjs +109 -0
- package/src/main/pty.cjs +53 -0
- package/src/main/remoteClient.cjs +305 -0
- package/src/main/remoteClientIpc.cjs +101 -0
- package/src/main/webRemote.cjs +90 -108
- package/src/preload/api.d.ts +42 -5
- package/src/preload/index.cjs +27 -2
- 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/listSkills-QORduPIk.js +0 -1
- package/dist/assets/useAgentsDirWatch-1C2CBBya.js +0 -1
- package/dist/assets/useConfigDirWatch-Cd5PnTkB.js +0 -1
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Desktop-to-desktop remote: the CLIENT role. Drives a paired Session Manager
|
|
4
|
+
// agent (webRemote.cjs) over the relay using the same envelope protocol the
|
|
5
|
+
// phone app speaks (web/remote-app/src/ws.ts). No Electron imports — IPC wiring
|
|
6
|
+
// lives elsewhere.
|
|
7
|
+
|
|
8
|
+
const crypto = require('node:crypto');
|
|
9
|
+
const path = require('node:path');
|
|
10
|
+
const { claudeHome } = require('./lib/schedulerPaths.cjs');
|
|
11
|
+
const { writeJsonAtomicSync, readJsonOrSync } = require('./lib/atomicFs.cjs');
|
|
12
|
+
const { generateE2EKeyPair, deriveSessionKey, deriveSas, encryptBox, decryptBox } = require('./lib/remoteE2eCrypto.cjs');
|
|
13
|
+
const { ALLOWED_COMMANDS } = require('./ipcSchemas.cjs');
|
|
14
|
+
|
|
15
|
+
// Hard-coded wss:// — same relay URL as webRemote.cjs, no plaintext downgrade.
|
|
16
|
+
const RELAY_WSS_URL = 'wss://bilko.run/projects/session-manager/relay';
|
|
17
|
+
const SEND_TIMEOUT_MS = 15_000;
|
|
18
|
+
const WS_OPEN = 1;
|
|
19
|
+
|
|
20
|
+
/** Default relay auth: the bilko.run relay has no desktop-client auth yet. */
|
|
21
|
+
const defaultRelayAuth = {
|
|
22
|
+
async getBrowserTicket() { throw new Error('relay_client_auth_unavailable'); },
|
|
23
|
+
async getDevicePubKey() { throw new Error('relay_client_auth_unavailable'); },
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function defaultWsFactory(url) {
|
|
27
|
+
const WebSocket = require('ws');
|
|
28
|
+
return new WebSocket(url, { rejectUnauthorized: true });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function defaultConfigPath() {
|
|
32
|
+
return path.join(claudeHome(), 'session-manager', 'remote-peers.json');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function createRemoteClient({ relayAuth = defaultRelayAuth, wsFactory = defaultWsFactory, configPath } = {}) {
|
|
36
|
+
const file = configPath || defaultConfigPath();
|
|
37
|
+
/** @type {Map<string, any>} peerId -> runtime state */
|
|
38
|
+
const runtime = new Map();
|
|
39
|
+
/** @type {Map<string, Set<Function>>} */
|
|
40
|
+
const subscribers = new Map();
|
|
41
|
+
|
|
42
|
+
function loadPeers() {
|
|
43
|
+
const data = readJsonOrSync(file, { peers: [] });
|
|
44
|
+
return Array.isArray(data?.peers) ? data.peers : [];
|
|
45
|
+
}
|
|
46
|
+
function savePeers(peers) {
|
|
47
|
+
writeJsonAtomicSync(file, { peers }, { mode: 0o600 });
|
|
48
|
+
}
|
|
49
|
+
function getPeer(peerId) {
|
|
50
|
+
return loadPeers().find((p) => p.peerId === peerId) || null;
|
|
51
|
+
}
|
|
52
|
+
function updatePeer(peerId, patch) {
|
|
53
|
+
const peers = loadPeers();
|
|
54
|
+
const i = peers.findIndex((p) => p.peerId === peerId);
|
|
55
|
+
if (i < 0) return;
|
|
56
|
+
peers[i] = { ...peers[i], ...patch };
|
|
57
|
+
savePeers(peers);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function rt(peerId) {
|
|
61
|
+
let s = runtime.get(peerId);
|
|
62
|
+
if (!s) {
|
|
63
|
+
s = {
|
|
64
|
+
status: 'idle', reason: null, sas: null, ws: null, sessionKey: null,
|
|
65
|
+
devicePubKey: null, pending: new Map(), attempt: 0,
|
|
66
|
+
};
|
|
67
|
+
runtime.set(peerId, s);
|
|
68
|
+
}
|
|
69
|
+
return s;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function failPending(s, err) {
|
|
73
|
+
for (const p of s.pending.values()) { clearTimeout(p.timer); p.reject(err); }
|
|
74
|
+
s.pending.clear();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function fail(peerId, reason) {
|
|
78
|
+
const s = rt(peerId);
|
|
79
|
+
s.status = 'failed';
|
|
80
|
+
s.reason = reason;
|
|
81
|
+
s.sessionKey = null;
|
|
82
|
+
s.sas = null;
|
|
83
|
+
failPending(s, new Error(reason));
|
|
84
|
+
const ws = s.ws;
|
|
85
|
+
s.ws = null;
|
|
86
|
+
try { ws?.close(); } catch { /* already closed */ }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function addPeer({ peerId, deviceId, machineName }) {
|
|
90
|
+
if (!peerId || !deviceId) throw new Error('peerId and deviceId required');
|
|
91
|
+
const peers = loadPeers();
|
|
92
|
+
const existing = peers.find((p) => p.peerId === peerId);
|
|
93
|
+
if (existing) return existing;
|
|
94
|
+
const kp = generateE2EKeyPair();
|
|
95
|
+
const peer = {
|
|
96
|
+
peerId,
|
|
97
|
+
deviceId,
|
|
98
|
+
machineName: machineName || '',
|
|
99
|
+
pairedAt: new Date().toISOString(),
|
|
100
|
+
clientPrivateKey: kp.e2ePrivateKey,
|
|
101
|
+
clientPublicKey: kp.e2ePublicKey,
|
|
102
|
+
verifiedDevicePubKey: null,
|
|
103
|
+
};
|
|
104
|
+
peers.push(peer);
|
|
105
|
+
savePeers(peers);
|
|
106
|
+
return peer;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function listPeers() {
|
|
110
|
+
// Never expose the private key to callers.
|
|
111
|
+
return loadPeers().map(({ clientPrivateKey, ...rest }) => rest);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function getStatus(peerId) {
|
|
115
|
+
const s = rt(peerId);
|
|
116
|
+
return { status: s.status, reason: s.reason, sas: s.sas };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function rawSend(ws, msg) {
|
|
120
|
+
ws.send(JSON.stringify(msg));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function handleInner(peerId, s, inner) {
|
|
124
|
+
if (!inner || typeof inner.type !== 'string') return;
|
|
125
|
+
if (inner.type.startsWith('resp:')) {
|
|
126
|
+
const p = s.pending.get(inner.id);
|
|
127
|
+
if (!p) return;
|
|
128
|
+
s.pending.delete(inner.id);
|
|
129
|
+
clearTimeout(p.timer);
|
|
130
|
+
p.resolve(inner.payload);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (inner.type.startsWith('event:')) {
|
|
134
|
+
for (const cb of subscribers.get(peerId) || []) {
|
|
135
|
+
try { cb(inner); } catch { /* subscriber errors never break the socket */ }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function handleMessage(peerId, s, peer, raw) {
|
|
141
|
+
let msg;
|
|
142
|
+
try { msg = JSON.parse(raw.toString()); } catch { return; }
|
|
143
|
+
if (!msg || typeof msg.type !== 'string') return;
|
|
144
|
+
|
|
145
|
+
if (msg.type === 'ping') {
|
|
146
|
+
if (s.ws && s.ws.readyState === WS_OPEN) rawSend(s.ws, { type: 'pong', id: msg.id, ts: Date.now() });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (msg.type === 'error') {
|
|
150
|
+
const code = msg.code || msg.payload?.code;
|
|
151
|
+
if (code === 'token_revoked') fail(peerId, 'token_revoked');
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (msg.type === 'auth:ok') {
|
|
155
|
+
// Relay accepted the ticket: start the E2E handshake.
|
|
156
|
+
rawSend(s.ws, {
|
|
157
|
+
type: 'e2e:hello',
|
|
158
|
+
id: crypto.randomUUID(),
|
|
159
|
+
deviceId: peer.deviceId,
|
|
160
|
+
payload: { pubKey: peer.clientPublicKey },
|
|
161
|
+
ts: Date.now(),
|
|
162
|
+
});
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (msg.type === 'e2e:ready') {
|
|
166
|
+
if (s.sessionKey) return;
|
|
167
|
+
try {
|
|
168
|
+
s.sessionKey = deriveSessionKey(peer.clientPrivateKey, s.devicePubKey, peer.deviceId);
|
|
169
|
+
s.sas = deriveSas(peer.clientPrivateKey, s.devicePubKey, peer.deviceId);
|
|
170
|
+
} catch {
|
|
171
|
+
fail(peerId, 'e2e_derivation_failed');
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
s.status = peer.verifiedDevicePubKey && peer.verifiedDevicePubKey === s.devicePubKey
|
|
175
|
+
? 'authenticated'
|
|
176
|
+
: 'pending_sas';
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (msg.type === 'e2e:box') {
|
|
180
|
+
if (!s.sessionKey) return;
|
|
181
|
+
const { nonce, ciphertext } = msg.payload || {};
|
|
182
|
+
if (!nonce || !ciphertext) return;
|
|
183
|
+
const plain = decryptBox(nonce, ciphertext, s.sessionKey);
|
|
184
|
+
if (!plain) return;
|
|
185
|
+
let inner;
|
|
186
|
+
try { inner = JSON.parse(plain); } catch { return; }
|
|
187
|
+
handleInner(peerId, s, inner);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function connect(peerId) {
|
|
192
|
+
const peer = getPeer(peerId);
|
|
193
|
+
if (!peer) throw new Error('unknown_peer');
|
|
194
|
+
const s = rt(peerId);
|
|
195
|
+
const attempt = ++s.attempt;
|
|
196
|
+
try { s.ws?.close(); } catch { /* already closed */ }
|
|
197
|
+
s.ws = null;
|
|
198
|
+
s.sessionKey = null;
|
|
199
|
+
s.sas = null;
|
|
200
|
+
s.reason = null;
|
|
201
|
+
s.status = 'connecting';
|
|
202
|
+
|
|
203
|
+
let ticket;
|
|
204
|
+
let devicePubKey;
|
|
205
|
+
try {
|
|
206
|
+
ticket = await relayAuth.getBrowserTicket(peer);
|
|
207
|
+
devicePubKey = await relayAuth.getDevicePubKey(peer.deviceId);
|
|
208
|
+
} catch (e) {
|
|
209
|
+
fail(peerId, e?.message || 'relay_auth_failed');
|
|
210
|
+
return getStatus(peerId);
|
|
211
|
+
}
|
|
212
|
+
if (attempt !== s.attempt) return getStatus(peerId); // superseded or removed
|
|
213
|
+
if (!ticket || !devicePubKey) {
|
|
214
|
+
fail(peerId, 'relay_auth_failed');
|
|
215
|
+
return getStatus(peerId);
|
|
216
|
+
}
|
|
217
|
+
// TOFU: a changed device key after pinning fails closed, before any socket opens.
|
|
218
|
+
if (peer.verifiedDevicePubKey && peer.verifiedDevicePubKey !== devicePubKey) {
|
|
219
|
+
fail(peerId, 'device_key_changed');
|
|
220
|
+
return getStatus(peerId);
|
|
221
|
+
}
|
|
222
|
+
s.devicePubKey = devicePubKey;
|
|
223
|
+
|
|
224
|
+
let ws;
|
|
225
|
+
try {
|
|
226
|
+
ws = wsFactory(`${RELAY_WSS_URL}?ticket=${encodeURIComponent(ticket)}`);
|
|
227
|
+
} catch {
|
|
228
|
+
fail(peerId, 'ws_create_failed');
|
|
229
|
+
return getStatus(peerId);
|
|
230
|
+
}
|
|
231
|
+
s.ws = ws;
|
|
232
|
+
ws.on('message', (data) => { if (s.ws === ws) handleMessage(peerId, s, peer, data); });
|
|
233
|
+
ws.on('close', () => {
|
|
234
|
+
if (s.ws !== ws) return;
|
|
235
|
+
s.ws = null;
|
|
236
|
+
s.sessionKey = null;
|
|
237
|
+
failPending(s, new Error('connection_closed'));
|
|
238
|
+
if (s.status !== 'failed') { s.status = 'idle'; s.sas = null; }
|
|
239
|
+
});
|
|
240
|
+
ws.on('error', () => { if (s.ws === ws) fail(peerId, 'ws_error'); });
|
|
241
|
+
return getStatus(peerId);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function confirmSas(peerId) {
|
|
245
|
+
const s = rt(peerId);
|
|
246
|
+
if (s.status !== 'pending_sas' || !s.devicePubKey) return { ok: false, reason: 'not_pending_sas' };
|
|
247
|
+
updatePeer(peerId, { verifiedDevicePubKey: s.devicePubKey });
|
|
248
|
+
s.status = 'authenticated';
|
|
249
|
+
return { ok: true };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function send(peerId, type, payload) {
|
|
253
|
+
if (typeof type !== 'string' || !ALLOWED_COMMANDS.has(type)) {
|
|
254
|
+
return Promise.reject(new Error('command_not_allowed'));
|
|
255
|
+
}
|
|
256
|
+
const s = rt(peerId);
|
|
257
|
+
if (!s.ws || s.ws.readyState !== WS_OPEN || !s.sessionKey) {
|
|
258
|
+
return Promise.reject(new Error('not_connected'));
|
|
259
|
+
}
|
|
260
|
+
const id = crypto.randomUUID();
|
|
261
|
+
const peer = getPeer(peerId);
|
|
262
|
+
if (!peer) return Promise.reject(new Error('unknown_peer'));
|
|
263
|
+
const inner = { type, id, payload: payload ?? {}, ts: Date.now() };
|
|
264
|
+
const { nonce, ciphertext } = encryptBox(JSON.stringify(inner), s.sessionKey);
|
|
265
|
+
return new Promise((resolve, reject) => {
|
|
266
|
+
const timer = setTimeout(() => {
|
|
267
|
+
s.pending.delete(id);
|
|
268
|
+
reject(new Error('timeout'));
|
|
269
|
+
}, SEND_TIMEOUT_MS);
|
|
270
|
+
s.pending.set(id, { resolve, reject, timer });
|
|
271
|
+
try {
|
|
272
|
+
rawSend(s.ws, { type: 'e2e:box', id, deviceId: peer.deviceId, payload: { nonce, ciphertext }, ts: Date.now() });
|
|
273
|
+
} catch (e) {
|
|
274
|
+
s.pending.delete(id);
|
|
275
|
+
clearTimeout(timer);
|
|
276
|
+
reject(e);
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function onEvent(peerId, cb) {
|
|
282
|
+
let set = subscribers.get(peerId);
|
|
283
|
+
if (!set) { set = new Set(); subscribers.set(peerId, set); }
|
|
284
|
+
set.add(cb);
|
|
285
|
+
return () => { set.delete(cb); };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function removePeer(peerId) {
|
|
289
|
+
const s = runtime.get(peerId);
|
|
290
|
+
if (s) {
|
|
291
|
+
s.attempt++;
|
|
292
|
+
failPending(s, new Error('peer_removed'));
|
|
293
|
+
try { s.ws?.close(); } catch { /* already closed */ }
|
|
294
|
+
runtime.delete(peerId);
|
|
295
|
+
}
|
|
296
|
+
subscribers.delete(peerId);
|
|
297
|
+
savePeers(loadPeers().filter((p) => p.peerId !== peerId));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return { addPeer, listPeers, connect, confirmSas, getStatus, send, onEvent, removePeer };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const remoteClient = createRemoteClient();
|
|
304
|
+
|
|
305
|
+
module.exports = { createRemoteClient, remoteClient, SEND_TIMEOUT_MS };
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* remoteClientIpc.cjs — IPC surface for the desktop-to-desktop remote client
|
|
5
|
+
* (remoteClient.cjs). Every payload is Zod-validated here, in main. Status
|
|
6
|
+
* changes and peer events are pushed to the renderer on `remoteClient:status`
|
|
7
|
+
* and `remoteClient:event`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { ipcMain } = require('electron');
|
|
11
|
+
const { z } = require('zod');
|
|
12
|
+
const { remoteClient } = require('./remoteClient.cjs');
|
|
13
|
+
const { ALLOWED_COMMANDS } = require('./ipcSchemas.cjs');
|
|
14
|
+
const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
|
|
15
|
+
|
|
16
|
+
const STATUS_POLL_MS = 1000;
|
|
17
|
+
|
|
18
|
+
const peerId = z.string().min(1).max(128);
|
|
19
|
+
const schemas = {
|
|
20
|
+
peer: z.object({ peerId }),
|
|
21
|
+
send: z.object({
|
|
22
|
+
peerId,
|
|
23
|
+
type: z.string().min(1).max(128).refine((t) => ALLOWED_COMMANDS.has(t), { message: 'command_not_allowed' }),
|
|
24
|
+
payload: z.unknown().optional(),
|
|
25
|
+
}),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function validated(schema, handler) {
|
|
29
|
+
return (_event, payload) => handler(schema.parse(payload));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function registerRemoteClientHandlers(getWindow, client = remoteClient) {
|
|
33
|
+
const unsubs = new Map(); // peerId -> unsubscribe for event forwarding
|
|
34
|
+
const lastStatus = new Map(); // peerId -> serialized status
|
|
35
|
+
|
|
36
|
+
const toWindow = (channel, payload) => sendIfAlive(getWindow(), channel, payload);
|
|
37
|
+
|
|
38
|
+
function publishStatus(id) {
|
|
39
|
+
const status = client.getStatus(id);
|
|
40
|
+
const key = JSON.stringify(status);
|
|
41
|
+
if (lastStatus.get(id) === key) return status;
|
|
42
|
+
lastStatus.set(id, key);
|
|
43
|
+
toWindow('remoteClient:status', { peerId: id, ...status });
|
|
44
|
+
return status;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function ensureForwarding(id) {
|
|
48
|
+
if (unsubs.has(id)) return;
|
|
49
|
+
unsubs.set(id, client.onEvent(id, (ev) => {
|
|
50
|
+
toWindow('remoteClient:event', { peerId: id, type: ev.type, payload: ev.payload });
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// remoteClient has no status callback; the socket moves status on its own
|
|
55
|
+
// (pending_sas, failed, idle), so poll for changes on known peers.
|
|
56
|
+
const timer = setInterval(() => {
|
|
57
|
+
for (const p of client.listPeers()) publishStatus(p.peerId);
|
|
58
|
+
}, STATUS_POLL_MS);
|
|
59
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
60
|
+
|
|
61
|
+
ipcMain.handle('remoteClient:list', () => client.listPeers().map((p) => {
|
|
62
|
+
ensureForwarding(p.peerId);
|
|
63
|
+
return { ...p, ...client.getStatus(p.peerId) };
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
ipcMain.handle('remoteClient:connect', validated(schemas.peer, async ({ peerId: id }) => {
|
|
67
|
+
ensureForwarding(id);
|
|
68
|
+
const connecting = client.connect(id);
|
|
69
|
+
publishStatus(id);
|
|
70
|
+
const status = await connecting;
|
|
71
|
+
publishStatus(id);
|
|
72
|
+
return status;
|
|
73
|
+
}));
|
|
74
|
+
|
|
75
|
+
ipcMain.handle('remoteClient:confirm-sas', validated(schemas.peer, ({ peerId: id }) => {
|
|
76
|
+
const res = client.confirmSas(id);
|
|
77
|
+
publishStatus(id);
|
|
78
|
+
return res;
|
|
79
|
+
}));
|
|
80
|
+
|
|
81
|
+
ipcMain.handle('remoteClient:remove', validated(schemas.peer, ({ peerId: id }) => {
|
|
82
|
+
client.removePeer(id);
|
|
83
|
+
const off = unsubs.get(id);
|
|
84
|
+
if (off) off();
|
|
85
|
+
unsubs.delete(id);
|
|
86
|
+
lastStatus.delete(id);
|
|
87
|
+
toWindow('remoteClient:status', { peerId: id, status: 'removed', reason: null, sas: null });
|
|
88
|
+
return { ok: true };
|
|
89
|
+
}));
|
|
90
|
+
|
|
91
|
+
ipcMain.handle('remoteClient:send', validated(schemas.send, ({ peerId: id, type, payload }) =>
|
|
92
|
+
client.send(id, type, payload)));
|
|
93
|
+
|
|
94
|
+
return () => {
|
|
95
|
+
clearInterval(timer);
|
|
96
|
+
for (const off of unsubs.values()) off();
|
|
97
|
+
unsubs.clear();
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = { registerRemoteClientHandlers, schemas };
|
package/src/main/webRemote.cjs
CHANGED
|
@@ -68,107 +68,8 @@ const MSG_MAX_BYTES = 256 * 1024;
|
|
|
68
68
|
const { ALLOWED_COMMANDS, MUTATE_COMMANDS, SAS_GATED_READS } = require('./ipcSchemas.cjs');
|
|
69
69
|
|
|
70
70
|
// ─── 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
|
-
}
|
|
71
|
+
// Implemented in ./lib/remoteE2eCrypto.cjs (shared with the desktop client role).
|
|
72
|
+
const { generateE2EKeyPair, deriveSessionKey, deriveSas, encryptBox, decryptBox } = require('./lib/remoteE2eCrypto.cjs');
|
|
172
73
|
|
|
173
74
|
// ─── Module state ────────────────────────────────────────────────────────────
|
|
174
75
|
|
|
@@ -317,7 +218,23 @@ async function getDeviceTicket(deviceToken) {
|
|
|
317
218
|
|
|
318
219
|
// ─── E2E state helpers ────────────────────────────────────────────────────────
|
|
319
220
|
|
|
221
|
+
// tabId → unsubscribe fn for the PtyManager output listener streaming to the peer.
|
|
222
|
+
const _remotePtyStreams = new Map();
|
|
223
|
+
|
|
224
|
+
function stopRemotePtyStream(tabId) {
|
|
225
|
+
const unsub = _remotePtyStreams.get(tabId);
|
|
226
|
+
_remotePtyStreams.delete(tabId);
|
|
227
|
+
if (unsub) {
|
|
228
|
+
try { unsub(); } catch { /* listener already gone */ }
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function stopAllRemotePtyStreams() {
|
|
233
|
+
for (const tabId of [..._remotePtyStreams.keys()]) stopRemotePtyStream(tabId);
|
|
234
|
+
}
|
|
235
|
+
|
|
320
236
|
function resetE2e(state = 'idle') {
|
|
237
|
+
stopAllRemotePtyStreams();
|
|
321
238
|
_e2e = makeState(state);
|
|
322
239
|
_e2ePeerPubKey = null;
|
|
323
240
|
_e2eDeviceId = null;
|
|
@@ -407,6 +324,7 @@ function scheduleReconnect() {
|
|
|
407
324
|
async function disconnect() {
|
|
408
325
|
cancelReconnect();
|
|
409
326
|
stopHeartbeat();
|
|
327
|
+
stopAllRemotePtyStreams();
|
|
410
328
|
resetE2e();
|
|
411
329
|
if (_ws) {
|
|
412
330
|
const ws = _ws;
|
|
@@ -526,6 +444,7 @@ async function connect() {
|
|
|
526
444
|
}
|
|
527
445
|
|
|
528
446
|
async function handleTokenRevoked(deviceId) {
|
|
447
|
+
stopAllRemotePtyStreams();
|
|
529
448
|
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'token revoked', meta: { deviceId } });
|
|
530
449
|
const cfg = await loadConfig();
|
|
531
450
|
const devices = (cfg.devices || []).filter((d) => d.deviceId !== deviceId);
|
|
@@ -712,6 +631,41 @@ function stopAllSessionWatches() {
|
|
|
712
631
|
if (_sessionListTimer) { clearInterval(_sessionListTimer); _sessionListTimer = null; }
|
|
713
632
|
}
|
|
714
633
|
|
|
634
|
+
/**
|
|
635
|
+
* Pure builder for the phone's session list: one project entry per tab, then
|
|
636
|
+
* each tab's ACTIVE Epics (from active-index.json via readIndex). Never throws.
|
|
637
|
+
*/
|
|
638
|
+
function buildRemoteSessionList(tabs, readIndex) {
|
|
639
|
+
const out = [];
|
|
640
|
+
const seenEpics = new Set();
|
|
641
|
+
for (const tab of Array.isArray(tabs) ? tabs : []) {
|
|
642
|
+
if (!tab) continue;
|
|
643
|
+
const project = tab.label || path.basename(tab.cwd || '');
|
|
644
|
+
out.push({ kind: 'project', tabId: tab.sessionId, cwd: tab.cwd, title: tab.label || tab.cwd, project });
|
|
645
|
+
let sessions = null;
|
|
646
|
+
try { sessions = readIndex(tab.cwd)?.sessions; } catch { sessions = null; }
|
|
647
|
+
if (!sessions || typeof sessions !== 'object') continue;
|
|
648
|
+
for (const epic of Object.values(sessions)) {
|
|
649
|
+
if (!epic || epic.status !== 'active') continue;
|
|
650
|
+
if (typeof epic.claudeSessionId !== 'string' || !epic.claudeSessionId) continue;
|
|
651
|
+
if (seenEpics.has(epic.id)) continue;
|
|
652
|
+
seenEpics.add(epic.id);
|
|
653
|
+
const firstLine = typeof epic.goalText === 'string' ? epic.goalText.split('\n')[0].trim().slice(0, 80).trim() : '';
|
|
654
|
+
out.push({
|
|
655
|
+
kind: 'epic',
|
|
656
|
+
tabId: epic.claudeSessionId,
|
|
657
|
+
epicId: epic.id,
|
|
658
|
+
cwd: epic.worktree?.dir || epic.cwd,
|
|
659
|
+
title: firstLine || epic.id,
|
|
660
|
+
project,
|
|
661
|
+
tag: epic.tag,
|
|
662
|
+
agentType: epic.agentType,
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
return out;
|
|
667
|
+
}
|
|
668
|
+
|
|
715
669
|
/** Push the current session list (reuses sessionsStore — the canonical source). */
|
|
716
670
|
async function pushSessionList() {
|
|
717
671
|
try {
|
|
@@ -727,13 +681,12 @@ async function pushSessionList() {
|
|
|
727
681
|
if (_e2e.state !== 'authenticated') return;
|
|
728
682
|
const sessionsStore = require('./sessionsStore.cjs');
|
|
729
683
|
const data = await sessionsStore.load();
|
|
730
|
-
//
|
|
684
|
+
// Tabs + their ACTIVE Epics → SessionMeta. tabId === claude sessionId so it
|
|
731
685
|
// matches the transcript JSONL name used by cmd:session:subscribe.
|
|
732
|
-
const
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
state: _sessionWatchers.get(t.sessionId)?.state ?? null,
|
|
686
|
+
const { readActiveIndex } = require('./lib/epicMint.cjs');
|
|
687
|
+
const sessions = buildRemoteSessionList(data?.tabs ?? [], readActiveIndex).map((s) => ({
|
|
688
|
+
...s,
|
|
689
|
+
state: _sessionWatchers.get(s.tabId)?.state ?? null,
|
|
737
690
|
}));
|
|
738
691
|
const payload = { sessions, activeTabId: data?.activeTabId ?? null };
|
|
739
692
|
const json = JSON.stringify(payload);
|
|
@@ -1072,7 +1025,25 @@ function getDispatchMap() {
|
|
|
1072
1025
|
// remotely-controlled 8 KiB string to spawn is a landmine if pty.cjs
|
|
1073
1026
|
// ever uses it. Remote callers have no legitimate need for it.
|
|
1074
1027
|
const { startupCommand: _ignored, ...safePayload } = parsed;
|
|
1075
|
-
|
|
1028
|
+
const result = await ptyManager.spawn(safePayload);
|
|
1029
|
+
// Stream output to the peer — replaces any prior subscription for this tab.
|
|
1030
|
+
// Chunk contents are sensitive: never logged.
|
|
1031
|
+
const tabId = safePayload.tabId;
|
|
1032
|
+
stopRemotePtyStream(tabId);
|
|
1033
|
+
const unsub = ptyManager.addOutputListener(
|
|
1034
|
+
tabId,
|
|
1035
|
+
(_id, data) => {
|
|
1036
|
+
if (_e2e.state !== 'authenticated' || !loadConfigSync().remoteEnabled) return;
|
|
1037
|
+
pushEvent('event:pty:data', { tabId, data });
|
|
1038
|
+
},
|
|
1039
|
+
(_id, info) => {
|
|
1040
|
+
if (_remotePtyStreams.get(tabId) === unsub) _remotePtyStreams.delete(tabId);
|
|
1041
|
+
if (_e2e.state !== 'authenticated' || !loadConfigSync().remoteEnabled) return;
|
|
1042
|
+
pushEvent('event:pty:exit', { tabId, exitCode: info?.exitCode, signal: info?.signal });
|
|
1043
|
+
},
|
|
1044
|
+
);
|
|
1045
|
+
_remotePtyStreams.set(tabId, unsub);
|
|
1046
|
+
return result;
|
|
1076
1047
|
},
|
|
1077
1048
|
|
|
1078
1049
|
'cmd:pty:write': async (payload) => {
|
|
@@ -1088,6 +1059,7 @@ function getDispatchMap() {
|
|
|
1088
1059
|
|
|
1089
1060
|
'cmd:pty:kill': async (payload) => {
|
|
1090
1061
|
const parsed = schemas.ptyTabId.parse(payload);
|
|
1062
|
+
stopRemotePtyStream(parsed.tabId);
|
|
1091
1063
|
ptyManager.kill(parsed.tabId);
|
|
1092
1064
|
return { ok: true };
|
|
1093
1065
|
},
|
|
@@ -1190,6 +1162,7 @@ async function pair(otp) {
|
|
|
1190
1162
|
}
|
|
1191
1163
|
|
|
1192
1164
|
async function revokeDevice(deviceId) {
|
|
1165
|
+
stopAllRemotePtyStreams();
|
|
1193
1166
|
invalidateConfigCache();
|
|
1194
1167
|
const cfg = await loadConfig();
|
|
1195
1168
|
const devices = (cfg.devices || []).filter((d) => d.deviceId !== deviceId);
|
|
@@ -1214,6 +1187,7 @@ async function revokeDevice(deviceId) {
|
|
|
1214
1187
|
* This is the local-side "kill everything" action (ADR §4.1).
|
|
1215
1188
|
*/
|
|
1216
1189
|
async function revokeAllDevices() {
|
|
1190
|
+
stopAllRemotePtyStreams();
|
|
1217
1191
|
const cfg = await loadConfig();
|
|
1218
1192
|
const revokedCount = (cfg.devices || []).length;
|
|
1219
1193
|
await saveConfig({ ...cfg, remoteEnabled: false, devices: [] });
|
|
@@ -1361,4 +1335,12 @@ module.exports = {
|
|
|
1361
1335
|
resetE2e,
|
|
1362
1336
|
getE2eState: () => _e2e,
|
|
1363
1337
|
},
|
|
1338
|
+
__test: {
|
|
1339
|
+
getDispatchMap,
|
|
1340
|
+
buildRemoteSessionList,
|
|
1341
|
+
setWs: (ws) => { _ws = ws; },
|
|
1342
|
+
setE2eAuthenticated: (key) => { _e2e = makeState('authenticated', key, null); },
|
|
1343
|
+
revokeAllDevices,
|
|
1344
|
+
streamCount: () => _remotePtyStreams.size,
|
|
1345
|
+
},
|
|
1364
1346
|
};
|