claude-code-session-manager 0.62.0 → 0.63.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-BeJJa_zv.js +1 -0
- package/dist/assets/History-mWbqemhZ.js +2 -0
- package/dist/assets/Hooks-D_8sciZu.js +3 -0
- package/dist/assets/HostBilko-6aUJ7Smz.js +1 -0
- package/dist/assets/Library-CG1LDXLw.js +46 -0
- package/dist/assets/ListDetail-CIIJOWwI.js +1 -0
- package/dist/assets/MarkdownEditor-D0-y8V-f.js +1 -0
- package/dist/assets/McpServers-Kc-djiVi.js +2 -0
- package/dist/assets/Memory-B60DGRTW.js +8 -0
- package/dist/assets/Panel-CbTPsYHq.js +1 -0
- package/dist/assets/Permissions-C8KrSfrd.js +3 -0
- package/dist/assets/Plugins-BT8EumHb.js +2 -0
- package/dist/assets/ProvenanceBadge-cEqPpFsT.js +1 -0
- package/dist/assets/Scheduler-DbKPd0or.js +14 -0
- package/dist/assets/ScopeSwitcher-BNesCJ_t.js +1 -0
- package/dist/assets/Settings-Bjt7AQ6G.js +3 -0
- package/dist/assets/SkillReferenceGraph-DzuaPDM-.js +46 -0
- package/dist/assets/Skills-CVL_4X3T.js +3 -0
- package/dist/assets/SystemPrompt-Ct__nFF1.js +1 -0
- package/dist/assets/TagLibrary-DmrTfw4D.js +1 -0
- package/dist/assets/{TiptapBody-Bn-LMce4.js → TiptapBody-DaS0M_Ni.js} +1 -1
- package/dist/assets/Toggle-C0a-6xV7.js +1 -0
- package/dist/assets/index-CO_7DroC.js +3066 -0
- package/dist/assets/{index-DOXvph-Z.css → index-LlWpj2VJ.css} +1 -1
- package/dist/assets/listSkills-QORduPIk.js +1 -0
- package/dist/assets/settingsSchema-B7dMJaix.js +3 -0
- package/dist/assets/skillFrontmatter-Dif5JIg7.js +10 -0
- package/dist/index.html +2 -2
- package/package.json +1 -3
- package/src/main/__tests__/config-readText-bounded.test.cjs +84 -0
- package/src/main/__tests__/heapSnapshot.test.cjs +121 -0
- package/src/main/__tests__/historyAggregatorIntraday.test.cjs +313 -0
- package/src/main/__tests__/runLogRetention.test.cjs +343 -0
- package/src/main/__tests__/runVerify-transcript-commit-evidence.test.cjs +215 -0
- package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +42 -1
- package/src/main/__tests__/scheduler-prd-missing-skip.test.cjs +54 -1
- package/src/main/__tests__/transcripts-batch-flush.test.cjs +249 -0
- package/src/main/__tests__/transcripts-doFlush-array.test.cjs +6 -0
- package/src/main/__tests__/transcripts-paged-reads.test.cjs +6 -0
- package/src/main/__tests__/transcriptsUsageFor.test.cjs +6 -0
- package/src/main/config.cjs +32 -29
- package/src/main/files.cjs +20 -0
- package/src/main/health.cjs +32 -0
- package/src/main/heapSnapshot.cjs +122 -0
- package/src/main/historyAggregator.cjs +176 -36
- package/src/main/index.cjs +10 -63
- package/src/main/ipcSchemas.cjs +20 -209
- package/src/main/lib/opsOwnership.cjs +0 -2
- package/src/main/lib/runLogRetention.cjs +358 -0
- package/src/main/lib/summarize.cjs +0 -1
- package/src/main/runVerify.cjs +55 -6
- package/src/main/scheduler.cjs +46 -7
- package/src/main/transcripts.cjs +29 -5
- package/src/preload/api.d.ts +18 -199
- package/src/preload/index.cjs +12 -102
- package/dist/assets/index-BfiMRkln.js +0 -3225
- package/src/main/__tests__/browserAgentServer.test.cjs +0 -235
- package/src/main/__tests__/browserView-destroyed-handler.test.cjs +0 -85
- package/src/main/__tests__/browserView-oauth-popup.test.cjs +0 -126
- package/src/main/__tests__/web-remote-e2e-pinning.test.cjs +0 -181
- package/src/main/browserAgentServer.cjs +0 -224
- package/src/main/browserCapture.cjs +0 -728
- package/src/main/browserView.cjs +0 -916
- package/src/main/lib/__tests__/browserAgentActions.test.cjs +0 -76
- package/src/main/lib/browserAgentActions.cjs +0 -114
- package/src/main/lib/e2eStateMachine.cjs +0 -39
- package/src/main/webRemote.cjs +0 -1367
- package/src/preload/browserViewPreload.cjs +0 -118
package/src/main/webRemote.cjs
DELETED
|
@@ -1,1367 +0,0 @@
|
|
|
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: enumerated command types; unknown → opaque rejected response.
|
|
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
|
-
const { summarize: _sharedSummarize } = require('./lib/summarize.cjs');
|
|
32
|
-
const { makeState, confirmSas: confirmSasLogic } = require('./lib/e2eStateMachine.cjs');
|
|
33
|
-
|
|
34
|
-
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
35
|
-
|
|
36
|
-
// Hard-coded wss:// — no configuration allows plaintext downgrade (ADR §5.1).
|
|
37
|
-
// v2: relay is same-origin on bilko.run (ARCHITECTURE-V2-MOBILE.md §1). REST under
|
|
38
|
-
// /api/sm-relay; WS upgrade at /projects/session-manager/relay (covered by host CSP
|
|
39
|
-
// connect-src 'self').
|
|
40
|
-
const RELAY_HTTPS_BASE = 'https://bilko.run';
|
|
41
|
-
const RELAY_API_BASE = `${RELAY_HTTPS_BASE}/api/sm-relay`;
|
|
42
|
-
const RELAY_WSS_URL = 'wss://bilko.run/projects/session-manager/relay';
|
|
43
|
-
|
|
44
|
-
const CONFIG_PATH = path.join(
|
|
45
|
-
os.homedir(), '.claude', 'session-manager', 'web-remote.json'
|
|
46
|
-
);
|
|
47
|
-
const AUDIT_LOG_DIR = path.join(
|
|
48
|
-
os.homedir(), '.claude', 'session-manager', 'logs'
|
|
49
|
-
);
|
|
50
|
-
|
|
51
|
-
// Reconnect backoff: init 1s, x2, cap 60s, ±20% jitter (ADR §2.4).
|
|
52
|
-
const BACKOFF_INIT_MS = 1_000;
|
|
53
|
-
const BACKOFF_MAX_MS = 60_000;
|
|
54
|
-
const BACKOFF_MULT = 2;
|
|
55
|
-
|
|
56
|
-
// Heartbeat (ADR §2.3)
|
|
57
|
-
const PING_INTERVAL_MS = 30_000;
|
|
58
|
-
const PONG_TIMEOUT_MS = 10_000;
|
|
59
|
-
const MAX_MISSED_PONGS = 3;
|
|
60
|
-
|
|
61
|
-
// Config re-read TTL: 1s so the kill-switch propagates within one second.
|
|
62
|
-
const CONFIG_CACHE_TTL_MS = 1_000;
|
|
63
|
-
|
|
64
|
-
// Max message size: 256 KiB (matches PRD_WRITE_MAX_BYTES, ADR §2.5).
|
|
65
|
-
const MSG_MAX_BYTES = 256 * 1024;
|
|
66
|
-
|
|
67
|
-
// ─── Command allowlist ───────────────────────────────────────────────────────
|
|
68
|
-
|
|
69
|
-
// Single source of truth lives in ipcSchemas.cjs — imported here so the test
|
|
70
|
-
// can verify the same Set without depending on Electron-linked modules.
|
|
71
|
-
const { ALLOWED_COMMANDS, MUTATE_COMMANDS, SAS_GATED_READS } = require('./ipcSchemas.cjs');
|
|
72
|
-
|
|
73
|
-
// ─── E2E encryption helpers (P-256 ECDH + AES-256-GCM, ADR §5.2) ────────────
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Generate a P-256 ECDH keypair. Public key is SPKI DER base64url; private key
|
|
77
|
-
* is PKCS8 DER base64url. Both are stored in web-remote.json at 0600.
|
|
78
|
-
*/
|
|
79
|
-
function generateE2EKeyPair() {
|
|
80
|
-
const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', {
|
|
81
|
-
namedCurve: 'P-256',
|
|
82
|
-
publicKeyEncoding: { type: 'spki', format: 'der' },
|
|
83
|
-
privateKeyEncoding: { type: 'pkcs8', format: 'der' },
|
|
84
|
-
});
|
|
85
|
-
return {
|
|
86
|
-
e2ePrivateKey: privateKey.toString('base64url'),
|
|
87
|
-
e2ePublicKey: publicKey.toString('base64url'),
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Derive an AES-256-GCM session key from two P-256 public keys via ECDH + HKDF.
|
|
93
|
-
* @param myPrivateKeyB64 Agent's PKCS8 private key, base64url DER
|
|
94
|
-
* @param peerPublicKeyB64 Browser's SPKI public key, base64url DER
|
|
95
|
-
* @param deviceId Used as HKDF salt for domain separation
|
|
96
|
-
* @returns 32-byte Buffer (AES-256-GCM key)
|
|
97
|
-
*/
|
|
98
|
-
function deriveSessionKey(myPrivateKeyB64, peerPublicKeyB64, deviceId) {
|
|
99
|
-
const myPrivKey = crypto.createPrivateKey({
|
|
100
|
-
key: Buffer.from(myPrivateKeyB64, 'base64url'),
|
|
101
|
-
format: 'der',
|
|
102
|
-
type: 'pkcs8',
|
|
103
|
-
});
|
|
104
|
-
const peerPubKey = crypto.createPublicKey({
|
|
105
|
-
key: Buffer.from(peerPublicKeyB64, 'base64url'),
|
|
106
|
-
format: 'der',
|
|
107
|
-
type: 'spki',
|
|
108
|
-
});
|
|
109
|
-
const sharedSecret = crypto.diffieHellman({ privateKey: myPrivKey, publicKey: peerPubKey });
|
|
110
|
-
// HKDF: salt = deviceId bytes for domain separation, info = fixed protocol label.
|
|
111
|
-
const salt = Buffer.from(deviceId, 'utf8');
|
|
112
|
-
const info = Buffer.from('sm-e2e-v1', 'utf8');
|
|
113
|
-
return Buffer.from(crypto.hkdfSync('sha256', sharedSecret, salt, info, 32));
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Derive a 6-digit Short Authentication String from the ECDH shared secret.
|
|
118
|
-
* Uses a separate HKDF info label ('sm-sas-v1') so the SAS is independent of
|
|
119
|
-
* the session key. Both sides compute the same value; user confirms they match.
|
|
120
|
-
*/
|
|
121
|
-
function deriveSas(myPrivateKeyB64, peerPublicKeyB64, deviceId) {
|
|
122
|
-
const myPrivKey = crypto.createPrivateKey({
|
|
123
|
-
key: Buffer.from(myPrivateKeyB64, 'base64url'),
|
|
124
|
-
format: 'der',
|
|
125
|
-
type: 'pkcs8',
|
|
126
|
-
});
|
|
127
|
-
const peerPubKey = crypto.createPublicKey({
|
|
128
|
-
key: Buffer.from(peerPublicKeyB64, 'base64url'),
|
|
129
|
-
format: 'der',
|
|
130
|
-
type: 'spki',
|
|
131
|
-
});
|
|
132
|
-
const sharedSecret = crypto.diffieHellman({ privateKey: myPrivKey, publicKey: peerPubKey });
|
|
133
|
-
const salt = Buffer.from(deviceId, 'utf8');
|
|
134
|
-
const info = Buffer.from('sm-sas-v1', 'utf8');
|
|
135
|
-
const sasBytes = Buffer.from(crypto.hkdfSync('sha256', sharedSecret, salt, info, 3));
|
|
136
|
-
const sasNum = ((sasBytes[0] << 16) | (sasBytes[1] << 8) | sasBytes[2]) % 1_000_000;
|
|
137
|
-
return sasNum.toString().padStart(6, '0');
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Encrypt a plaintext JSON string into an AES-256-GCM box.
|
|
142
|
-
* @returns { nonce: base64url, ciphertext: base64url } — the nonce is 12 random bytes
|
|
143
|
-
*/
|
|
144
|
-
function encryptBox(plaintext, sessionKey) {
|
|
145
|
-
const nonce = crypto.randomBytes(12);
|
|
146
|
-
const cipher = crypto.createCipheriv('aes-256-gcm', sessionKey, nonce);
|
|
147
|
-
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
148
|
-
const tag = cipher.getAuthTag(); // 16-byte GCM authentication tag
|
|
149
|
-
return {
|
|
150
|
-
nonce: nonce.toString('base64url'),
|
|
151
|
-
// Append the GCM tag to the ciphertext so decryptBox can locate it.
|
|
152
|
-
ciphertext: Buffer.concat([encrypted, tag]).toString('base64url'),
|
|
153
|
-
};
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Decrypt an AES-256-GCM box produced by encryptBox (or the browser's equivalent).
|
|
158
|
-
* @returns Decrypted UTF-8 string, or null if authentication fails.
|
|
159
|
-
*/
|
|
160
|
-
function decryptBox(nonceB64, ciphertextB64, sessionKey) {
|
|
161
|
-
try {
|
|
162
|
-
const nonce = Buffer.from(nonceB64, 'base64url');
|
|
163
|
-
const data = Buffer.from(ciphertextB64, 'base64url');
|
|
164
|
-
if (data.length < 16) return null; // too short to contain tag
|
|
165
|
-
const tag = data.subarray(data.length - 16);
|
|
166
|
-
const ciphertext = data.subarray(0, data.length - 16);
|
|
167
|
-
const decipher = crypto.createDecipheriv('aes-256-gcm', sessionKey, nonce);
|
|
168
|
-
decipher.setAuthTag(tag);
|
|
169
|
-
const plain = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
|
|
170
|
-
return plain;
|
|
171
|
-
} catch {
|
|
172
|
-
return null; // authentication failed — drop the message
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// ─── Module state ────────────────────────────────────────────────────────────
|
|
177
|
-
|
|
178
|
-
let _window = null;
|
|
179
|
-
let _ws = null;
|
|
180
|
-
let _reconnectTimer = null;
|
|
181
|
-
let _backoffMs = BACKOFF_INIT_MS;
|
|
182
|
-
let _pingTimer = null;
|
|
183
|
-
let _pongTimer = null;
|
|
184
|
-
let _missedPongs = 0;
|
|
185
|
-
let _configCache = null;
|
|
186
|
-
let _configCacheAt = 0;
|
|
187
|
-
let _destroyed = false; // set at app shutdown to stop reconnect loops
|
|
188
|
-
|
|
189
|
-
// E2E session state — reset on each new WS connection.
|
|
190
|
-
// .state: 'idle' | 'pending_sas' | 'authenticated' | 'failed'
|
|
191
|
-
let _e2e = makeState();
|
|
192
|
-
// Browser SPKI pubkey (base64url) that produced the current _e2e session, and the
|
|
193
|
-
// deviceId it was received on — tracked so a manual SAS confirmation can pin it to
|
|
194
|
-
// that device's `verifiedPeerPubKey` for auto-trust on future reconnects.
|
|
195
|
-
let _e2ePeerPubKey = null;
|
|
196
|
-
let _e2eDeviceId = null;
|
|
197
|
-
|
|
198
|
-
// ─── Config helpers ───────────────────────────────────────────────────────────
|
|
199
|
-
|
|
200
|
-
function defaultConfig() {
|
|
201
|
-
return { remoteEnabled: false, remoteControlEnabled: false, devices: [] };
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
function loadConfigSync() {
|
|
205
|
-
const now = Date.now();
|
|
206
|
-
if (_configCache && now - _configCacheAt < CONFIG_CACHE_TTL_MS) {
|
|
207
|
-
return _configCache;
|
|
208
|
-
}
|
|
209
|
-
try {
|
|
210
|
-
const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
|
|
211
|
-
_configCache = { ...defaultConfig(), ...JSON.parse(raw) };
|
|
212
|
-
} catch {
|
|
213
|
-
_configCache = defaultConfig();
|
|
214
|
-
}
|
|
215
|
-
_configCacheAt = now;
|
|
216
|
-
return _configCache;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
async function loadConfig() {
|
|
220
|
-
const now = Date.now();
|
|
221
|
-
if (_configCache && now - _configCacheAt < CONFIG_CACHE_TTL_MS) {
|
|
222
|
-
return _configCache;
|
|
223
|
-
}
|
|
224
|
-
try {
|
|
225
|
-
const raw = await fsp.readFile(CONFIG_PATH, 'utf8');
|
|
226
|
-
_configCache = { ...defaultConfig(), ...JSON.parse(raw) };
|
|
227
|
-
} catch {
|
|
228
|
-
_configCache = defaultConfig();
|
|
229
|
-
}
|
|
230
|
-
_configCacheAt = now;
|
|
231
|
-
return _configCache;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
function invalidateConfigCache() {
|
|
235
|
-
_configCacheAt = 0;
|
|
236
|
-
_configCache = null;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// Writes the config atomically at mode 0600 (ADR §4 — equivalent to ~/.ssh/id_rsa).
|
|
240
|
-
async function saveConfig(data) {
|
|
241
|
-
const pretty = JSON.stringify(data, null, 2) + '\n';
|
|
242
|
-
await writeTextAtomic(CONFIG_PATH, pretty, { mode: 0o600 });
|
|
243
|
-
invalidateConfigCache();
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
// ─── Audit log ────────────────────────────────────────────────────────────────
|
|
247
|
-
|
|
248
|
-
// Format: <ISO> <type> deviceId=<id> msgId=<uuid> result=ok|error:<code>
|
|
249
|
-
// NEVER log token values or payload content.
|
|
250
|
-
async function auditLog(ts, type, deviceId, msgId, result) {
|
|
251
|
-
try {
|
|
252
|
-
const ymd = ts.slice(0, 10);
|
|
253
|
-
const logPath = path.join(AUDIT_LOG_DIR, `remote-audit-${ymd}.log`);
|
|
254
|
-
const clean = (s) => String(s ?? '').replace(/[\r\n\t]+/g, ' ').slice(0, 200);
|
|
255
|
-
const line = `${ts} ${clean(type)} deviceId=${clean(deviceId) || '-'} msgId=${clean(msgId) || '-'} result=${result}\n`;
|
|
256
|
-
const handle = await fsp.open(logPath, 'a', 0o600);
|
|
257
|
-
try {
|
|
258
|
-
await handle.write(line);
|
|
259
|
-
} finally {
|
|
260
|
-
await handle.close();
|
|
261
|
-
}
|
|
262
|
-
} catch (e) {
|
|
263
|
-
logs.writeLine({
|
|
264
|
-
scope: 'webRemote', level: 'warn',
|
|
265
|
-
message: 'audit log write failed', meta: { error: e?.message },
|
|
266
|
-
});
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// ─── HTTPS helpers ────────────────────────────────────────────────────────────
|
|
271
|
-
|
|
272
|
-
function httpsPost(url, body, headers = {}) {
|
|
273
|
-
return new Promise((resolve, reject) => {
|
|
274
|
-
const parsed = new URL(url);
|
|
275
|
-
if (parsed.protocol !== 'https:') {
|
|
276
|
-
reject(new Error('Only https:// allowed for relay API calls'));
|
|
277
|
-
return;
|
|
278
|
-
}
|
|
279
|
-
const req = https.request({
|
|
280
|
-
hostname: parsed.hostname,
|
|
281
|
-
port: parsed.port || 443,
|
|
282
|
-
path: parsed.pathname + (parsed.search || ''),
|
|
283
|
-
method: 'POST',
|
|
284
|
-
headers: {
|
|
285
|
-
'Content-Type': 'application/json',
|
|
286
|
-
'Content-Length': Buffer.byteLength(body),
|
|
287
|
-
...headers,
|
|
288
|
-
},
|
|
289
|
-
rejectUnauthorized: true, // verify relay TLS cert
|
|
290
|
-
}, (res) => {
|
|
291
|
-
let data = '';
|
|
292
|
-
res.on('data', (c) => { data += c; });
|
|
293
|
-
res.on('end', () => {
|
|
294
|
-
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
295
|
-
try { resolve(JSON.parse(data)); } catch { resolve({}); }
|
|
296
|
-
} else {
|
|
297
|
-
let errMsg = `HTTP ${res.statusCode}`;
|
|
298
|
-
try { errMsg = JSON.parse(data).error || errMsg; } catch { /* */ }
|
|
299
|
-
reject(new Error(errMsg));
|
|
300
|
-
}
|
|
301
|
-
});
|
|
302
|
-
});
|
|
303
|
-
req.on('error', reject);
|
|
304
|
-
req.setTimeout(15_000, () => { req.destroy(new Error('timeout')); });
|
|
305
|
-
req.write(body);
|
|
306
|
-
req.end();
|
|
307
|
-
});
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
// POST /api/device-ticket to exchange device-token for a one-time WS ticket.
|
|
311
|
-
async function getDeviceTicket(deviceToken) {
|
|
312
|
-
const result = await httpsPost(
|
|
313
|
-
`${RELAY_API_BASE}/device-ticket`,
|
|
314
|
-
'{}',
|
|
315
|
-
{ Authorization: `Bearer ${deviceToken}` }
|
|
316
|
-
);
|
|
317
|
-
if (!result.ticket) throw new Error('relay returned no ticket');
|
|
318
|
-
return result.ticket;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
// ─── E2E state helpers ────────────────────────────────────────────────────────
|
|
322
|
-
|
|
323
|
-
function resetE2e(state = 'idle') {
|
|
324
|
-
_e2e = makeState(state);
|
|
325
|
-
_e2ePeerPubKey = null;
|
|
326
|
-
_e2eDeviceId = null;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
// ─── WebSocket lifecycle ──────────────────────────────────────────────────────
|
|
330
|
-
|
|
331
|
-
// Single source of truth for the status payload shape shared by the
|
|
332
|
-
// broadcast push (webRemote:status) and the get-status IPC pull — both must
|
|
333
|
-
// report the same connected/e2e/device view of `cfg` + `_ws`/`_e2e` module state.
|
|
334
|
-
function computeStatus(cfg) {
|
|
335
|
-
const connected = _ws !== null && _ws.readyState === WebSocket.OPEN;
|
|
336
|
-
return {
|
|
337
|
-
enabled: cfg.remoteEnabled,
|
|
338
|
-
remoteControlEnabled: cfg.remoteControlEnabled ?? false,
|
|
339
|
-
connected,
|
|
340
|
-
e2eActive: connected && _e2e.sessionKey !== null,
|
|
341
|
-
e2eAuthenticated: connected && _e2e.state === 'authenticated',
|
|
342
|
-
e2eState: connected ? _e2e.state : 'idle',
|
|
343
|
-
pendingSas: connected && _e2e.state === 'pending_sas' ? _e2e.pendingSas : null,
|
|
344
|
-
devices: (cfg.devices || []).map(({ deviceId, deviceName, issuedAt, lastConnectedAt }) => ({
|
|
345
|
-
deviceId, deviceName, issuedAt, lastConnectedAt,
|
|
346
|
-
})),
|
|
347
|
-
};
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
function broadcastStatus() {
|
|
351
|
-
if (!_window || _window.isDestroyed()) return;
|
|
352
|
-
const cfg = loadConfigSync();
|
|
353
|
-
sendIfAlive(_window, 'webRemote:status', computeStatus(cfg));
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
function stopHeartbeat() {
|
|
357
|
-
if (_pingTimer) { clearInterval(_pingTimer); _pingTimer = null; }
|
|
358
|
-
if (_pongTimer) { clearTimeout(_pongTimer); _pongTimer = null; }
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
function startHeartbeat() {
|
|
362
|
-
stopHeartbeat();
|
|
363
|
-
_pingTimer = setInterval(() => {
|
|
364
|
-
if (!_ws || _ws.readyState !== WebSocket.OPEN) { stopHeartbeat(); return; }
|
|
365
|
-
const pingId = crypto.randomUUID();
|
|
366
|
-
_ws.send(JSON.stringify({ type: 'ping', id: pingId, ts: Date.now() }));
|
|
367
|
-
_pongTimer = setTimeout(() => {
|
|
368
|
-
_missedPongs++;
|
|
369
|
-
logs.writeLine({
|
|
370
|
-
scope: 'webRemote', level: 'warn',
|
|
371
|
-
message: 'missed pong', meta: { missed: _missedPongs },
|
|
372
|
-
});
|
|
373
|
-
if (_missedPongs >= MAX_MISSED_PONGS) {
|
|
374
|
-
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'closing after missed pongs' });
|
|
375
|
-
_ws?.terminate();
|
|
376
|
-
}
|
|
377
|
-
}, PONG_TIMEOUT_MS);
|
|
378
|
-
}, PING_INTERVAL_MS);
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
function cancelReconnect() {
|
|
382
|
-
if (_reconnectTimer) { clearTimeout(_reconnectTimer); _reconnectTimer = null; }
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
// Full jitter: delay = random(0, min(cap, base * mult^n))
|
|
386
|
-
function nextBackoffMs() {
|
|
387
|
-
const raw = Math.min(_backoffMs, BACKOFF_MAX_MS);
|
|
388
|
-
const jitter = 0.8 + Math.random() * 0.4; // ±20%
|
|
389
|
-
const next = Math.floor(raw * jitter);
|
|
390
|
-
_backoffMs = Math.min(_backoffMs * BACKOFF_MULT, BACKOFF_MAX_MS);
|
|
391
|
-
return next;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
function scheduleReconnect() {
|
|
395
|
-
if (_destroyed) return;
|
|
396
|
-
cancelReconnect();
|
|
397
|
-
const delay = nextBackoffMs();
|
|
398
|
-
logs.writeLine({
|
|
399
|
-
scope: 'webRemote', level: 'info',
|
|
400
|
-
message: 'reconnect scheduled', meta: { delayMs: delay },
|
|
401
|
-
});
|
|
402
|
-
_reconnectTimer = setTimeout(() => {
|
|
403
|
-
_reconnectTimer = null;
|
|
404
|
-
connect().catch((e) => {
|
|
405
|
-
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'connect failed', meta: { error: e?.message } });
|
|
406
|
-
});
|
|
407
|
-
}, delay);
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
async function disconnect() {
|
|
411
|
-
cancelReconnect();
|
|
412
|
-
stopHeartbeat();
|
|
413
|
-
resetE2e();
|
|
414
|
-
if (_ws) {
|
|
415
|
-
const ws = _ws;
|
|
416
|
-
_ws = null;
|
|
417
|
-
try { ws.terminate(); } catch { /* already closed */ }
|
|
418
|
-
}
|
|
419
|
-
broadcastStatus();
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
async function connect() {
|
|
423
|
-
if (_destroyed) return;
|
|
424
|
-
|
|
425
|
-
const cfg = await loadConfig();
|
|
426
|
-
if (!cfg.remoteEnabled) return;
|
|
427
|
-
|
|
428
|
-
const devices = cfg.devices || [];
|
|
429
|
-
// Use the most recently paired credential. The relay's device-token store is
|
|
430
|
-
// in-memory and wiped on every relay redeploy, so an older credential can hold a
|
|
431
|
-
// dead token that 401s forever; picking the newest lets a fresh re-pair recover.
|
|
432
|
-
const device = devices
|
|
433
|
-
.filter((d) => d.deviceToken)
|
|
434
|
-
.sort((a, b) => String(b.issuedAt || '').localeCompare(String(a.issuedAt || '')))[0];
|
|
435
|
-
if (!device) {
|
|
436
|
-
logs.writeLine({ scope: 'webRemote', level: 'info', message: 'no paired device; not connecting' });
|
|
437
|
-
return;
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
// Step 1: get a single-use WS ticket via the device token.
|
|
441
|
-
let ticket;
|
|
442
|
-
try {
|
|
443
|
-
ticket = await getDeviceTicket(device.deviceToken);
|
|
444
|
-
} catch (e) {
|
|
445
|
-
logs.writeLine({
|
|
446
|
-
scope: 'webRemote', level: 'warn',
|
|
447
|
-
message: 'device ticket request failed', meta: { error: e?.message },
|
|
448
|
-
});
|
|
449
|
-
scheduleReconnect();
|
|
450
|
-
return;
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
// Step 2: open WSS connection with the ticket.
|
|
454
|
-
let ws;
|
|
455
|
-
try {
|
|
456
|
-
ws = new WebSocket(`${RELAY_WSS_URL}?ticket=${encodeURIComponent(ticket)}`, {
|
|
457
|
-
rejectUnauthorized: true, // verify relay TLS cert
|
|
458
|
-
});
|
|
459
|
-
} catch (e) {
|
|
460
|
-
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'ws create failed', meta: { error: e?.message } });
|
|
461
|
-
scheduleReconnect();
|
|
462
|
-
return;
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
_ws = ws;
|
|
466
|
-
_missedPongs = 0;
|
|
467
|
-
resetE2e(); // reset E2E state on new connection
|
|
468
|
-
|
|
469
|
-
ws.on('open', () => {
|
|
470
|
-
logs.writeLine({ scope: 'webRemote', level: 'info', message: 'connected to relay' });
|
|
471
|
-
_backoffMs = BACKOFF_INIT_MS;
|
|
472
|
-
_missedPongs = 0;
|
|
473
|
-
startHeartbeat();
|
|
474
|
-
// Update lastConnectedAt without exposing token
|
|
475
|
-
loadConfig().then(async (c) => {
|
|
476
|
-
const devs = (c.devices || []).map((d) =>
|
|
477
|
-
d.deviceId === device.deviceId
|
|
478
|
-
? { ...d, lastConnectedAt: new Date().toISOString() }
|
|
479
|
-
: d
|
|
480
|
-
);
|
|
481
|
-
await saveConfig({ ...c, devices: devs });
|
|
482
|
-
broadcastStatus();
|
|
483
|
-
}).catch(() => {});
|
|
484
|
-
broadcastStatus();
|
|
485
|
-
// v2: begin pushing the live session list once connected.
|
|
486
|
-
// Reset diff-guard so the new client always receives a full session-list push.
|
|
487
|
-
_lastSessionListJson = null;
|
|
488
|
-
startSessionListPush();
|
|
489
|
-
});
|
|
490
|
-
|
|
491
|
-
ws.on('message', (raw) => {
|
|
492
|
-
if (raw.length > MSG_MAX_BYTES) {
|
|
493
|
-
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'oversized message dropped' });
|
|
494
|
-
return;
|
|
495
|
-
}
|
|
496
|
-
handleMessage(raw.toString(), device).catch((e) => {
|
|
497
|
-
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'handleMessage error', meta: { error: e?.message } });
|
|
498
|
-
});
|
|
499
|
-
});
|
|
500
|
-
|
|
501
|
-
ws.on('close', (code) => {
|
|
502
|
-
stopHeartbeat();
|
|
503
|
-
stopAllSessionWatches();
|
|
504
|
-
resetE2e();
|
|
505
|
-
if (_ws === ws) _ws = null;
|
|
506
|
-
logs.writeLine({ scope: 'webRemote', level: 'info', message: 'ws closed', meta: { code } });
|
|
507
|
-
broadcastStatus();
|
|
508
|
-
|
|
509
|
-
if (code === 4001) {
|
|
510
|
-
// Token revoked by relay — stop reconnecting, clear token (ADR §4.1).
|
|
511
|
-
handleTokenRevoked(device.deviceId).catch(() => {});
|
|
512
|
-
return;
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
if (!_destroyed) scheduleReconnect();
|
|
516
|
-
});
|
|
517
|
-
|
|
518
|
-
ws.on('error', (e) => {
|
|
519
|
-
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'ws error', meta: { error: e?.message } });
|
|
520
|
-
});
|
|
521
|
-
|
|
522
|
-
// Belt-and-suspenders liveness: the relay also runs its own protocol-level
|
|
523
|
-
// ws.ping() heartbeat, independent of our app-level JSON ping/pong. Receiving
|
|
524
|
-
// that frame is proof the link is alive, so reset the missed-pong counter on it
|
|
525
|
-
// too. This keeps the session up even if the relay ever stops answering the
|
|
526
|
-
// app-level pings (the exact failure that silently dropped sessions every ~100s
|
|
527
|
-
// before the relay learned to pong). node 'ws' auto-replies to the ping itself.
|
|
528
|
-
ws.on('ping', () => { _missedPongs = 0; });
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
async function handleTokenRevoked(deviceId) {
|
|
532
|
-
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'token revoked', meta: { deviceId } });
|
|
533
|
-
const cfg = await loadConfig();
|
|
534
|
-
const devices = (cfg.devices || []).filter((d) => d.deviceId !== deviceId);
|
|
535
|
-
await saveConfig({ ...cfg, remoteEnabled: false, devices });
|
|
536
|
-
broadcastStatus();
|
|
537
|
-
sendIfAlive(_window, 'webRemote:token-revoked', { deviceId });
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
// ─── v2 mobile: session live state + summary push ────────────────────────────
|
|
541
|
-
//
|
|
542
|
-
// For each subscribed tab the agent tails its transcript JSONL (reusing the
|
|
543
|
-
// canonical classifyLine + transcriptPath from transcripts.cjs — single source of
|
|
544
|
-
// truth), derives a coarse state, and pushes event:session:state on change.
|
|
545
|
-
// The last completed assistant turn drives the Haiku summary (see maybeSummarize).
|
|
546
|
-
|
|
547
|
-
const SESSION_POLL_MS = 1500;
|
|
548
|
-
const SESSION_LIST_PUSH_MS = 5000;
|
|
549
|
-
const SESSION_INIT_TAIL_BYTES = 512 * 1024; // bound the initial read
|
|
550
|
-
|
|
551
|
-
const _sessionWatchers = new Map(); // tabId → watcher
|
|
552
|
-
let _sessionListTimer = null;
|
|
553
|
-
let _lastSessionListJson = null; // diff-guard: skip push when snapshot unchanged
|
|
554
|
-
|
|
555
|
-
/** Push an unsolicited event to the browser(s). Encrypts when an E2E key is active. */
|
|
556
|
-
function pushEvent(type, payload) {
|
|
557
|
-
if (!_ws || _ws.readyState !== WebSocket.OPEN) return;
|
|
558
|
-
const inner = { type, id: crypto.randomUUID(), payload, ts: Date.now() };
|
|
559
|
-
try {
|
|
560
|
-
if (_e2e.sessionKey) {
|
|
561
|
-
const { nonce, ciphertext } = encryptBox(JSON.stringify(inner), _e2e.sessionKey);
|
|
562
|
-
_ws.send(JSON.stringify({ type: 'e2e:box', id: inner.id, payload: { nonce, ciphertext }, ts: Date.now() }));
|
|
563
|
-
} else {
|
|
564
|
-
_ws.send(JSON.stringify(inner));
|
|
565
|
-
}
|
|
566
|
-
} catch (e) {
|
|
567
|
-
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'pushEvent failed', meta: { type, error: e?.message } });
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
/** Extract concatenated text from an assistant transcript line, or '' if none. */
|
|
572
|
-
function extractAssistantText(raw) {
|
|
573
|
-
const msg = raw?.message || raw;
|
|
574
|
-
const content = msg?.content;
|
|
575
|
-
if (typeof content === 'string') return content;
|
|
576
|
-
if (!Array.isArray(content)) return '';
|
|
577
|
-
return content.filter((b) => b?.type === 'text' && typeof b.text === 'string').map((b) => b.text).join('\n').trim();
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
/** Map a classified transcript event to a coarse session state. */
|
|
581
|
-
function deriveState(ev, raw) {
|
|
582
|
-
// API/usage errors surface as a flagged message line.
|
|
583
|
-
if (raw?.isApiErrorMessage || raw?.level === 'error') return 'error';
|
|
584
|
-
switch (ev.kind) {
|
|
585
|
-
case 'tool_use':
|
|
586
|
-
case 'agent_spawn':
|
|
587
|
-
return 'running'; // model invoked a tool, awaiting result
|
|
588
|
-
case 'tool_result':
|
|
589
|
-
return 'thinking'; // tool finished, model resuming
|
|
590
|
-
case 'user':
|
|
591
|
-
return 'thinking'; // input submitted, model will respond
|
|
592
|
-
case 'assistant':
|
|
593
|
-
return 'idle'; // assistant text turn complete → user's turn
|
|
594
|
-
default:
|
|
595
|
-
return null; // usage/todo/plan/etc. — no state change
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
async function tailLines(filePath, fromOffset) {
|
|
600
|
-
const stat = await fsp.stat(filePath).catch(() => null);
|
|
601
|
-
if (!stat) return { lines: [], size: 0, inode: undefined };
|
|
602
|
-
let start = fromOffset;
|
|
603
|
-
if (start == null || start > stat.size) start = Math.max(0, stat.size - SESSION_INIT_TAIL_BYTES);
|
|
604
|
-
if (stat.size <= start) return { lines: [], size: stat.size, inode: stat.ino };
|
|
605
|
-
const fd = await fsp.open(filePath, 'r');
|
|
606
|
-
try {
|
|
607
|
-
// Drop the first fragment only when offset genuinely landed mid-line —
|
|
608
|
-
// i.e. the byte immediately before `start` is not '\n'. When `start`
|
|
609
|
-
// sits right on a newline boundary (previous poll ended at a complete
|
|
610
|
-
// line) the first split-part is already a full line and must be kept.
|
|
611
|
-
let dropFirst = false;
|
|
612
|
-
if (start > 0) {
|
|
613
|
-
const prev = Buffer.alloc(1);
|
|
614
|
-
await fd.read(prev, 0, 1, start - 1);
|
|
615
|
-
dropFirst = prev[0] !== 0x0a; // 0x0a = '\n'
|
|
616
|
-
}
|
|
617
|
-
const len = stat.size - start;
|
|
618
|
-
const buf = Buffer.alloc(len);
|
|
619
|
-
await fd.read(buf, 0, len, start);
|
|
620
|
-
// Shift before filter: the fragment may be an empty string (when the
|
|
621
|
-
// buffer starts with '\n', completing the previous partial line). If we
|
|
622
|
-
// filter(Boolean) first, the empty fragment disappears and shift() would
|
|
623
|
-
// remove the first valid line instead.
|
|
624
|
-
const parts = buf.toString('utf8').split('\n');
|
|
625
|
-
if (dropFirst && parts.length) parts.shift();
|
|
626
|
-
return { lines: parts.filter(Boolean), size: stat.size, inode: stat.ino };
|
|
627
|
-
} finally {
|
|
628
|
-
await fd.close();
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
async function pollSessionWatcher(w) {
|
|
633
|
-
let res;
|
|
634
|
-
try {
|
|
635
|
-
res = await tailLines(w.filePath, w.offset);
|
|
636
|
-
} catch { return; }
|
|
637
|
-
// Inode change = file replaced; restart from a bounded tail.
|
|
638
|
-
if (w.inode !== undefined && res.inode !== undefined && res.inode !== w.inode) {
|
|
639
|
-
w.offset = Math.max(0, res.size - SESSION_INIT_TAIL_BYTES);
|
|
640
|
-
w.inode = res.inode;
|
|
641
|
-
return;
|
|
642
|
-
}
|
|
643
|
-
w.offset = res.size;
|
|
644
|
-
w.inode = res.inode;
|
|
645
|
-
|
|
646
|
-
let nextState = null;
|
|
647
|
-
let newAssistantText = null;
|
|
648
|
-
let newMsgId = null;
|
|
649
|
-
for (const line of res.lines) {
|
|
650
|
-
let obj;
|
|
651
|
-
try { obj = JSON.parse(line); } catch { continue; }
|
|
652
|
-
const events = require('./transcripts.cjs').classifyLine(obj);
|
|
653
|
-
for (const ev of events) {
|
|
654
|
-
const s = deriveState(ev, obj);
|
|
655
|
-
if (s) nextState = s;
|
|
656
|
-
if (ev.kind === 'assistant') {
|
|
657
|
-
const text = extractAssistantText(obj);
|
|
658
|
-
if (text) { newAssistantText = text; newMsgId = obj.uuid || obj.message?.id || `${w.tabId}:${res.size}`; }
|
|
659
|
-
}
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
if (nextState && nextState !== w.state) {
|
|
664
|
-
w.state = nextState;
|
|
665
|
-
// Guard: don't push state if SAS not yet confirmed (watcher may outlive an auth reset).
|
|
666
|
-
if (_e2e.state === 'authenticated') {
|
|
667
|
-
pushEvent('event:session:state', { tabId: w.tabId, state: w.state, since: Date.now() });
|
|
668
|
-
}
|
|
669
|
-
}
|
|
670
|
-
if (newAssistantText && newMsgId !== w.lastMsgId) {
|
|
671
|
-
w.lastAssistantText = newAssistantText;
|
|
672
|
-
w.lastMsgId = newMsgId;
|
|
673
|
-
}
|
|
674
|
-
// Summarize only a COMPLETED turn (state idle) — not assistant text mid-turn that
|
|
675
|
-
// is followed by a tool call. Cache by msgId so re-subscribe doesn't re-bill.
|
|
676
|
-
if (w.state === 'idle' && w.lastAssistantText && w.lastMsgId !== w.summarizedMsgId) {
|
|
677
|
-
w.summarizedMsgId = w.lastMsgId;
|
|
678
|
-
maybeSummarize(w).catch(() => {});
|
|
679
|
-
}
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
function startSessionWatch(tabId, cwd) {
|
|
683
|
-
if (_sessionWatchers.has(tabId)) return;
|
|
684
|
-
const filePath = require('./transcripts.cjs').transcriptPath(cwd, tabId);
|
|
685
|
-
// Defense in depth: the schema restricts tabId charset, but re-validate the
|
|
686
|
-
// FINAL joined path against the home-dir boundary (validatePath resolves
|
|
687
|
-
// symlinks + rejects escapes) before any fs read. Throws → dispatch drops it.
|
|
688
|
-
validatePath(filePath);
|
|
689
|
-
const w = {
|
|
690
|
-
tabId, cwd, filePath,
|
|
691
|
-
offset: null, // null → first poll reads a bounded tail then tracks EOF
|
|
692
|
-
inode: undefined,
|
|
693
|
-
state: 'idle',
|
|
694
|
-
lastAssistantText: null,
|
|
695
|
-
lastMsgId: null,
|
|
696
|
-
summarizedMsgId: null,
|
|
697
|
-
timer: null,
|
|
698
|
-
};
|
|
699
|
-
_sessionWatchers.set(tabId, w);
|
|
700
|
-
// Prime once immediately (captures current state + last assistant turn), then poll.
|
|
701
|
-
pollSessionWatcher(w).catch(() => {});
|
|
702
|
-
w.timer = setInterval(() => pollSessionWatcher(w).catch(() => {}), SESSION_POLL_MS);
|
|
703
|
-
if (typeof w.timer.unref === 'function') w.timer.unref();
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
function stopSessionWatch(tabId) {
|
|
707
|
-
const w = _sessionWatchers.get(tabId);
|
|
708
|
-
if (!w) return;
|
|
709
|
-
if (w.timer) clearInterval(w.timer);
|
|
710
|
-
_sessionWatchers.delete(tabId);
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
function stopAllSessionWatches() {
|
|
714
|
-
for (const tabId of Array.from(_sessionWatchers.keys())) stopSessionWatch(tabId);
|
|
715
|
-
if (_sessionListTimer) { clearInterval(_sessionListTimer); _sessionListTimer = null; }
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
/** Push the current session list (reuses sessionsStore — the canonical source). */
|
|
719
|
-
async function pushSessionList() {
|
|
720
|
-
try {
|
|
721
|
-
// Honor the kill switch: when remote is disabled, push nothing (the project
|
|
722
|
-
// list = cwds/titles is sensitive). dispatchEnvelope already blocks cmd:*;
|
|
723
|
-
// this stops the unsolicited background push too.
|
|
724
|
-
const cfg = await loadConfig();
|
|
725
|
-
if (!cfg.remoteEnabled) return;
|
|
726
|
-
// Don't push before SAS is confirmed — session cwds/titles are sensitive user data.
|
|
727
|
-
// A relay that completes e2e:hello before the user confirms the SAS would otherwise
|
|
728
|
-
// receive the full session list immediately (same threat SAS_GATED_READS blocks for
|
|
729
|
-
// cmd:sessions:load). Guard here so _lastSessionListJson is not poisoned either.
|
|
730
|
-
if (_e2e.state !== 'authenticated') return;
|
|
731
|
-
const sessionsStore = require('./sessionsStore.cjs');
|
|
732
|
-
const data = await sessionsStore.load();
|
|
733
|
-
// Normalize persisted tabs → SessionMeta. tabId === sessionId so it
|
|
734
|
-
// matches the transcript JSONL name used by cmd:session:subscribe.
|
|
735
|
-
const sessions = (data?.tabs ?? []).map((t) => ({
|
|
736
|
-
tabId: t.sessionId,
|
|
737
|
-
cwd: t.cwd,
|
|
738
|
-
title: t.label || t.cwd,
|
|
739
|
-
state: _sessionWatchers.get(t.sessionId)?.state ?? null,
|
|
740
|
-
}));
|
|
741
|
-
const payload = { sessions, activeTabId: data?.activeTabId ?? null };
|
|
742
|
-
const json = JSON.stringify(payload);
|
|
743
|
-
if (json === _lastSessionListJson) return;
|
|
744
|
-
_lastSessionListJson = json;
|
|
745
|
-
pushEvent('event:session:list', payload);
|
|
746
|
-
} catch (e) {
|
|
747
|
-
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'pushSessionList failed', meta: { error: e?.message } });
|
|
748
|
-
}
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
function startSessionListPush() {
|
|
752
|
-
if (_sessionListTimer) return;
|
|
753
|
-
pushSessionList().catch(() => {});
|
|
754
|
-
_sessionListTimer = setInterval(() => pushSessionList().catch(() => {}), SESSION_LIST_PUSH_MS);
|
|
755
|
-
if (typeof _sessionListTimer.unref === 'function') _sessionListTimer.unref();
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
// ─── SM-V2-03: mobile summary via Claude Haiku 4.5 ───────────────────────────
|
|
759
|
-
// Summarization logic lives in lib/summarize.cjs (shared with exchanges.cjs).
|
|
760
|
-
|
|
761
|
-
const SUMMARY_MIN_CHARS = 280; // below this, push raw — not worth an API call
|
|
762
|
-
|
|
763
|
-
/**
|
|
764
|
-
* Produce a mobile summary of the watcher's last completed assistant turn and push it.
|
|
765
|
-
* Short turns are pushed raw (no API call). If no API key is configured, degrades to
|
|
766
|
-
* the raw message so core remote control is never blocked. Cost: Haiku in+out per
|
|
767
|
-
* completed turn per subscribed tab (~$1/$5 per 1M tokens).
|
|
768
|
-
*/
|
|
769
|
-
async function maybeSummarize(w) {
|
|
770
|
-
// Guard: don't push summaries (session transcript content) before SAS confirmed.
|
|
771
|
-
if (_e2e.state !== 'authenticated') return;
|
|
772
|
-
const text = w.lastAssistantText;
|
|
773
|
-
if (!text) return;
|
|
774
|
-
const ofMessageId = w.lastMsgId;
|
|
775
|
-
|
|
776
|
-
if (text.length < SUMMARY_MIN_CHARS) {
|
|
777
|
-
pushEvent('event:session:summary', { tabId: w.tabId, summary: text, ofMessageId, model: 'raw', ts: Date.now() });
|
|
778
|
-
return;
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
const { summary, model, degraded } = await _sharedSummarize(text);
|
|
782
|
-
if (degraded) {
|
|
783
|
-
logs.writeLine({ scope: 'webRemote', level: 'warn', message: `summary degraded: ${degraded}`, meta: {} });
|
|
784
|
-
}
|
|
785
|
-
pushEvent('event:session:summary', {
|
|
786
|
-
tabId: w.tabId, summary, ofMessageId, model,
|
|
787
|
-
...(degraded ? { degraded } : {}),
|
|
788
|
-
ts: Date.now(),
|
|
789
|
-
});
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
/**
|
|
793
|
-
* Shared finish-up for any path that transitions _e2e into 'authenticated'
|
|
794
|
-
* (manual SAS confirmation or pinned-key auto-trust): log, broadcast the new
|
|
795
|
-
* status, and flush the session list immediately rather than waiting for the
|
|
796
|
-
* next SESSION_LIST_PUSH_MS tick.
|
|
797
|
-
*/
|
|
798
|
-
function finalizeE2eAuthenticated(logMessage) {
|
|
799
|
-
logs.writeLine({ scope: 'webRemote', level: 'info', message: logMessage });
|
|
800
|
-
broadcastStatus();
|
|
801
|
-
_lastSessionListJson = null;
|
|
802
|
-
pushSessionList().catch(() => {});
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
// ─── Message handling & command dispatch ─────────────────────────────────────
|
|
806
|
-
|
|
807
|
-
async function handleMessage(raw, device) {
|
|
808
|
-
let envelope;
|
|
809
|
-
try {
|
|
810
|
-
envelope = JSON.parse(raw);
|
|
811
|
-
} catch {
|
|
812
|
-
return; // malformed JSON — drop silently
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
const { type, id, payload } = envelope;
|
|
816
|
-
if (typeof type !== 'string') return;
|
|
817
|
-
|
|
818
|
-
// Handle relay control messages
|
|
819
|
-
if (type === 'pong') {
|
|
820
|
-
if (_pongTimer) { clearTimeout(_pongTimer); _pongTimer = null; }
|
|
821
|
-
_missedPongs = 0;
|
|
822
|
-
return;
|
|
823
|
-
}
|
|
824
|
-
if (type === 'ping') {
|
|
825
|
-
if (_ws && _ws.readyState === WebSocket.OPEN) {
|
|
826
|
-
_ws.send(JSON.stringify({ type: 'pong', id, ts: Date.now() }));
|
|
827
|
-
}
|
|
828
|
-
return;
|
|
829
|
-
}
|
|
830
|
-
if (type === 'auth:ok') {
|
|
831
|
-
logs.writeLine({ scope: 'webRemote', level: 'info', message: 'auth:ok from relay' });
|
|
832
|
-
return;
|
|
833
|
-
}
|
|
834
|
-
if (type === 'error') {
|
|
835
|
-
const code = envelope.code || payload?.code;
|
|
836
|
-
if (code === 'token_revoked') {
|
|
837
|
-
const ws = _ws;
|
|
838
|
-
_ws = null;
|
|
839
|
-
try { ws?.terminate(); } catch { /* */ }
|
|
840
|
-
handleTokenRevoked(device.deviceId).catch(() => {});
|
|
841
|
-
}
|
|
842
|
-
return;
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
// ── E2E key exchange ───────────────────────────────────────────────────────
|
|
846
|
-
// Browser sends e2e:hello with its ephemeral P-256 public key (SPKI base64url).
|
|
847
|
-
// We compute the shared session key and acknowledge.
|
|
848
|
-
if (type === 'e2e:hello') {
|
|
849
|
-
const browserPubKey = payload?.pubKey;
|
|
850
|
-
if (!browserPubKey || typeof browserPubKey !== 'string') {
|
|
851
|
-
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'e2e:hello missing pubKey' });
|
|
852
|
-
return;
|
|
853
|
-
}
|
|
854
|
-
// P-256 SPKI DER is 91 bytes → base64url ~122 chars; reject out-of-range blobs
|
|
855
|
-
// before they reach crypto.createPublicKey (malformed input throws and is caught,
|
|
856
|
-
// but repeated bad keys force plaintext fallback via the keep-e2e enforcement above).
|
|
857
|
-
const PUB_KEY_RE = /^[A-Za-z0-9+/=_-]+$/;
|
|
858
|
-
if (browserPubKey.length < 80 || browserPubKey.length > 256 || !PUB_KEY_RE.test(browserPubKey)) {
|
|
859
|
-
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'e2e:hello invalid pubKey format' });
|
|
860
|
-
return;
|
|
861
|
-
}
|
|
862
|
-
if (!device.e2ePrivateKey) {
|
|
863
|
-
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'e2e:hello but no device private key — skipping E2E' });
|
|
864
|
-
return;
|
|
865
|
-
}
|
|
866
|
-
// Explicit P-256 curve validation — do not rely on Node's implicit throw.
|
|
867
|
-
// Rejects wrong-curve keys (e.g. P-384), malformed DER, and the all-zero
|
|
868
|
-
// identity point. Must happen before deriveSessionKey so a bad key drops
|
|
869
|
-
// the session here, not silently in a crypto catch-all below.
|
|
870
|
-
try {
|
|
871
|
-
const derBytes = Buffer.from(browserPubKey, 'base64url');
|
|
872
|
-
const importedPub = crypto.createPublicKey({ key: derBytes, format: 'der', type: 'spki' });
|
|
873
|
-
if (importedPub.asymmetricKeyDetails?.namedCurve !== 'prime256v1') {
|
|
874
|
-
throw new Error(`wrong curve: ${importedPub.asymmetricKeyDetails?.namedCurve}`);
|
|
875
|
-
}
|
|
876
|
-
// P-256 SPKI DER is always 91 bytes; raw EC point (04 || x || y) starts at offset 26.
|
|
877
|
-
// Defense-in-depth: reject the identity point (x=0, y=0) explicitly even though
|
|
878
|
-
// Node's ECDH would also reject it — P-256 is a prime-order group, so the identity
|
|
879
|
-
// is the only low-order point.
|
|
880
|
-
if (derBytes.length === 91) {
|
|
881
|
-
const x = derBytes.subarray(27, 59);
|
|
882
|
-
const y = derBytes.subarray(59, 91);
|
|
883
|
-
if (x.every((b) => b === 0) && y.every((b) => b === 0)) {
|
|
884
|
-
throw new Error('identity point rejected');
|
|
885
|
-
}
|
|
886
|
-
}
|
|
887
|
-
} catch (e) {
|
|
888
|
-
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'e2e:hello peer key validation failed — session dropped', meta: { error: e?.message } });
|
|
889
|
-
await auditLog(new Date().toISOString(), 'e2e:hello', device.deviceId, undefined, 'error:invalid_peer_key');
|
|
890
|
-
resetE2e('failed'); // surface key-validation failure to UI — user must reconnect
|
|
891
|
-
broadcastStatus();
|
|
892
|
-
return;
|
|
893
|
-
}
|
|
894
|
-
try {
|
|
895
|
-
const sessionKey = deriveSessionKey(device.e2ePrivateKey, browserPubKey, device.deviceId);
|
|
896
|
-
let pendingSas;
|
|
897
|
-
try {
|
|
898
|
-
pendingSas = deriveSas(device.e2ePrivateKey, browserPubKey, device.deviceId);
|
|
899
|
-
} catch (sasErr) {
|
|
900
|
-
// SAS derivation failed — session cannot be verified by the user.
|
|
901
|
-
// Mark failed so confirm-sas returns ok:false and the UI prompts retry.
|
|
902
|
-
resetE2e('failed');
|
|
903
|
-
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'E2E SAS derivation failed — session marked failed', meta: { error: sasErr?.message } });
|
|
904
|
-
broadcastStatus();
|
|
905
|
-
return;
|
|
906
|
-
}
|
|
907
|
-
_e2ePeerPubKey = browserPubKey;
|
|
908
|
-
_e2eDeviceId = device.deviceId;
|
|
909
|
-
// TOFU pinning: if this exact (deviceId, browserPubKey) pair was already
|
|
910
|
-
// manually SAS-confirmed once, skip pending_sas and auto-authenticate — the
|
|
911
|
-
// user already verified this browser. A different/first-seen pubKey always
|
|
912
|
-
// falls through to the manual pending_sas flow below, unchanged.
|
|
913
|
-
if (device.verifiedPeerPubKey && device.verifiedPeerPubKey === browserPubKey) {
|
|
914
|
-
_e2e = makeState('authenticated', sessionKey, null);
|
|
915
|
-
finalizeE2eAuthenticated('E2E session auto-authenticated — pinned browser key matched');
|
|
916
|
-
respond(id, undefined, 'e2e:ready');
|
|
917
|
-
return;
|
|
918
|
-
}
|
|
919
|
-
_e2e = makeState('pending_sas', sessionKey, pendingSas);
|
|
920
|
-
logs.writeLine({ scope: 'webRemote', level: 'info', message: 'E2E session key established — SAS pending confirmation' });
|
|
921
|
-
broadcastStatus();
|
|
922
|
-
// Acknowledge with e2e:ready (unencrypted — session just started)
|
|
923
|
-
respond(id, undefined, 'e2e:ready');
|
|
924
|
-
} catch (e) {
|
|
925
|
-
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'E2E key derivation failed', meta: { error: e?.message } });
|
|
926
|
-
resetE2e('failed');
|
|
927
|
-
broadcastStatus();
|
|
928
|
-
}
|
|
929
|
-
return;
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
// ── Decrypt e2e:box messages ──────────────────────────────────────────────
|
|
933
|
-
if (type === 'e2e:box') {
|
|
934
|
-
if (!_e2e.sessionKey) {
|
|
935
|
-
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'e2e:box received but no session key — dropping' });
|
|
936
|
-
return;
|
|
937
|
-
}
|
|
938
|
-
const { nonce, ciphertext } = payload || {};
|
|
939
|
-
if (!nonce || !ciphertext) return;
|
|
940
|
-
const plaintext = decryptBox(nonce, ciphertext, _e2e.sessionKey);
|
|
941
|
-
if (!plaintext) {
|
|
942
|
-
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'e2e:box decryption failed (auth tag mismatch)' });
|
|
943
|
-
return;
|
|
944
|
-
}
|
|
945
|
-
// Replace envelope with the decrypted inner command and continue dispatch.
|
|
946
|
-
let inner;
|
|
947
|
-
try {
|
|
948
|
-
inner = JSON.parse(plaintext);
|
|
949
|
-
} catch {
|
|
950
|
-
return;
|
|
951
|
-
}
|
|
952
|
-
// Dispatch as if it arrived unencrypted
|
|
953
|
-
await dispatchEnvelope(inner, device);
|
|
954
|
-
return;
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
// Only dispatch cmd:* type messages
|
|
958
|
-
if (!type.startsWith('cmd:')) return;
|
|
959
|
-
// After E2E is established, reject plaintext commands — a malicious relay cannot
|
|
960
|
-
// silently downgrade the session by stripping e2e:hello (PENTEST.md §H1).
|
|
961
|
-
if (_e2e.sessionKey) {
|
|
962
|
-
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'plaintext cmd rejected — e2e session active' });
|
|
963
|
-
return;
|
|
964
|
-
}
|
|
965
|
-
await dispatchEnvelope(envelope, device);
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
async function dispatchEnvelope(envelope, device) {
|
|
969
|
-
const { type, id, payload } = envelope;
|
|
970
|
-
if (typeof type !== 'string' || !type.startsWith('cmd:')) return;
|
|
971
|
-
|
|
972
|
-
const ts = new Date().toISOString();
|
|
973
|
-
|
|
974
|
-
// Kill switch — re-reads config with 1s TTL
|
|
975
|
-
const cfg = await loadConfig();
|
|
976
|
-
if (!cfg.remoteEnabled) {
|
|
977
|
-
await auditLog(ts, type, device.deviceId, id, 'error:disabled');
|
|
978
|
-
respond(id, { error: 'rejected' }); // opaque — reason stays in audit log only
|
|
979
|
-
return;
|
|
980
|
-
}
|
|
981
|
-
|
|
982
|
-
// Allowlist check — reject unknown cmd:* types with opaque error (oracle prevention)
|
|
983
|
-
if (!ALLOWED_COMMANDS.has(type)) {
|
|
984
|
-
await auditLog(ts, type, device.deviceId, id, 'error:not_allowed');
|
|
985
|
-
respond(id, { error: 'rejected' });
|
|
986
|
-
return;
|
|
987
|
-
}
|
|
988
|
-
|
|
989
|
-
// MUTATE tier gate — write/exec commands require remoteControlEnabled=true (default false)
|
|
990
|
-
if (MUTATE_COMMANDS.has(type) && !cfg.remoteControlEnabled) {
|
|
991
|
-
await auditLog(ts, type, device.deviceId, id, 'error:not_allowed');
|
|
992
|
-
respond(id, { error: 'rejected' }); // opaque — reason stays in audit log only
|
|
993
|
-
return;
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
// E2E auth gate — MUTATE and sensitive READ commands are blocked until the
|
|
997
|
-
// user confirms the SAS on the desktop. This prevents a compromised relay
|
|
998
|
-
// from exfiltrating session lists, PRDs, run logs, or transcript summaries
|
|
999
|
-
// by completing the ECDH handshake without the user's knowledge.
|
|
1000
|
-
if ((MUTATE_COMMANDS.has(type) || SAS_GATED_READS.has(type)) && _e2e.state !== 'authenticated') {
|
|
1001
|
-
await auditLog(ts, type, device.deviceId, id, 'error:e2e_not_authenticated');
|
|
1002
|
-
respond(id, { error: 'rejected' }); // opaque — reason stays in audit log only
|
|
1003
|
-
return;
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
|
-
// Dispatch
|
|
1007
|
-
let result;
|
|
1008
|
-
try {
|
|
1009
|
-
result = await dispatchCommand(type, payload ?? {});
|
|
1010
|
-
await auditLog(ts, type, device.deviceId, id, 'ok');
|
|
1011
|
-
} catch (e) {
|
|
1012
|
-
const code = e?.name === 'ZodError' ? 'schema_invalid' : 'dispatch_error';
|
|
1013
|
-
await auditLog(ts, type, device.deviceId, id, `error:${code}`);
|
|
1014
|
-
result = { error: 'rejected' }; // opaque on-wire — reason stays in audit log only
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1017
|
-
respond(id, result);
|
|
1018
|
-
}
|
|
1019
|
-
|
|
1020
|
-
function respond(msgId, payload, typeOverride) {
|
|
1021
|
-
if (!_ws || _ws.readyState !== WebSocket.OPEN) return;
|
|
1022
|
-
const responseType = typeOverride || (msgId ? `resp:${msgId}` : undefined);
|
|
1023
|
-
if (!responseType) return;
|
|
1024
|
-
|
|
1025
|
-
const inner = {
|
|
1026
|
-
type: responseType,
|
|
1027
|
-
id: msgId,
|
|
1028
|
-
payload,
|
|
1029
|
-
ts: Date.now(),
|
|
1030
|
-
};
|
|
1031
|
-
|
|
1032
|
-
try {
|
|
1033
|
-
// Encrypt the response if a session key is active.
|
|
1034
|
-
if (_e2e.sessionKey && !typeOverride) {
|
|
1035
|
-
const { nonce, ciphertext } = encryptBox(JSON.stringify(inner), _e2e.sessionKey);
|
|
1036
|
-
_ws.send(JSON.stringify({
|
|
1037
|
-
type: 'e2e:box',
|
|
1038
|
-
id: msgId,
|
|
1039
|
-
payload: { nonce, ciphertext },
|
|
1040
|
-
ts: Date.now(),
|
|
1041
|
-
}));
|
|
1042
|
-
} else {
|
|
1043
|
-
_ws.send(JSON.stringify(inner));
|
|
1044
|
-
}
|
|
1045
|
-
} catch (e) {
|
|
1046
|
-
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'respond send failed', meta: { error: e?.message } });
|
|
1047
|
-
}
|
|
1048
|
-
}
|
|
1049
|
-
|
|
1050
|
-
// Lazy-loaded dispatch map — avoids circular require at module load time.
|
|
1051
|
-
let _dispatchMap = null;
|
|
1052
|
-
|
|
1053
|
-
function getDispatchMap() {
|
|
1054
|
-
if (_dispatchMap) return _dispatchMap;
|
|
1055
|
-
|
|
1056
|
-
const { manager: ptyManager } = require('./pty.cjs');
|
|
1057
|
-
const sessionsStore = require('./sessionsStore.cjs');
|
|
1058
|
-
const { remote: histRemote } = require('./historyAggregator.cjs');
|
|
1059
|
-
const chatRunner = require('./chatRunner.cjs');
|
|
1060
|
-
|
|
1061
|
-
_dispatchMap = {
|
|
1062
|
-
'cmd:sessions:load': async () =>
|
|
1063
|
-
sessionsStore.load(),
|
|
1064
|
-
|
|
1065
|
-
'cmd:sessions:save': async (payload) => {
|
|
1066
|
-
const parsed = schemas.sessionsPayload.parse(payload);
|
|
1067
|
-
return sessionsStore.save(parsed);
|
|
1068
|
-
},
|
|
1069
|
-
|
|
1070
|
-
'cmd:pty:spawn': async (payload) => {
|
|
1071
|
-
const parsed = schemas.ptySpawn.parse(payload);
|
|
1072
|
-
// Path safety — validatePath rejects anything outside home dir.
|
|
1073
|
-
validatePath(parsed.cwd);
|
|
1074
|
-
// Strip startupCommand: it is ignored by pty.cjs today, but passing a
|
|
1075
|
-
// remotely-controlled 8 KiB string to spawn is a landmine if pty.cjs
|
|
1076
|
-
// ever uses it. Remote callers have no legitimate need for it.
|
|
1077
|
-
const { startupCommand: _ignored, ...safePayload } = parsed;
|
|
1078
|
-
return ptyManager.spawn(safePayload);
|
|
1079
|
-
},
|
|
1080
|
-
|
|
1081
|
-
'cmd:pty:write': async (payload) => {
|
|
1082
|
-
const parsed = schemas.ptyWrite.parse(payload);
|
|
1083
|
-
return ptyManager.write(parsed);
|
|
1084
|
-
},
|
|
1085
|
-
|
|
1086
|
-
'cmd:pty:resize': async (payload) => {
|
|
1087
|
-
const parsed = schemas.ptyResize.parse(payload);
|
|
1088
|
-
ptyManager.resize(parsed);
|
|
1089
|
-
return { ok: true };
|
|
1090
|
-
},
|
|
1091
|
-
|
|
1092
|
-
'cmd:pty:kill': async (payload) => {
|
|
1093
|
-
const parsed = schemas.ptyTabId.parse(payload);
|
|
1094
|
-
ptyManager.kill(parsed.tabId);
|
|
1095
|
-
return { ok: true };
|
|
1096
|
-
},
|
|
1097
|
-
|
|
1098
|
-
'cmd:history:aggregate': async (payload) => {
|
|
1099
|
-
const parsed = schemas.historyAggregate.parse(payload);
|
|
1100
|
-
return histRemote.aggregate(parsed);
|
|
1101
|
-
},
|
|
1102
|
-
|
|
1103
|
-
'cmd:app:version': async () =>
|
|
1104
|
-
app.getVersion(),
|
|
1105
|
-
|
|
1106
|
-
// v2 mobile: start/stop pushing live state + summary for a session.
|
|
1107
|
-
'cmd:session:subscribe': async (payload) => {
|
|
1108
|
-
const parsed = schemas.sessionSubscribe.parse(payload);
|
|
1109
|
-
validatePath(parsed.cwd); // home-dir boundary before any fs access
|
|
1110
|
-
startSessionWatch(parsed.tabId, parsed.cwd);
|
|
1111
|
-
return { ok: true };
|
|
1112
|
-
},
|
|
1113
|
-
|
|
1114
|
-
'cmd:session:unsubscribe': async (payload) => {
|
|
1115
|
-
const parsed = schemas.ptyTabId.parse(payload);
|
|
1116
|
-
stopSessionWatch(parsed.tabId);
|
|
1117
|
-
return { ok: true };
|
|
1118
|
-
},
|
|
1119
|
-
|
|
1120
|
-
// Push a prompt into an open tab's chat queue from the paired phone
|
|
1121
|
-
// client — in-process call, no admin HTTP hop needed since webRemote
|
|
1122
|
-
// already runs inside the Electron main process (PRD 753).
|
|
1123
|
-
'cmd:chat:send': async (payload) => {
|
|
1124
|
-
const parsed = schemas.chatExternalSend.parse(payload);
|
|
1125
|
-
chatRunner.enqueueExternalPrompt(parsed.tabId, parsed.prompt);
|
|
1126
|
-
return { ok: true };
|
|
1127
|
-
},
|
|
1128
|
-
};
|
|
1129
|
-
|
|
1130
|
-
return _dispatchMap;
|
|
1131
|
-
}
|
|
1132
|
-
|
|
1133
|
-
async function dispatchCommand(type, payload) {
|
|
1134
|
-
const map = getDispatchMap();
|
|
1135
|
-
const handler = map[type];
|
|
1136
|
-
if (!handler) throw new Error(`no handler for ${type}`);
|
|
1137
|
-
return handler(payload);
|
|
1138
|
-
}
|
|
1139
|
-
|
|
1140
|
-
// ─── Pairing ─────────────────────────────────────────────────────────────────
|
|
1141
|
-
|
|
1142
|
-
async function pair(otp) {
|
|
1143
|
-
const deviceId = crypto.randomUUID();
|
|
1144
|
-
|
|
1145
|
-
// Generate E2E keypair at pair time — public key is sent to relay and stored
|
|
1146
|
-
// alongside the device token so the browser can do key agreement.
|
|
1147
|
-
const { e2ePrivateKey, e2ePublicKey } = generateE2EKeyPair();
|
|
1148
|
-
|
|
1149
|
-
const body = JSON.stringify({
|
|
1150
|
-
code: otp.trim().toUpperCase(),
|
|
1151
|
-
deviceId,
|
|
1152
|
-
devicePubKey: e2ePublicKey,
|
|
1153
|
-
});
|
|
1154
|
-
|
|
1155
|
-
let response;
|
|
1156
|
-
try {
|
|
1157
|
-
response = await httpsPost(`${RELAY_API_BASE}/pair`, body);
|
|
1158
|
-
} catch (e) {
|
|
1159
|
-
return { ok: false, error: e?.message || 'pairing request failed' };
|
|
1160
|
-
}
|
|
1161
|
-
|
|
1162
|
-
if (!response.deviceToken || !response.deviceId) {
|
|
1163
|
-
return { ok: false, error: 'relay returned no device token' };
|
|
1164
|
-
}
|
|
1165
|
-
|
|
1166
|
-
const cfg = await loadConfig();
|
|
1167
|
-
// A desktop only ever connects to the relay as ONE device; all of the user's
|
|
1168
|
-
// browsers route to it (the relay isolates by email, not per-credential). So keep
|
|
1169
|
-
// only the newly paired credential — accumulating stale entries is pure cruft that
|
|
1170
|
-
// would otherwise shadow this one and trap reconnection on a dead token.
|
|
1171
|
-
const devices = [{
|
|
1172
|
-
deviceId: response.deviceId,
|
|
1173
|
-
deviceToken: response.deviceToken, // stored only on disk at 0600
|
|
1174
|
-
// Private key stored at 0600 — same security model as device token.
|
|
1175
|
-
e2ePrivateKey,
|
|
1176
|
-
e2ePublicKey,
|
|
1177
|
-
deviceName: `Device (paired ${new Date().toISOString().slice(0, 10)})`,
|
|
1178
|
-
issuedAt: new Date().toISOString(),
|
|
1179
|
-
lastConnectedAt: null,
|
|
1180
|
-
// Set once a browser's SAS is manually confirmed; TOFU-pinned for auto-trust
|
|
1181
|
-
// on future reconnects from the same (deviceId, browserPubKey) pair.
|
|
1182
|
-
verifiedPeerPubKey: null,
|
|
1183
|
-
}];
|
|
1184
|
-
|
|
1185
|
-
await saveConfig({ ...cfg, devices });
|
|
1186
|
-
|
|
1187
|
-
if (cfg.remoteEnabled) {
|
|
1188
|
-
connect().catch(() => {});
|
|
1189
|
-
}
|
|
1190
|
-
|
|
1191
|
-
// Return only non-secret fields to the renderer
|
|
1192
|
-
return { ok: true, deviceId: response.deviceId };
|
|
1193
|
-
}
|
|
1194
|
-
|
|
1195
|
-
async function revokeDevice(deviceId) {
|
|
1196
|
-
invalidateConfigCache();
|
|
1197
|
-
const cfg = await loadConfig();
|
|
1198
|
-
const devices = (cfg.devices || []).filter((d) => d.deviceId !== deviceId);
|
|
1199
|
-
await saveConfig({ ...cfg, devices });
|
|
1200
|
-
|
|
1201
|
-
// If the active connection is for this device, disconnect
|
|
1202
|
-
if (_ws && _ws.readyState === WebSocket.OPEN) {
|
|
1203
|
-
await disconnect();
|
|
1204
|
-
// Reconnect to a different device if any remain
|
|
1205
|
-
if (devices.length > 0 && cfg.remoteEnabled) {
|
|
1206
|
-
connect().catch(() => {});
|
|
1207
|
-
}
|
|
1208
|
-
}
|
|
1209
|
-
|
|
1210
|
-
broadcastStatus();
|
|
1211
|
-
return { ok: true };
|
|
1212
|
-
}
|
|
1213
|
-
|
|
1214
|
-
/**
|
|
1215
|
-
* Panic / revoke-all: immediately disconnect the relay WS, clear all device
|
|
1216
|
-
* entries from web-remote.json, and set remoteEnabled = false.
|
|
1217
|
-
* This is the local-side "kill everything" action (ADR §4.1).
|
|
1218
|
-
*/
|
|
1219
|
-
async function revokeAllDevices() {
|
|
1220
|
-
const cfg = await loadConfig();
|
|
1221
|
-
const revokedCount = (cfg.devices || []).length;
|
|
1222
|
-
await saveConfig({ ...cfg, remoteEnabled: false, devices: [] });
|
|
1223
|
-
await disconnect(); // tears down WS + clears session key
|
|
1224
|
-
broadcastStatus();
|
|
1225
|
-
sendIfAlive(_window, 'webRemote:revoked-all', { revokedCount });
|
|
1226
|
-
return { ok: true };
|
|
1227
|
-
}
|
|
1228
|
-
|
|
1229
|
-
// ─── IPC handlers ─────────────────────────────────────────────────────────────
|
|
1230
|
-
|
|
1231
|
-
function registerRemoteHandlers() {
|
|
1232
|
-
const { validated } = require('./ipcSchemas.cjs');
|
|
1233
|
-
|
|
1234
|
-
// Returns current status without tokens — safe to expose to renderer.
|
|
1235
|
-
ipcMain.handle('webRemote:get-status', async () => {
|
|
1236
|
-
const cfg = await loadConfig();
|
|
1237
|
-
return computeStatus(cfg);
|
|
1238
|
-
});
|
|
1239
|
-
|
|
1240
|
-
// User confirmed that the SAS shown on both desktop and browser match.
|
|
1241
|
-
// Returns ok:false if the session is not in the pending_sas state (e.g. key
|
|
1242
|
-
// missing, already failed, already authenticated, or reconnected).
|
|
1243
|
-
ipcMain.handle('webRemote:confirm-sas', async () => {
|
|
1244
|
-
const { ok, error, next } = confirmSasLogic(_e2e);
|
|
1245
|
-
if (!ok) {
|
|
1246
|
-
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'confirm-sas rejected — wrong state', meta: { state: _e2e.state, error } });
|
|
1247
|
-
return { ok: false, error };
|
|
1248
|
-
}
|
|
1249
|
-
_e2e = next;
|
|
1250
|
-
// Pin this browser's pubkey to the device so future reconnects with the same
|
|
1251
|
-
// key auto-authenticate (see e2e:hello above) instead of re-prompting forever.
|
|
1252
|
-
if (_e2ePeerPubKey && _e2eDeviceId) {
|
|
1253
|
-
try {
|
|
1254
|
-
const cfg = await loadConfig();
|
|
1255
|
-
const devices = (cfg.devices || []).map((d) =>
|
|
1256
|
-
d.deviceId === _e2eDeviceId ? { ...d, verifiedPeerPubKey: _e2ePeerPubKey } : d
|
|
1257
|
-
);
|
|
1258
|
-
await saveConfig({ ...cfg, devices });
|
|
1259
|
-
} catch (e) {
|
|
1260
|
-
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'failed to persist pinned peer pubkey', meta: { error: e?.message } });
|
|
1261
|
-
}
|
|
1262
|
-
}
|
|
1263
|
-
// Flush the session list immediately — the push loop was suppressed while
|
|
1264
|
-
// state !== 'authenticated', so the mobile app would otherwise wait up to
|
|
1265
|
-
// SESSION_LIST_PUSH_MS for the first useful data.
|
|
1266
|
-
finalizeE2eAuthenticated('E2E session authenticated — SAS confirmed by user');
|
|
1267
|
-
return { ok: true };
|
|
1268
|
-
});
|
|
1269
|
-
|
|
1270
|
-
ipcMain.handle('webRemote:enable', async () => {
|
|
1271
|
-
const cfg = await loadConfig();
|
|
1272
|
-
await saveConfig({ ...cfg, remoteEnabled: true });
|
|
1273
|
-
connect().catch(() => {});
|
|
1274
|
-
broadcastStatus();
|
|
1275
|
-
return { ok: true };
|
|
1276
|
-
});
|
|
1277
|
-
|
|
1278
|
-
ipcMain.handle('webRemote:disable', async () => {
|
|
1279
|
-
const cfg = await loadConfig();
|
|
1280
|
-
await saveConfig({ ...cfg, remoteEnabled: false });
|
|
1281
|
-
await disconnect();
|
|
1282
|
-
broadcastStatus();
|
|
1283
|
-
return { ok: true };
|
|
1284
|
-
});
|
|
1285
|
-
|
|
1286
|
-
ipcMain.handle('webRemote:enable-control', async () => {
|
|
1287
|
-
const cfg = await loadConfig();
|
|
1288
|
-
await saveConfig({ ...cfg, remoteControlEnabled: true });
|
|
1289
|
-
broadcastStatus();
|
|
1290
|
-
return { ok: true };
|
|
1291
|
-
});
|
|
1292
|
-
|
|
1293
|
-
ipcMain.handle('webRemote:disable-control', async () => {
|
|
1294
|
-
const cfg = await loadConfig();
|
|
1295
|
-
await saveConfig({ ...cfg, remoteControlEnabled: false });
|
|
1296
|
-
broadcastStatus();
|
|
1297
|
-
return { ok: true };
|
|
1298
|
-
});
|
|
1299
|
-
|
|
1300
|
-
ipcMain.handle('webRemote:pair', validated(schemas.webRemotePair, async ({ otp }) => {
|
|
1301
|
-
return pair(otp);
|
|
1302
|
-
}));
|
|
1303
|
-
|
|
1304
|
-
ipcMain.handle('webRemote:revoke-device', validated(schemas.webRemoteRevokeDevice, async ({ deviceId }) => {
|
|
1305
|
-
return revokeDevice(deviceId);
|
|
1306
|
-
}));
|
|
1307
|
-
|
|
1308
|
-
// Panic button: revoke all devices, disable remote, disconnect immediately.
|
|
1309
|
-
ipcMain.handle('webRemote:revoke-all', async () => {
|
|
1310
|
-
return revokeAllDevices();
|
|
1311
|
-
});
|
|
1312
|
-
|
|
1313
|
-
ipcMain.handle('webRemote:audit-tail', validated(schemas.webRemoteAuditTail, async ({ lines }) => {
|
|
1314
|
-
const lineCount = lines || 50;
|
|
1315
|
-
const ymd = new Date().toISOString().slice(0, 10);
|
|
1316
|
-
const logPath = path.join(AUDIT_LOG_DIR, `remote-audit-${ymd}.log`);
|
|
1317
|
-
try {
|
|
1318
|
-
const text = await fsp.readFile(logPath, 'utf8');
|
|
1319
|
-
const all = text.split('\n').filter(Boolean);
|
|
1320
|
-
return { ok: true, lines: all.slice(-lineCount) };
|
|
1321
|
-
} catch (e) {
|
|
1322
|
-
if (e?.code === 'ENOENT') return { ok: true, lines: [] };
|
|
1323
|
-
return { ok: false, error: e?.message };
|
|
1324
|
-
}
|
|
1325
|
-
}));
|
|
1326
|
-
}
|
|
1327
|
-
|
|
1328
|
-
// ─── Module lifecycle ────────────────────────────────────────────────────────
|
|
1329
|
-
|
|
1330
|
-
function attachWindow(w) {
|
|
1331
|
-
_window = w;
|
|
1332
|
-
}
|
|
1333
|
-
|
|
1334
|
-
async function init() {
|
|
1335
|
-
await fsp.mkdir(AUDIT_LOG_DIR, { recursive: true });
|
|
1336
|
-
const cfg = await loadConfig();
|
|
1337
|
-
if (cfg.remoteEnabled && (cfg.devices || []).some((d) => d.deviceToken)) {
|
|
1338
|
-
connect().catch((e) => {
|
|
1339
|
-
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'init connect failed', meta: { error: e?.message } });
|
|
1340
|
-
});
|
|
1341
|
-
}
|
|
1342
|
-
}
|
|
1343
|
-
|
|
1344
|
-
function destroy() {
|
|
1345
|
-
_destroyed = true;
|
|
1346
|
-
cancelReconnect();
|
|
1347
|
-
resetE2e();
|
|
1348
|
-
if (_ws) {
|
|
1349
|
-
try { _ws.terminate(); } catch { /* */ }
|
|
1350
|
-
_ws = null;
|
|
1351
|
-
}
|
|
1352
|
-
stopHeartbeat();
|
|
1353
|
-
}
|
|
1354
|
-
|
|
1355
|
-
module.exports = {
|
|
1356
|
-
attachWindow,
|
|
1357
|
-
registerRemoteHandlers,
|
|
1358
|
-
init,
|
|
1359
|
-
destroy,
|
|
1360
|
-
// Test-only internals — not part of the IPC surface. Lets unit tests drive
|
|
1361
|
-
// e2e:hello / SAS-pinning transitions without a real relay WS connection.
|
|
1362
|
-
_internal: {
|
|
1363
|
-
handleMessage,
|
|
1364
|
-
resetE2e,
|
|
1365
|
-
getE2eState: () => _e2e,
|
|
1366
|
-
},
|
|
1367
|
-
};
|