claude-code-session-manager 0.20.0 → 0.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{TiptapBody-COZHDXvn.js → TiptapBody-Db7_uXrI.js} +1 -1
- package/dist/assets/{cssMode-BGlgF50F.js → cssMode-DFKJhhi6.js} +1 -1
- package/dist/assets/{freemarker2-CwlJczaA.js → freemarker2-DUat8x8o.js} +1 -1
- package/dist/assets/{handlebars-C7ChleGP.js → handlebars-B2C1qhAI.js} +1 -1
- package/dist/assets/{html-C0XyedAq.js → html-khtg0DVs.js} +1 -1
- package/dist/assets/{htmlMode-DTJsOfuO.js → htmlMode-Jmhs-vfl.js} +1 -1
- package/dist/assets/{index-6poesY86.css → index-BkkBX1z7.css} +1 -1
- package/dist/assets/{index-C4joLNKY.js → index-pqnuXM14.js} +588 -578
- package/dist/assets/{javascript-CPRB5GUm.js → javascript-i1CXbgg4.js} +1 -1
- package/dist/assets/{jsonMode-DKBN0s8-.js → jsonMode-DXZaj-kR.js} +1 -1
- package/dist/assets/{liquid-CJmNIgnK.js → liquid-Ds7jUF53.js} +1 -1
- package/dist/assets/{lspLanguageFeatures-CIIba3v8.js → lspLanguageFeatures-B_15vO6X.js} +1 -1
- package/dist/assets/{mdx-BOiNk1a1.js → mdx-DgrrLgTE.js} +1 -1
- package/dist/assets/{python-5AV3HPYJ.js → python-Cff3tPw3.js} +1 -1
- package/dist/assets/{razor-6iMJA6dH.js → razor-DlyG7FmM.js} +1 -1
- package/dist/assets/{tsMode-WJISqg3-.js → tsMode-DRmmmttS.js} +1 -1
- package/dist/assets/{typescript-CnA0yZf9.js → typescript-DQFL2T1p.js} +1 -1
- package/dist/assets/{xml-BLkNwYO2.js → xml-CwsJEzdU.js} +1 -1
- package/dist/assets/{yaml-D6anZ1nO.js → yaml-BDsDjf-y.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +3 -1
- package/src/main/historyAggregator.cjs +15 -9
- package/src/main/index.cjs +7 -2
- package/src/main/ipcSchemas.cjs +43 -0
- package/src/main/kg.cjs +27 -17
- package/src/main/lib/reaperHelpers.cjs +67 -0
- package/src/main/lib/schedulerBatch.cjs +212 -0
- package/src/main/scheduler.cjs +173 -125
- package/src/main/webRemote.cjs +916 -0
- package/src/preload/api.d.ts +50 -9
- package/src/preload/index.cjs +34 -5
- package/src/main/projectSkills.cjs +0 -124
|
@@ -0,0 +1,916 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* webRemote.cjs — Local WebSocket agent for the web remote control channel.
|
|
5
|
+
*
|
|
6
|
+
* Security invariants (ARCHITECTURE.md §0, §6):
|
|
7
|
+
* - Outbound-only: opens a WS TO the relay, never listens.
|
|
8
|
+
* - OFF by default: remoteEnabled must be explicitly true in web-remote.json.
|
|
9
|
+
* - Kill switch: synchronous — drops socket + refuses all commands instantly.
|
|
10
|
+
* - Strict allowlist: 15 enumerated command types; unknown → silent drop.
|
|
11
|
+
* - Zod validation: every payload parsed before any dispatch.
|
|
12
|
+
* - Path safety: cwd/path fields go through validatePath (home-dir boundary).
|
|
13
|
+
* - Token at rest: web-remote.json written at 0600 via writeTextAtomic.
|
|
14
|
+
* - TLS mandatory: relay URL hard-coded wss://; no downgrade path.
|
|
15
|
+
* - Audit log: every dispatched command logged locally, no secret values.
|
|
16
|
+
* - E2E encryption: P-256 ECDH + AES-256-GCM; relay sees only ciphertext.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const { ipcMain, app } = require('electron');
|
|
20
|
+
const WebSocket = require('ws');
|
|
21
|
+
const https = require('node:https');
|
|
22
|
+
const crypto = require('node:crypto');
|
|
23
|
+
const os = require('node:os');
|
|
24
|
+
const path = require('node:path');
|
|
25
|
+
const fsp = require('node:fs/promises');
|
|
26
|
+
const fs = require('node:fs');
|
|
27
|
+
const { writeTextAtomic, validatePath } = require('./config.cjs');
|
|
28
|
+
const logs = require('./logs.cjs');
|
|
29
|
+
const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
|
|
30
|
+
const { schemas } = require('./ipcSchemas.cjs');
|
|
31
|
+
|
|
32
|
+
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
// Hard-coded wss:// — no configuration allows plaintext downgrade (ADR §5.1).
|
|
35
|
+
const RELAY_HTTPS_BASE = 'https://relay.session-manager.bilko.run';
|
|
36
|
+
const RELAY_WSS_ORIGIN = 'wss://relay.session-manager.bilko.run';
|
|
37
|
+
|
|
38
|
+
const CONFIG_PATH = path.join(
|
|
39
|
+
os.homedir(), '.claude', 'session-manager', 'web-remote.json'
|
|
40
|
+
);
|
|
41
|
+
const AUDIT_LOG_DIR = path.join(
|
|
42
|
+
os.homedir(), '.claude', 'session-manager', 'logs'
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
// Reconnect backoff: init 1s, x2, cap 60s, ±20% jitter (ADR §2.4).
|
|
46
|
+
const BACKOFF_INIT_MS = 1_000;
|
|
47
|
+
const BACKOFF_MAX_MS = 60_000;
|
|
48
|
+
const BACKOFF_MULT = 2;
|
|
49
|
+
|
|
50
|
+
// Heartbeat (ADR §2.3)
|
|
51
|
+
const PING_INTERVAL_MS = 30_000;
|
|
52
|
+
const PONG_TIMEOUT_MS = 10_000;
|
|
53
|
+
const MAX_MISSED_PONGS = 3;
|
|
54
|
+
|
|
55
|
+
// Config re-read TTL: 1s so the kill-switch propagates within one second.
|
|
56
|
+
const CONFIG_CACHE_TTL_MS = 1_000;
|
|
57
|
+
|
|
58
|
+
// Max message size: 256 KiB (matches PRD_WRITE_MAX_BYTES, ADR §2.5).
|
|
59
|
+
const MSG_MAX_BYTES = 256 * 1024;
|
|
60
|
+
|
|
61
|
+
// ─── Command allowlist ───────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
// Single source of truth lives in ipcSchemas.cjs — imported here so the test
|
|
64
|
+
// can verify the same Set without depending on Electron-linked modules.
|
|
65
|
+
const { ALLOWED_COMMANDS } = require('./ipcSchemas.cjs');
|
|
66
|
+
|
|
67
|
+
// ─── E2E encryption helpers (P-256 ECDH + AES-256-GCM, ADR §5.2) ────────────
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Generate a P-256 ECDH keypair. Public key is SPKI DER base64url; private key
|
|
71
|
+
* is PKCS8 DER base64url. Both are stored in web-remote.json at 0600.
|
|
72
|
+
*/
|
|
73
|
+
function generateE2EKeyPair() {
|
|
74
|
+
const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', {
|
|
75
|
+
namedCurve: 'P-256',
|
|
76
|
+
publicKeyEncoding: { type: 'spki', format: 'der' },
|
|
77
|
+
privateKeyEncoding: { type: 'pkcs8', format: 'der' },
|
|
78
|
+
});
|
|
79
|
+
return {
|
|
80
|
+
e2ePrivateKey: privateKey.toString('base64url'),
|
|
81
|
+
e2ePublicKey: publicKey.toString('base64url'),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Derive an AES-256-GCM session key from two P-256 public keys via ECDH + HKDF.
|
|
87
|
+
* @param myPrivateKeyB64 Agent's PKCS8 private key, base64url DER
|
|
88
|
+
* @param peerPublicKeyB64 Browser's SPKI public key, base64url DER
|
|
89
|
+
* @param deviceId Used as HKDF salt for domain separation
|
|
90
|
+
* @returns 32-byte Buffer (AES-256-GCM key)
|
|
91
|
+
*/
|
|
92
|
+
function deriveSessionKey(myPrivateKeyB64, peerPublicKeyB64, deviceId) {
|
|
93
|
+
const myPrivKey = crypto.createPrivateKey({
|
|
94
|
+
key: Buffer.from(myPrivateKeyB64, 'base64url'),
|
|
95
|
+
format: 'der',
|
|
96
|
+
type: 'pkcs8',
|
|
97
|
+
});
|
|
98
|
+
const peerPubKey = crypto.createPublicKey({
|
|
99
|
+
key: Buffer.from(peerPublicKeyB64, 'base64url'),
|
|
100
|
+
format: 'der',
|
|
101
|
+
type: 'spki',
|
|
102
|
+
});
|
|
103
|
+
const sharedSecret = crypto.diffieHellman({ privateKey: myPrivKey, publicKey: peerPubKey });
|
|
104
|
+
// HKDF: salt = deviceId bytes for domain separation, info = fixed protocol label.
|
|
105
|
+
const salt = Buffer.from(deviceId, 'utf8');
|
|
106
|
+
const info = Buffer.from('sm-e2e-v1', 'utf8');
|
|
107
|
+
return Buffer.from(crypto.hkdfSync('sha256', sharedSecret, salt, info, 32));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Encrypt a plaintext JSON string into an AES-256-GCM box.
|
|
112
|
+
* @returns { nonce: base64url, ciphertext: base64url } — the nonce is 12 random bytes
|
|
113
|
+
*/
|
|
114
|
+
function encryptBox(plaintext, sessionKey) {
|
|
115
|
+
const nonce = crypto.randomBytes(12);
|
|
116
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', sessionKey, nonce);
|
|
117
|
+
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
118
|
+
const tag = cipher.getAuthTag(); // 16-byte GCM authentication tag
|
|
119
|
+
return {
|
|
120
|
+
nonce: nonce.toString('base64url'),
|
|
121
|
+
// Append the GCM tag to the ciphertext so decryptBox can locate it.
|
|
122
|
+
ciphertext: Buffer.concat([encrypted, tag]).toString('base64url'),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Decrypt an AES-256-GCM box produced by encryptBox (or the browser's equivalent).
|
|
128
|
+
* @returns Decrypted UTF-8 string, or null if authentication fails.
|
|
129
|
+
*/
|
|
130
|
+
function decryptBox(nonceB64, ciphertextB64, sessionKey) {
|
|
131
|
+
try {
|
|
132
|
+
const nonce = Buffer.from(nonceB64, 'base64url');
|
|
133
|
+
const data = Buffer.from(ciphertextB64, 'base64url');
|
|
134
|
+
if (data.length < 16) return null; // too short to contain tag
|
|
135
|
+
const tag = data.subarray(data.length - 16);
|
|
136
|
+
const ciphertext = data.subarray(0, data.length - 16);
|
|
137
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', sessionKey, nonce);
|
|
138
|
+
decipher.setAuthTag(tag);
|
|
139
|
+
const plain = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
|
|
140
|
+
return plain;
|
|
141
|
+
} catch {
|
|
142
|
+
return null; // authentication failed — drop the message
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ─── Module state ────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
let _window = null;
|
|
149
|
+
let _ws = null;
|
|
150
|
+
let _reconnectTimer = null;
|
|
151
|
+
let _backoffMs = BACKOFF_INIT_MS;
|
|
152
|
+
let _pingTimer = null;
|
|
153
|
+
let _pongTimer = null;
|
|
154
|
+
let _missedPongs = 0;
|
|
155
|
+
let _configCache = null;
|
|
156
|
+
let _configCacheAt = 0;
|
|
157
|
+
let _destroyed = false; // set at app shutdown to stop reconnect loops
|
|
158
|
+
|
|
159
|
+
// E2E session state — reset on each new WS connection.
|
|
160
|
+
let _e2eSessionKey = null; // Buffer | null
|
|
161
|
+
|
|
162
|
+
// ─── Config helpers ───────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
function defaultConfig() {
|
|
165
|
+
return { remoteEnabled: false, devices: [] };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function loadConfigSync() {
|
|
169
|
+
const now = Date.now();
|
|
170
|
+
if (_configCache && now - _configCacheAt < CONFIG_CACHE_TTL_MS) {
|
|
171
|
+
return _configCache;
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
|
|
175
|
+
_configCache = { ...defaultConfig(), ...JSON.parse(raw) };
|
|
176
|
+
} catch {
|
|
177
|
+
_configCache = defaultConfig();
|
|
178
|
+
}
|
|
179
|
+
_configCacheAt = now;
|
|
180
|
+
return _configCache;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function loadConfig() {
|
|
184
|
+
const now = Date.now();
|
|
185
|
+
if (_configCache && now - _configCacheAt < CONFIG_CACHE_TTL_MS) {
|
|
186
|
+
return _configCache;
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
const raw = await fsp.readFile(CONFIG_PATH, 'utf8');
|
|
190
|
+
_configCache = { ...defaultConfig(), ...JSON.parse(raw) };
|
|
191
|
+
} catch {
|
|
192
|
+
_configCache = defaultConfig();
|
|
193
|
+
}
|
|
194
|
+
_configCacheAt = now;
|
|
195
|
+
return _configCache;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function invalidateConfigCache() {
|
|
199
|
+
_configCacheAt = 0;
|
|
200
|
+
_configCache = null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Writes the config atomically at mode 0600 (ADR §4 — equivalent to ~/.ssh/id_rsa).
|
|
204
|
+
async function saveConfig(data) {
|
|
205
|
+
const pretty = JSON.stringify(data, null, 2) + '\n';
|
|
206
|
+
await writeTextAtomic(CONFIG_PATH, pretty, { mode: 0o600 });
|
|
207
|
+
invalidateConfigCache();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ─── Audit log ────────────────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
// Format: <ISO> <type> deviceId=<id> msgId=<uuid> result=ok|error:<code>
|
|
213
|
+
// NEVER log token values or payload content.
|
|
214
|
+
async function auditLog(ts, type, deviceId, msgId, result) {
|
|
215
|
+
try {
|
|
216
|
+
const ymd = ts.slice(0, 10);
|
|
217
|
+
const logPath = path.join(AUDIT_LOG_DIR, `remote-audit-${ymd}.log`);
|
|
218
|
+
const line = `${ts} ${type} deviceId=${deviceId || '-'} msgId=${msgId || '-'} result=${result}\n`;
|
|
219
|
+
const handle = await fsp.open(logPath, 'a', 0o600);
|
|
220
|
+
try {
|
|
221
|
+
await handle.write(line);
|
|
222
|
+
} finally {
|
|
223
|
+
await handle.close();
|
|
224
|
+
}
|
|
225
|
+
} catch (e) {
|
|
226
|
+
logs.writeLine({
|
|
227
|
+
scope: 'webRemote', level: 'warn',
|
|
228
|
+
message: 'audit log write failed', meta: { error: e?.message },
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ─── HTTPS helpers ────────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
function httpsPost(url, body, headers = {}) {
|
|
236
|
+
return new Promise((resolve, reject) => {
|
|
237
|
+
const parsed = new URL(url);
|
|
238
|
+
if (parsed.protocol !== 'https:') {
|
|
239
|
+
reject(new Error('Only https:// allowed for relay API calls'));
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const req = https.request({
|
|
243
|
+
hostname: parsed.hostname,
|
|
244
|
+
port: parsed.port || 443,
|
|
245
|
+
path: parsed.pathname + (parsed.search || ''),
|
|
246
|
+
method: 'POST',
|
|
247
|
+
headers: {
|
|
248
|
+
'Content-Type': 'application/json',
|
|
249
|
+
'Content-Length': Buffer.byteLength(body),
|
|
250
|
+
...headers,
|
|
251
|
+
},
|
|
252
|
+
rejectUnauthorized: true, // verify relay TLS cert
|
|
253
|
+
}, (res) => {
|
|
254
|
+
let data = '';
|
|
255
|
+
res.on('data', (c) => { data += c; });
|
|
256
|
+
res.on('end', () => {
|
|
257
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
258
|
+
try { resolve(JSON.parse(data)); } catch { resolve({}); }
|
|
259
|
+
} else {
|
|
260
|
+
let errMsg = `HTTP ${res.statusCode}`;
|
|
261
|
+
try { errMsg = JSON.parse(data).error || errMsg; } catch { /* */ }
|
|
262
|
+
reject(new Error(errMsg));
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
req.on('error', reject);
|
|
267
|
+
req.setTimeout(15_000, () => { req.destroy(new Error('timeout')); });
|
|
268
|
+
req.write(body);
|
|
269
|
+
req.end();
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// POST /api/device-ticket to exchange device-token for a one-time WS ticket.
|
|
274
|
+
async function getDeviceTicket(deviceToken) {
|
|
275
|
+
const result = await httpsPost(
|
|
276
|
+
`${RELAY_HTTPS_BASE}/api/device-ticket`,
|
|
277
|
+
'{}',
|
|
278
|
+
{ Authorization: `Bearer ${deviceToken}` }
|
|
279
|
+
);
|
|
280
|
+
if (!result.ticket) throw new Error('relay returned no ticket');
|
|
281
|
+
return result.ticket;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ─── WebSocket lifecycle ──────────────────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
function broadcastStatus() {
|
|
287
|
+
if (!_window || _window.isDestroyed()) return;
|
|
288
|
+
const cfg = loadConfigSync();
|
|
289
|
+
const connected = _ws !== null && _ws.readyState === WebSocket.OPEN;
|
|
290
|
+
sendIfAlive(_window, 'webRemote:status', {
|
|
291
|
+
enabled: cfg.remoteEnabled,
|
|
292
|
+
connected,
|
|
293
|
+
e2eActive: connected && _e2eSessionKey !== null,
|
|
294
|
+
devices: (cfg.devices || []).map(({ deviceId, deviceName, issuedAt, lastConnectedAt }) => ({
|
|
295
|
+
deviceId, deviceName, issuedAt, lastConnectedAt,
|
|
296
|
+
})),
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function stopHeartbeat() {
|
|
301
|
+
if (_pingTimer) { clearInterval(_pingTimer); _pingTimer = null; }
|
|
302
|
+
if (_pongTimer) { clearTimeout(_pongTimer); _pongTimer = null; }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function startHeartbeat() {
|
|
306
|
+
stopHeartbeat();
|
|
307
|
+
_pingTimer = setInterval(() => {
|
|
308
|
+
if (!_ws || _ws.readyState !== WebSocket.OPEN) { stopHeartbeat(); return; }
|
|
309
|
+
const pingId = crypto.randomUUID();
|
|
310
|
+
_ws.send(JSON.stringify({ type: 'ping', id: pingId, ts: Date.now() }));
|
|
311
|
+
_pongTimer = setTimeout(() => {
|
|
312
|
+
_missedPongs++;
|
|
313
|
+
logs.writeLine({
|
|
314
|
+
scope: 'webRemote', level: 'warn',
|
|
315
|
+
message: 'missed pong', meta: { missed: _missedPongs },
|
|
316
|
+
});
|
|
317
|
+
if (_missedPongs >= MAX_MISSED_PONGS) {
|
|
318
|
+
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'closing after missed pongs' });
|
|
319
|
+
_ws?.terminate();
|
|
320
|
+
}
|
|
321
|
+
}, PONG_TIMEOUT_MS);
|
|
322
|
+
}, PING_INTERVAL_MS);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function cancelReconnect() {
|
|
326
|
+
if (_reconnectTimer) { clearTimeout(_reconnectTimer); _reconnectTimer = null; }
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Full jitter: delay = random(0, min(cap, base * mult^n))
|
|
330
|
+
function nextBackoffMs() {
|
|
331
|
+
const raw = Math.min(_backoffMs, BACKOFF_MAX_MS);
|
|
332
|
+
const jitter = 0.8 + Math.random() * 0.4; // ±20%
|
|
333
|
+
const next = Math.floor(raw * jitter);
|
|
334
|
+
_backoffMs = Math.min(_backoffMs * BACKOFF_MULT, BACKOFF_MAX_MS);
|
|
335
|
+
return next;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function scheduleReconnect() {
|
|
339
|
+
if (_destroyed) return;
|
|
340
|
+
cancelReconnect();
|
|
341
|
+
const delay = nextBackoffMs();
|
|
342
|
+
logs.writeLine({
|
|
343
|
+
scope: 'webRemote', level: 'info',
|
|
344
|
+
message: 'reconnect scheduled', meta: { delayMs: delay },
|
|
345
|
+
});
|
|
346
|
+
_reconnectTimer = setTimeout(() => {
|
|
347
|
+
_reconnectTimer = null;
|
|
348
|
+
connect().catch((e) => {
|
|
349
|
+
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'connect failed', meta: { error: e?.message } });
|
|
350
|
+
});
|
|
351
|
+
}, delay);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function disconnect() {
|
|
355
|
+
cancelReconnect();
|
|
356
|
+
stopHeartbeat();
|
|
357
|
+
_e2eSessionKey = null;
|
|
358
|
+
if (_ws) {
|
|
359
|
+
const ws = _ws;
|
|
360
|
+
_ws = null;
|
|
361
|
+
try { ws.terminate(); } catch { /* already closed */ }
|
|
362
|
+
}
|
|
363
|
+
broadcastStatus();
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function connect() {
|
|
367
|
+
if (_destroyed) return;
|
|
368
|
+
|
|
369
|
+
const cfg = await loadConfig();
|
|
370
|
+
if (!cfg.remoteEnabled) return;
|
|
371
|
+
|
|
372
|
+
const devices = cfg.devices || [];
|
|
373
|
+
const device = devices.find((d) => d.deviceToken);
|
|
374
|
+
if (!device) {
|
|
375
|
+
logs.writeLine({ scope: 'webRemote', level: 'info', message: 'no paired device; not connecting' });
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Step 1: get a single-use WS ticket via the device token.
|
|
380
|
+
let ticket;
|
|
381
|
+
try {
|
|
382
|
+
ticket = await getDeviceTicket(device.deviceToken);
|
|
383
|
+
} catch (e) {
|
|
384
|
+
logs.writeLine({
|
|
385
|
+
scope: 'webRemote', level: 'warn',
|
|
386
|
+
message: 'device ticket request failed', meta: { error: e?.message },
|
|
387
|
+
});
|
|
388
|
+
scheduleReconnect();
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Step 2: open WSS connection with the ticket.
|
|
393
|
+
let ws;
|
|
394
|
+
try {
|
|
395
|
+
ws = new WebSocket(`${RELAY_WSS_ORIGIN}/ws?ticket=${encodeURIComponent(ticket)}`, {
|
|
396
|
+
rejectUnauthorized: true, // verify relay TLS cert
|
|
397
|
+
});
|
|
398
|
+
} catch (e) {
|
|
399
|
+
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'ws create failed', meta: { error: e?.message } });
|
|
400
|
+
scheduleReconnect();
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
_ws = ws;
|
|
405
|
+
_missedPongs = 0;
|
|
406
|
+
_e2eSessionKey = null; // reset session key on new connection
|
|
407
|
+
|
|
408
|
+
ws.on('open', () => {
|
|
409
|
+
logs.writeLine({ scope: 'webRemote', level: 'info', message: 'connected to relay' });
|
|
410
|
+
_backoffMs = BACKOFF_INIT_MS;
|
|
411
|
+
_missedPongs = 0;
|
|
412
|
+
startHeartbeat();
|
|
413
|
+
// Update lastConnectedAt without exposing token
|
|
414
|
+
loadConfig().then(async (c) => {
|
|
415
|
+
const devs = (c.devices || []).map((d) =>
|
|
416
|
+
d.deviceId === device.deviceId
|
|
417
|
+
? { ...d, lastConnectedAt: new Date().toISOString() }
|
|
418
|
+
: d
|
|
419
|
+
);
|
|
420
|
+
await saveConfig({ ...c, devices: devs });
|
|
421
|
+
broadcastStatus();
|
|
422
|
+
}).catch(() => {});
|
|
423
|
+
broadcastStatus();
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
ws.on('message', (raw) => {
|
|
427
|
+
if (raw.length > MSG_MAX_BYTES) {
|
|
428
|
+
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'oversized message dropped' });
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
handleMessage(raw.toString(), device).catch((e) => {
|
|
432
|
+
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'handleMessage error', meta: { error: e?.message } });
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
ws.on('close', (code) => {
|
|
437
|
+
stopHeartbeat();
|
|
438
|
+
_e2eSessionKey = null;
|
|
439
|
+
if (_ws === ws) _ws = null;
|
|
440
|
+
logs.writeLine({ scope: 'webRemote', level: 'info', message: 'ws closed', meta: { code } });
|
|
441
|
+
broadcastStatus();
|
|
442
|
+
|
|
443
|
+
if (code === 4001) {
|
|
444
|
+
// Token revoked by relay — stop reconnecting, clear token (ADR §4.1).
|
|
445
|
+
handleTokenRevoked(device.deviceId).catch(() => {});
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (!_destroyed) scheduleReconnect();
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
ws.on('error', (e) => {
|
|
453
|
+
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'ws error', meta: { error: e?.message } });
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async function handleTokenRevoked(deviceId) {
|
|
458
|
+
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'token revoked', meta: { deviceId } });
|
|
459
|
+
const cfg = await loadConfig();
|
|
460
|
+
const devices = (cfg.devices || []).filter((d) => d.deviceId !== deviceId);
|
|
461
|
+
await saveConfig({ ...cfg, remoteEnabled: false, devices });
|
|
462
|
+
broadcastStatus();
|
|
463
|
+
sendIfAlive(_window, 'webRemote:token-revoked', { deviceId });
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// ─── Message handling & command dispatch ─────────────────────────────────────
|
|
467
|
+
|
|
468
|
+
async function handleMessage(raw, device) {
|
|
469
|
+
let envelope;
|
|
470
|
+
try {
|
|
471
|
+
envelope = JSON.parse(raw);
|
|
472
|
+
} catch {
|
|
473
|
+
return; // malformed JSON — drop silently
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const { type, id, payload } = envelope;
|
|
477
|
+
if (typeof type !== 'string') return;
|
|
478
|
+
|
|
479
|
+
// Handle relay control messages
|
|
480
|
+
if (type === 'pong') {
|
|
481
|
+
if (_pongTimer) { clearTimeout(_pongTimer); _pongTimer = null; }
|
|
482
|
+
_missedPongs = 0;
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
if (type === 'ping') {
|
|
486
|
+
if (_ws && _ws.readyState === WebSocket.OPEN) {
|
|
487
|
+
_ws.send(JSON.stringify({ type: 'pong', id, ts: Date.now() }));
|
|
488
|
+
}
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
if (type === 'auth:ok') {
|
|
492
|
+
logs.writeLine({ scope: 'webRemote', level: 'info', message: 'auth:ok from relay' });
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
if (type === 'error') {
|
|
496
|
+
const code = envelope.code || payload?.code;
|
|
497
|
+
if (code === 'token_revoked') {
|
|
498
|
+
const ws = _ws;
|
|
499
|
+
_ws = null;
|
|
500
|
+
try { ws?.terminate(); } catch { /* */ }
|
|
501
|
+
handleTokenRevoked(device.deviceId).catch(() => {});
|
|
502
|
+
}
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// ── E2E key exchange ───────────────────────────────────────────────────────
|
|
507
|
+
// Browser sends e2e:hello with its ephemeral P-256 public key (SPKI base64url).
|
|
508
|
+
// We compute the shared session key and acknowledge.
|
|
509
|
+
if (type === 'e2e:hello') {
|
|
510
|
+
const browserPubKey = payload?.pubKey;
|
|
511
|
+
if (!browserPubKey || typeof browserPubKey !== 'string') {
|
|
512
|
+
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'e2e:hello missing pubKey' });
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
// P-256 SPKI DER is 91 bytes → base64url ~122 chars; reject out-of-range blobs
|
|
516
|
+
// before they reach crypto.createPublicKey (malformed input throws and is caught,
|
|
517
|
+
// but repeated bad keys force plaintext fallback via the keep-e2e enforcement above).
|
|
518
|
+
const PUB_KEY_RE = /^[A-Za-z0-9+/=_-]+$/;
|
|
519
|
+
if (browserPubKey.length < 80 || browserPubKey.length > 256 || !PUB_KEY_RE.test(browserPubKey)) {
|
|
520
|
+
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'e2e:hello invalid pubKey format' });
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
if (!device.e2ePrivateKey) {
|
|
524
|
+
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'e2e:hello but no device private key — skipping E2E' });
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
try {
|
|
528
|
+
_e2eSessionKey = deriveSessionKey(device.e2ePrivateKey, browserPubKey, device.deviceId);
|
|
529
|
+
logs.writeLine({ scope: 'webRemote', level: 'info', message: 'E2E session key established' });
|
|
530
|
+
broadcastStatus();
|
|
531
|
+
// Acknowledge with e2e:ready (unencrypted — session just started)
|
|
532
|
+
respond(id, undefined, 'e2e:ready');
|
|
533
|
+
} catch (e) {
|
|
534
|
+
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'E2E key derivation failed', meta: { error: e?.message } });
|
|
535
|
+
_e2eSessionKey = null;
|
|
536
|
+
}
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// ── Decrypt e2e:box messages ──────────────────────────────────────────────
|
|
541
|
+
if (type === 'e2e:box') {
|
|
542
|
+
if (!_e2eSessionKey) {
|
|
543
|
+
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'e2e:box received but no session key — dropping' });
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
const { nonce, ciphertext } = payload || {};
|
|
547
|
+
if (!nonce || !ciphertext) return;
|
|
548
|
+
const plaintext = decryptBox(nonce, ciphertext, _e2eSessionKey);
|
|
549
|
+
if (!plaintext) {
|
|
550
|
+
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'e2e:box decryption failed (auth tag mismatch)' });
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
// Replace envelope with the decrypted inner command and continue dispatch.
|
|
554
|
+
let inner;
|
|
555
|
+
try {
|
|
556
|
+
inner = JSON.parse(plaintext);
|
|
557
|
+
} catch {
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
// Dispatch as if it arrived unencrypted
|
|
561
|
+
await dispatchEnvelope(inner, device);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Only dispatch cmd:* type messages
|
|
566
|
+
if (!type.startsWith('cmd:')) return;
|
|
567
|
+
// After E2E is established, reject plaintext commands — a malicious relay cannot
|
|
568
|
+
// silently downgrade the session by stripping e2e:hello (PENTEST.md §H1).
|
|
569
|
+
if (_e2eSessionKey) {
|
|
570
|
+
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'plaintext cmd rejected — e2e session active' });
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
await dispatchEnvelope(envelope, device);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
async function dispatchEnvelope(envelope, device) {
|
|
577
|
+
const { type, id, payload } = envelope;
|
|
578
|
+
if (typeof type !== 'string' || !type.startsWith('cmd:')) return;
|
|
579
|
+
|
|
580
|
+
const ts = new Date().toISOString();
|
|
581
|
+
|
|
582
|
+
// Kill switch — re-reads config with 1s TTL
|
|
583
|
+
const cfg = await loadConfig();
|
|
584
|
+
if (!cfg.remoteEnabled) {
|
|
585
|
+
await auditLog(ts, type, device.deviceId, id, 'error:disabled');
|
|
586
|
+
respond(id, { error: 'disabled' });
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Allowlist check — unknown types are dropped without error feedback (ADR §6.2)
|
|
591
|
+
if (!ALLOWED_COMMANDS.has(type)) {
|
|
592
|
+
await auditLog(ts, type, device.deviceId, id, 'error:not_allowed');
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Dispatch
|
|
597
|
+
let result;
|
|
598
|
+
try {
|
|
599
|
+
result = await dispatchCommand(type, payload ?? {});
|
|
600
|
+
await auditLog(ts, type, device.deviceId, id, 'ok');
|
|
601
|
+
} catch (e) {
|
|
602
|
+
const code = e?.name === 'ZodError' ? 'schema_invalid' : 'dispatch_error';
|
|
603
|
+
await auditLog(ts, type, device.deviceId, id, `error:${code}`);
|
|
604
|
+
result = { error: code }; // never leak internal error messages to the remote caller
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
respond(id, result);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function respond(msgId, payload, typeOverride) {
|
|
611
|
+
if (!_ws || _ws.readyState !== WebSocket.OPEN) return;
|
|
612
|
+
const responseType = typeOverride || (msgId ? `resp:${msgId}` : undefined);
|
|
613
|
+
if (!responseType) return;
|
|
614
|
+
|
|
615
|
+
const inner = {
|
|
616
|
+
type: responseType,
|
|
617
|
+
id: msgId,
|
|
618
|
+
payload,
|
|
619
|
+
ts: Date.now(),
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
try {
|
|
623
|
+
// Encrypt the response if a session key is active.
|
|
624
|
+
if (_e2eSessionKey && !typeOverride) {
|
|
625
|
+
const { nonce, ciphertext } = encryptBox(JSON.stringify(inner), _e2eSessionKey);
|
|
626
|
+
_ws.send(JSON.stringify({
|
|
627
|
+
type: 'e2e:box',
|
|
628
|
+
id: msgId,
|
|
629
|
+
payload: { nonce, ciphertext },
|
|
630
|
+
ts: Date.now(),
|
|
631
|
+
}));
|
|
632
|
+
} else {
|
|
633
|
+
_ws.send(JSON.stringify(inner));
|
|
634
|
+
}
|
|
635
|
+
} catch (e) {
|
|
636
|
+
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'respond send failed', meta: { error: e?.message } });
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Lazy-loaded dispatch map — avoids circular require at module load time.
|
|
641
|
+
let _dispatchMap = null;
|
|
642
|
+
|
|
643
|
+
function getDispatchMap() {
|
|
644
|
+
if (_dispatchMap) return _dispatchMap;
|
|
645
|
+
|
|
646
|
+
const { manager: ptyManager } = require('./pty.cjs');
|
|
647
|
+
const sessionsStore = require('./sessionsStore.cjs');
|
|
648
|
+
const scheduler = require('./scheduler.cjs');
|
|
649
|
+
const { remote: histRemote } = require('./historyAggregator.cjs');
|
|
650
|
+
|
|
651
|
+
_dispatchMap = {
|
|
652
|
+
'cmd:sessions:load': async () =>
|
|
653
|
+
sessionsStore.load(),
|
|
654
|
+
|
|
655
|
+
'cmd:sessions:save': async (payload) => {
|
|
656
|
+
const parsed = schemas.sessionsPayload.parse(payload);
|
|
657
|
+
return sessionsStore.save(parsed);
|
|
658
|
+
},
|
|
659
|
+
|
|
660
|
+
'cmd:pty:spawn': async (payload) => {
|
|
661
|
+
const parsed = schemas.ptySpawn.parse(payload);
|
|
662
|
+
// Path safety — validatePath rejects anything outside home dir.
|
|
663
|
+
validatePath(parsed.cwd);
|
|
664
|
+
// Strip startupCommand: it is ignored by pty.cjs today, but passing a
|
|
665
|
+
// remotely-controlled 8 KiB string to spawn is a landmine if pty.cjs
|
|
666
|
+
// ever uses it. Remote callers have no legitimate need for it.
|
|
667
|
+
const { startupCommand: _ignored, ...safePayload } = parsed;
|
|
668
|
+
return ptyManager.spawn(safePayload);
|
|
669
|
+
},
|
|
670
|
+
|
|
671
|
+
'cmd:pty:write': async (payload) => {
|
|
672
|
+
const parsed = schemas.ptyWrite.parse(payload);
|
|
673
|
+
ptyManager.write(parsed);
|
|
674
|
+
return { ok: true };
|
|
675
|
+
},
|
|
676
|
+
|
|
677
|
+
'cmd:pty:resize': async (payload) => {
|
|
678
|
+
const parsed = schemas.ptyResize.parse(payload);
|
|
679
|
+
ptyManager.resize(parsed);
|
|
680
|
+
return { ok: true };
|
|
681
|
+
},
|
|
682
|
+
|
|
683
|
+
'cmd:pty:kill': async (payload) => {
|
|
684
|
+
const parsed = schemas.ptyTabId.parse(payload);
|
|
685
|
+
ptyManager.kill(parsed.tabId);
|
|
686
|
+
return { ok: true };
|
|
687
|
+
},
|
|
688
|
+
|
|
689
|
+
'cmd:schedule:state': async () =>
|
|
690
|
+
scheduler.remote.getState(),
|
|
691
|
+
|
|
692
|
+
'cmd:schedule:read-prd': async (payload) => {
|
|
693
|
+
const parsed = schemas.scheduleSlug.parse(payload);
|
|
694
|
+
return scheduler.remote.readPrd(parsed.slug);
|
|
695
|
+
},
|
|
696
|
+
|
|
697
|
+
'cmd:schedule:read-log': async (payload) => {
|
|
698
|
+
const parsed = schemas.scheduleReadLog.parse(payload);
|
|
699
|
+
return scheduler.remote.readLog(parsed.slug, parsed.runId);
|
|
700
|
+
},
|
|
701
|
+
|
|
702
|
+
'cmd:schedule:write-prd': async (payload) => {
|
|
703
|
+
const parsed = schemas.scheduleWritePrd.parse(payload);
|
|
704
|
+
return scheduler.remote.writePrd(parsed.slug, parsed.body);
|
|
705
|
+
},
|
|
706
|
+
|
|
707
|
+
'cmd:schedule:reset-job': async (payload) => {
|
|
708
|
+
const parsed = schemas.scheduleSlug.parse(payload);
|
|
709
|
+
return scheduler.remote.resetJob(parsed.slug);
|
|
710
|
+
},
|
|
711
|
+
|
|
712
|
+
'cmd:schedule:run-now': async () =>
|
|
713
|
+
scheduler.remote.runNow(),
|
|
714
|
+
|
|
715
|
+
'cmd:schedule:set-config': async (payload) => {
|
|
716
|
+
const parsed = schemas.setConfigSchema.default({}).parse(payload ?? {});
|
|
717
|
+
return scheduler.remote.setConfig(parsed);
|
|
718
|
+
},
|
|
719
|
+
|
|
720
|
+
'cmd:history:aggregate': async (payload) => {
|
|
721
|
+
const parsed = schemas.historyAggregate.parse(payload);
|
|
722
|
+
return histRemote.aggregate(parsed);
|
|
723
|
+
},
|
|
724
|
+
|
|
725
|
+
'cmd:app:version': async () =>
|
|
726
|
+
app.getVersion(),
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
return _dispatchMap;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
async function dispatchCommand(type, payload) {
|
|
733
|
+
const map = getDispatchMap();
|
|
734
|
+
const handler = map[type];
|
|
735
|
+
if (!handler) throw new Error(`no handler for ${type}`);
|
|
736
|
+
return handler(payload);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// ─── Pairing ─────────────────────────────────────────────────────────────────
|
|
740
|
+
|
|
741
|
+
async function pair(otp) {
|
|
742
|
+
const deviceId = crypto.randomUUID();
|
|
743
|
+
|
|
744
|
+
// Generate E2E keypair at pair time — public key is sent to relay and stored
|
|
745
|
+
// alongside the device token so the browser can do key agreement.
|
|
746
|
+
const { e2ePrivateKey, e2ePublicKey } = generateE2EKeyPair();
|
|
747
|
+
|
|
748
|
+
const body = JSON.stringify({
|
|
749
|
+
code: otp.trim().toUpperCase(),
|
|
750
|
+
deviceId,
|
|
751
|
+
devicePubKey: e2ePublicKey,
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
let response;
|
|
755
|
+
try {
|
|
756
|
+
response = await httpsPost(`${RELAY_HTTPS_BASE}/pair`, body);
|
|
757
|
+
} catch (e) {
|
|
758
|
+
return { ok: false, error: e?.message || 'pairing request failed' };
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
if (!response.deviceToken || !response.deviceId) {
|
|
762
|
+
return { ok: false, error: 'relay returned no device token' };
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const cfg = await loadConfig();
|
|
766
|
+
const devices = cfg.devices || [];
|
|
767
|
+
devices.push({
|
|
768
|
+
deviceId: response.deviceId,
|
|
769
|
+
deviceToken: response.deviceToken, // stored only on disk at 0600
|
|
770
|
+
// Private key stored at 0600 — same security model as device token.
|
|
771
|
+
e2ePrivateKey,
|
|
772
|
+
e2ePublicKey,
|
|
773
|
+
deviceName: `Device (paired ${new Date().toISOString().slice(0, 10)})`,
|
|
774
|
+
issuedAt: new Date().toISOString(),
|
|
775
|
+
lastConnectedAt: null,
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
await saveConfig({ ...cfg, devices });
|
|
779
|
+
|
|
780
|
+
if (cfg.remoteEnabled) {
|
|
781
|
+
connect().catch(() => {});
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// Return only non-secret fields to the renderer
|
|
785
|
+
return { ok: true, deviceId: response.deviceId };
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
async function revokeDevice(deviceId) {
|
|
789
|
+
invalidateConfigCache();
|
|
790
|
+
const cfg = await loadConfig();
|
|
791
|
+
const devices = (cfg.devices || []).filter((d) => d.deviceId !== deviceId);
|
|
792
|
+
await saveConfig({ ...cfg, devices });
|
|
793
|
+
|
|
794
|
+
// If the active connection is for this device, disconnect
|
|
795
|
+
if (_ws && _ws.readyState === WebSocket.OPEN) {
|
|
796
|
+
await disconnect();
|
|
797
|
+
// Reconnect to a different device if any remain
|
|
798
|
+
if (devices.length > 0 && cfg.remoteEnabled) {
|
|
799
|
+
connect().catch(() => {});
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
broadcastStatus();
|
|
804
|
+
return { ok: true };
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Panic / revoke-all: immediately disconnect the relay WS, clear all device
|
|
809
|
+
* entries from web-remote.json, and set remoteEnabled = false.
|
|
810
|
+
* This is the local-side "kill everything" action (ADR §4.1).
|
|
811
|
+
*/
|
|
812
|
+
async function revokeAllDevices() {
|
|
813
|
+
const cfg = await loadConfig();
|
|
814
|
+
const revokedCount = (cfg.devices || []).length;
|
|
815
|
+
await saveConfig({ ...cfg, remoteEnabled: false, devices: [] });
|
|
816
|
+
await disconnect(); // tears down WS + clears session key
|
|
817
|
+
broadcastStatus();
|
|
818
|
+
sendIfAlive(_window, 'webRemote:revoked-all', { revokedCount });
|
|
819
|
+
return { ok: true };
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// ─── IPC handlers ─────────────────────────────────────────────────────────────
|
|
823
|
+
|
|
824
|
+
function registerRemoteHandlers() {
|
|
825
|
+
const { validated } = require('./ipcSchemas.cjs');
|
|
826
|
+
|
|
827
|
+
// Returns current status without tokens — safe to expose to renderer.
|
|
828
|
+
ipcMain.handle('webRemote:get-status', async () => {
|
|
829
|
+
const cfg = await loadConfig();
|
|
830
|
+
return {
|
|
831
|
+
enabled: cfg.remoteEnabled,
|
|
832
|
+
connected: _ws !== null && _ws.readyState === WebSocket.OPEN,
|
|
833
|
+
e2eActive: _ws !== null && _ws.readyState === WebSocket.OPEN && _e2eSessionKey !== null,
|
|
834
|
+
devices: (cfg.devices || []).map(({ deviceId, deviceName, issuedAt, lastConnectedAt }) => ({
|
|
835
|
+
deviceId, deviceName, issuedAt, lastConnectedAt,
|
|
836
|
+
})),
|
|
837
|
+
};
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
ipcMain.handle('webRemote:enable', async () => {
|
|
841
|
+
const cfg = await loadConfig();
|
|
842
|
+
await saveConfig({ ...cfg, remoteEnabled: true });
|
|
843
|
+
connect().catch(() => {});
|
|
844
|
+
broadcastStatus();
|
|
845
|
+
return { ok: true };
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
ipcMain.handle('webRemote:disable', async () => {
|
|
849
|
+
const cfg = await loadConfig();
|
|
850
|
+
await saveConfig({ ...cfg, remoteEnabled: false });
|
|
851
|
+
await disconnect();
|
|
852
|
+
broadcastStatus();
|
|
853
|
+
return { ok: true };
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
ipcMain.handle('webRemote:pair', validated(schemas.webRemotePair, async ({ otp }) => {
|
|
857
|
+
return pair(otp);
|
|
858
|
+
}));
|
|
859
|
+
|
|
860
|
+
ipcMain.handle('webRemote:revoke-device', validated(schemas.webRemoteRevokeDevice, async ({ deviceId }) => {
|
|
861
|
+
return revokeDevice(deviceId);
|
|
862
|
+
}));
|
|
863
|
+
|
|
864
|
+
// Panic button: revoke all devices, disable remote, disconnect immediately.
|
|
865
|
+
ipcMain.handle('webRemote:revoke-all', async () => {
|
|
866
|
+
return revokeAllDevices();
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
ipcMain.handle('webRemote:audit-tail', validated(schemas.webRemoteAuditTail, async ({ lines }) => {
|
|
870
|
+
const lineCount = lines || 50;
|
|
871
|
+
const ymd = new Date().toISOString().slice(0, 10);
|
|
872
|
+
const logPath = path.join(AUDIT_LOG_DIR, `remote-audit-${ymd}.log`);
|
|
873
|
+
try {
|
|
874
|
+
const text = await fsp.readFile(logPath, 'utf8');
|
|
875
|
+
const all = text.split('\n').filter(Boolean);
|
|
876
|
+
return { ok: true, lines: all.slice(-lineCount) };
|
|
877
|
+
} catch (e) {
|
|
878
|
+
if (e?.code === 'ENOENT') return { ok: true, lines: [] };
|
|
879
|
+
return { ok: false, error: e?.message };
|
|
880
|
+
}
|
|
881
|
+
}));
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// ─── Module lifecycle ────────────────────────────────────────────────────────
|
|
885
|
+
|
|
886
|
+
function attachWindow(w) {
|
|
887
|
+
_window = w;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
async function init() {
|
|
891
|
+
await fsp.mkdir(AUDIT_LOG_DIR, { recursive: true });
|
|
892
|
+
const cfg = await loadConfig();
|
|
893
|
+
if (cfg.remoteEnabled && (cfg.devices || []).some((d) => d.deviceToken)) {
|
|
894
|
+
connect().catch((e) => {
|
|
895
|
+
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'init connect failed', meta: { error: e?.message } });
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function destroy() {
|
|
901
|
+
_destroyed = true;
|
|
902
|
+
cancelReconnect();
|
|
903
|
+
_e2eSessionKey = null;
|
|
904
|
+
if (_ws) {
|
|
905
|
+
try { _ws.terminate(); } catch { /* */ }
|
|
906
|
+
_ws = null;
|
|
907
|
+
}
|
|
908
|
+
stopHeartbeat();
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
module.exports = {
|
|
912
|
+
attachWindow,
|
|
913
|
+
registerRemoteHandlers,
|
|
914
|
+
init,
|
|
915
|
+
destroy,
|
|
916
|
+
};
|