forkoff 1.0.19 → 1.1.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/websocket.js CHANGED
@@ -6,6 +6,7 @@ const events_1 = require("events");
6
6
  const uuid_1 = require("uuid");
7
7
  const e2eeManager_1 = require("./crypto/e2eeManager");
8
8
  const server_1 = require("./server");
9
+ const cloud_client_1 = require("./cloud-client");
9
10
  // Whitelist of events allowed via encrypted_message channel (inbound from mobile)
10
11
  const ALLOWED_ENCRYPTED_EVENTS = new Set([
11
12
  'terminal_command',
@@ -35,36 +36,40 @@ const ALLOWED_ENCRYPTED_EVENTS = new Set([
35
36
  // Events that carry user data and MUST be encrypted — plaintext fallback is refused.
36
37
  // If E2EE is not established, these are queued (not sent in plaintext).
37
38
  const ENFORCED_SENSITIVE_EVENTS = new Set([
38
- // Core sensitive data
39
+ // Core sensitive data — these truly contain code/file contents
39
40
  'terminal_output',
40
41
  'read_file_response',
41
- 'directory_list_response',
42
42
  'permission_prompt',
43
43
  // Transcript data (contains full code, file contents, conversation history)
44
44
  'transcript_history',
45
45
  'transcript_update',
46
- // Claude reasoning and session data
46
+ // Claude reasoning
47
47
  'thinking_content',
48
- 'task_progress',
49
- 'tool_activity',
50
- // Approval context
48
+ // Approval context (contains code details)
51
49
  'claude_approval_request',
52
50
  'approval_request',
53
- // Session metadata (contains directory paths, file paths, working directories)
51
+ // Pending permissions (contains prompt details)
52
+ 'pending_permissions_sync',
53
+ // Session metadata (contains directory paths, session identifiers)
54
54
  'claude_session_update',
55
55
  'claude_session_batch_update',
56
+ 'claude_session_event',
57
+ // Directory/file data
58
+ 'directory_list_response',
56
59
  'terminal_cwd',
57
60
  'file_changed',
58
- // Token usage (contains session identifiers)
61
+ // Token/usage/progress data
59
62
  'token_usage',
60
- // Pending permissions (contains prompt details)
61
- 'pending_permissions_sync',
62
- // Session events (may contain error messages with paths)
63
- 'claude_session_event',
64
- // Usage analytics sync
63
+ 'task_progress',
64
+ 'tool_activity',
65
+ // Usage analytics
65
66
  'usage_stats_sync',
66
67
  'daily_usage_sync',
67
68
  'streak_info_sync',
69
+ // Tool status (contains tool state)
70
+ 'tool_status_update',
71
+ // Session loading state signal
72
+ 'session_loading',
68
73
  ]);
69
74
  // SECURITY: Inbound events from mobile that MUST arrive via E2EE decryption when active.
70
75
  // Plaintext versions are dropped when E2EE session is established with mobile peer.
@@ -84,7 +89,7 @@ const PLAINTEXT_DROP_EVENTS = [
84
89
  'directory_list', 'transcript_fetch', 'transcript_subscribe',
85
90
  'read_file', 'claude_approval_response', 'permission_response',
86
91
  'permission_rules_sync', 'claude_abort', 'tab_complete',
87
- 'usage_stats_request',
92
+ 'usage_stats_request', 'sdk_session_history',
88
93
  ];
89
94
  /** Events forwarded from server that do NOT need plaintext-drop checking */
90
95
  const PASSTHROUGH_EVENTS = [
@@ -96,10 +101,14 @@ class WebSocketClient extends events_1.EventEmitter {
96
101
  this.server = null;
97
102
  this.heartbeatInterval = null;
98
103
  this._sessionId = '';
104
+ this.isCloudRelay = false;
99
105
  this.e2eeManager = null;
100
106
  this.e2eeInitialized = false;
101
107
  // The mobile device ID learned from key exchange (used for encrypting CLI→mobile messages)
102
108
  this.e2eePeerDeviceId = null;
109
+ this._keyExchangePending = false;
110
+ this._keyExchangeDebounceTimer = null;
111
+ this._keyExchangeDebounceTarget = null;
103
112
  // Queue for sensitive messages waiting for E2EE session establishment
104
113
  this.pendingSensitiveMessages = [];
105
114
  this.usageTracker = null;
@@ -117,38 +126,89 @@ class WebSocketClient extends events_1.EventEmitter {
117
126
  if (!deviceId) {
118
127
  throw new Error('Device not registered');
119
128
  }
129
+ this.isCloudRelay = false;
120
130
  this.server = new server_1.EmbeddedRelayServer({
121
131
  port,
122
132
  deviceId,
123
133
  deviceName: config_1.config.deviceName,
124
134
  });
125
135
  await this.server.start();
136
+ this.wireUpTransportEvents();
137
+ }
138
+ /** Connect to a cloud relay as a Socket.io client (instead of running a local server) */
139
+ async connectToRelay(url) {
140
+ const deviceId = config_1.config.deviceId;
141
+ if (!deviceId) {
142
+ throw new Error('Device not registered');
143
+ }
144
+ this.isCloudRelay = true;
145
+ const cloudClient = new cloud_client_1.CloudRelayClient({
146
+ url,
147
+ deviceId,
148
+ deviceName: config_1.config.deviceName,
149
+ relayToken: config_1.config.relayToken,
150
+ });
151
+ this.server = cloudClient;
152
+ await cloudClient.start();
153
+ this.wireUpTransportEvents();
154
+ }
155
+ /** Wire up event forwarding from the transport (shared between startServer and connectToRelay) */
156
+ wireUpTransportEvents() {
157
+ if (!this.server)
158
+ return;
126
159
  // When mobile connects, emit connected + start heartbeat + initiate E2EE
127
160
  this.server.on('mobile_connected', (data) => {
128
161
  console.log(`[WS] Mobile connected: ${data.deviceId}`);
129
162
  this.emit('connected');
130
163
  this.startHeartbeat();
131
- // Initiate E2EE key exchange with the connected mobile device
132
- if (this.e2eeManager && this.e2eeInitialized) {
164
+ // SECURITY: Clear stale E2EE peer state on every mobile connect.
165
+ // Mobile sessions are in-memory only — lost on app restart/reconnect.
166
+ // Without this, CLI would encrypt with a restored persisted session
167
+ // that mobile can't decrypt, causing "No session established" errors.
168
+ // A fresh key exchange will re-establish the session after debounce.
169
+ if (this.e2eePeerDeviceId) {
170
+ console.log(`[E2EE] Clearing stale peer state for ${this.e2eePeerDeviceId} (mobile reconnected)`);
171
+ this.e2eeManager?.clearSession(this.e2eePeerDeviceId);
172
+ this.e2eePeerDeviceId = null;
173
+ }
174
+ // Debounce E2EE key exchange initiation.
175
+ // Relay fires multiple mobile_connected events with different IDs per connection cycle.
176
+ // Wait for them to settle, then initiate ONE key exchange with the last ID.
177
+ // NOTE: Don't guard on e2eeInitialized here — initE2EE() is async and may not
178
+ // be done when mobile_connected fires. Check inside the callback instead;
179
+ // by the time the 1.5s debounce fires, initE2EE() will have completed.
180
+ if (this._keyExchangeDebounceTimer) {
181
+ clearTimeout(this._keyExchangeDebounceTimer);
182
+ }
183
+ this._keyExchangeDebounceTarget = data.deviceId;
184
+ this._keyExchangeDebounceTimer = setTimeout(() => {
185
+ this._keyExchangeDebounceTimer = null;
186
+ const targetId = this._keyExchangeDebounceTarget;
187
+ if (!targetId || !this.e2eeManager || !this.e2eeInitialized)
188
+ return;
133
189
  try {
134
- // Clear any old session keys for this device — forces fresh key exchange.
135
- // Without this, queued messages would be encrypted with stale keys from
136
- // a previous connection that the mobile no longer has.
137
- this.e2eeManager.clearSession(data.deviceId);
138
- const initPayload = this.e2eeManager.createKeyExchangeInit(data.deviceId);
190
+ this._keyExchangePending = true;
191
+ console.log(`[E2EE] Initiating key exchange with ${targetId} (after debounce)`);
192
+ this.e2eeManager.clearSession(targetId);
193
+ const initPayload = this.e2eeManager.createKeyExchangeInit(targetId);
139
194
  this.server?.emitToMobile('encrypted_key_exchange_init', {
140
195
  ...initPayload,
141
- recipientDeviceId: data.deviceId,
196
+ recipientDeviceId: targetId,
142
197
  });
143
- // E2EE key exchange initiated
144
198
  }
145
199
  catch (err) {
200
+ this._keyExchangePending = false;
146
201
  console.warn('[E2EE] Failed to initiate key exchange');
147
202
  }
148
- }
203
+ }, 1500); // 1.5s debounce — wait for all mobile_connected events to settle
149
204
  });
150
205
  this.server.on('mobile_disconnected', (data) => {
151
206
  console.log(`[WS] Mobile disconnected: ${data.reason}`);
207
+ if (this._keyExchangeDebounceTimer) {
208
+ clearTimeout(this._keyExchangeDebounceTimer);
209
+ this._keyExchangeDebounceTimer = null;
210
+ }
211
+ this._keyExchangePending = false;
152
212
  this.emit('disconnected', data.reason);
153
213
  this.stopHeartbeat();
154
214
  });
@@ -172,20 +232,24 @@ class WebSocketClient extends events_1.EventEmitter {
172
232
  this.emit(event, data);
173
233
  });
174
234
  }
175
- // On successful pairing, reset TOFU trust for that specific device (handles re-pair with new keys).
176
- // Don't delete pending exchange or re-initiate — the init from mobile_connected is in-flight.
235
+ // On successful pairing, reset TOFU trust
177
236
  this.server.on('pair_device', (data) => {
178
237
  const mobileDeviceId = data.mobileDeviceId;
179
238
  if (mobileDeviceId && this.e2eeManager) {
180
239
  this.e2eeManager.clearTrustOnly(mobileDeviceId);
181
- // Reset TOFU trust for re-pair
182
240
  }
183
241
  });
184
- // E2EE key exchange events — forwarded from server, handled here
242
+ // E2EE key exchange events
185
243
  this.server.on('encrypted_key_exchange_init', (data) => {
186
244
  if (!this.e2eeManager)
187
245
  return;
246
+ // Guard: ignore our own init echoed back by the relay
247
+ if (data.senderDeviceId === config_1.config.deviceId) {
248
+ console.log('[E2EE] Ignoring own key exchange init echo');
249
+ return;
250
+ }
188
251
  try {
252
+ console.log(`[E2EE] Received key exchange init from ${data.senderDeviceId}`);
189
253
  const ack = this.e2eeManager.handleKeyExchangeInit(data);
190
254
  this.e2eePeerDeviceId = data.senderDeviceId;
191
255
  this.server?.emitToMobile('encrypted_key_exchange_ack', {
@@ -198,21 +262,33 @@ class WebSocketClient extends events_1.EventEmitter {
198
262
  this.sendAllUsageStats();
199
263
  }
200
264
  catch (err) {
201
- console.error('[E2EE] Key exchange init failed');
265
+ const msg = err instanceof Error ? err.message : String(err);
266
+ console.error(`[E2EE] Key exchange init failed: ${msg}`);
202
267
  }
203
268
  });
204
269
  this.server.on('encrypted_key_exchange_ack', (data) => {
205
270
  if (!this.e2eeManager)
206
271
  return;
207
272
  try {
273
+ console.log(`[E2EE] Received key exchange ack from ${data.senderDeviceId}`);
208
274
  this.e2eeManager.handleKeyExchangeAck(data);
275
+ this._keyExchangePending = false;
209
276
  this.e2eePeerDeviceId = data.senderDeviceId;
277
+ console.log(`[E2EE] Key exchange complete — session established with ${data.senderDeviceId}`);
210
278
  this.emit('e2ee_established', { peerDeviceId: data.senderDeviceId });
211
279
  this.flushSensitiveQueue();
212
280
  this.sendAllUsageStats();
213
281
  }
214
282
  catch (err) {
215
- console.error('[E2EE] Key exchange ack failed');
283
+ const msg = err instanceof Error ? err.message : String(err);
284
+ // Only suppress if it's truly a duplicate ack (pending exchange already consumed)
285
+ if (msg.includes('No pending key exchange')) {
286
+ console.log(`[E2EE] Duplicate ack from ${data.senderDeviceId} — ignored`);
287
+ }
288
+ else {
289
+ console.error(`[E2EE] Key exchange ack failed: ${msg}`);
290
+ this._keyExchangePending = false;
291
+ }
216
292
  }
217
293
  });
218
294
  // Encrypted messages — decrypt and re-emit as original event
@@ -227,7 +303,6 @@ class WebSocketClient extends events_1.EventEmitter {
227
303
  console.error('[E2EE] Decryption failed — message dropped');
228
304
  return;
229
305
  }
230
- // Validate JSON structure separately from decryption
231
306
  let parsed;
232
307
  try {
233
308
  parsed = JSON.parse(plaintext);
@@ -236,7 +311,6 @@ class WebSocketClient extends events_1.EventEmitter {
236
311
  console.error('[E2EE] Invalid JSON in decrypted message — dropped');
237
312
  return;
238
313
  }
239
- // Validate payload structure
240
314
  if (!parsed || typeof parsed !== 'object') {
241
315
  console.error('[E2EE] Decrypted payload is not an object — dropped');
242
316
  return;
@@ -253,7 +327,7 @@ class WebSocketClient extends events_1.EventEmitter {
253
327
  }
254
328
  this.emit(eventName, payload._data);
255
329
  });
256
- // Initialize E2EE (non-blocking — don't delay server start)
330
+ // Initialize E2EE (non-blocking)
257
331
  this.initE2EE().catch((err) => {
258
332
  console.warn('[E2EE] \u26a0 End-to-end encryption initialization failed. Messages will be sent without E2EE protection.');
259
333
  if (process.env.DEBUG) {
@@ -261,7 +335,7 @@ class WebSocketClient extends events_1.EventEmitter {
261
335
  }
262
336
  });
263
337
  }
264
- /** Set pairing code on the embedded server for in-process validation */
338
+ /** Set pairing code on the transport for validation */
265
339
  setPairingCode(code) {
266
340
  this.server?.setPairingCode(code);
267
341
  }
@@ -301,6 +375,35 @@ class WebSocketClient extends events_1.EventEmitter {
301
375
  return;
302
376
  }
303
377
  catch (err) {
378
+ const errMsg = err instanceof Error ? err.message : String(err);
379
+ if (errMsg.includes('re-key required')) {
380
+ // Session expired — automatically initiate a new key exchange
381
+ console.warn(`[E2EE] Session expired with ${targetDeviceId} — initiating re-key`);
382
+ this.e2eePeerDeviceId = null;
383
+ try {
384
+ this._keyExchangePending = true;
385
+ this.e2eeManager.clearSession(targetDeviceId);
386
+ const initPayload = this.e2eeManager.createKeyExchangeInit(targetDeviceId);
387
+ this.server?.emitToMobile('encrypted_key_exchange_init', {
388
+ ...initPayload,
389
+ recipientDeviceId: targetDeviceId,
390
+ });
391
+ }
392
+ catch (reKeyErr) {
393
+ this._keyExchangePending = false;
394
+ console.error('[E2EE] Re-key initiation failed');
395
+ }
396
+ // Queue the original message so it can be sent once re-keying completes
397
+ if (this.pendingSensitiveMessages.length < WebSocketClient.MAX_PENDING_SENSITIVE) {
398
+ this.pendingSensitiveMessages.push({
399
+ event,
400
+ data,
401
+ targetDeviceId,
402
+ queuedAt: Date.now(),
403
+ });
404
+ }
405
+ return;
406
+ }
304
407
  console.error('[E2EE] Encryption failed, message NOT sent (refusing plaintext fallback)');
305
408
  return;
306
409
  }
@@ -357,7 +460,9 @@ class WebSocketClient extends events_1.EventEmitter {
357
460
  sent++;
358
461
  }
359
462
  this.pendingSensitiveMessages = [];
360
- // Flushed sensitive queue
463
+ if (sent > 0 || dropped > 0) {
464
+ console.log(`[E2EE] Flushed sensitive queue: ${sent} sent, ${dropped} expired`);
465
+ }
361
466
  }
362
467
  /** Get the E2EE manager (for external key exchange initiation) */
363
468
  getE2EEManager() {
@@ -369,6 +474,10 @@ class WebSocketClient extends events_1.EventEmitter {
369
474
  }
370
475
  /** SECURITY: Check if plaintext inbound events should be dropped (E2EE active with peer) */
371
476
  shouldDropPlaintextInbound() {
477
+ // In cloud relay mode, events arrive via API forwarding — trusted, don't drop
478
+ if (this.isCloudRelay)
479
+ return false;
480
+ // In embedded relay mode, mobile connects directly — drop plaintext when E2EE active
372
481
  return !!(this.e2eePeerDeviceId && this.e2eeManager?.hasSessionKey(this.e2eePeerDeviceId));
373
482
  }
374
483
  disconnect() {
@@ -377,6 +486,7 @@ class WebSocketClient extends events_1.EventEmitter {
377
486
  this.e2eeManager = null;
378
487
  this.e2eeInitialized = false;
379
488
  this.e2eePeerDeviceId = null;
489
+ this.isCloudRelay = false;
380
490
  if (this.server) {
381
491
  this.server.stop();
382
492
  this.server = null;
@@ -393,11 +503,11 @@ class WebSocketClient extends events_1.EventEmitter {
393
503
  this.heartbeatInterval = null;
394
504
  }
395
505
  }
396
- // Send heartbeat to connected mobile
506
+ // Send heartbeat to connected mobile (plaintext — only carries status, no sensitive data)
397
507
  sendHeartbeat(status = 'online') {
398
508
  this.server?.emitToMobile('device_status', { status, deviceId: config_1.config.deviceId });
399
509
  }
400
- // Update device status
510
+ // Update device status (plaintext — only carries status, no sensitive data)
401
511
  updateStatus(status) {
402
512
  this.server?.emitToMobile('device_status', { status, deviceId: config_1.config.deviceId });
403
513
  }
@@ -408,17 +518,20 @@ class WebSocketClient extends events_1.EventEmitter {
408
518
  // Send multiple Claude sessions as a single batch event (sensitive — contains directory paths)
409
519
  sendClaudeSessions(sessions) {
410
520
  if (sessions.length > 0) {
521
+ const peer = this.e2eePeerDeviceId;
522
+ const hasE2EE = peer ? this.e2eeManager?.hasSessionKey(peer) : false;
523
+ console.log(`[WS] sendClaudeSessions: ${sessions.length} sessions, peer=${peer || 'none'}, e2ee=${hasE2EE}, queue=${this.pendingSensitiveMessages.length}`);
411
524
  const withDeviceId = sessions.map(s => ({ ...s, deviceId: config_1.config.deviceId }));
412
- this.emitSensitive('claude_session_batch_update', { sessions: withDeviceId }, this.e2eePeerDeviceId ?? undefined);
525
+ this.emitSensitive('claude_session_batch_update', { sessions: withDeviceId }, peer ?? undefined);
413
526
  }
414
527
  }
415
- // Send tool status update (e.g., Claude active/inactive)
528
+ // Send tool status update (e.g., Claude active/inactive) — encrypted via E2EE
416
529
  sendToolStatusUpdate(toolType, status) {
417
- this.server?.emitToMobile('tool_status_update', {
530
+ this.emitSensitive('tool_status_update', {
418
531
  toolType,
419
532
  status,
420
533
  timestamp: new Date().toISOString(),
421
- });
534
+ }, this.e2eePeerDeviceId ?? undefined);
422
535
  }
423
536
  // Send approval request (sensitive — contains code change descriptions)
424
537
  sendApprovalRequest(data) {
@@ -511,6 +624,10 @@ class WebSocketClient extends events_1.EventEmitter {
511
624
  this.emitSensitive('streak_info_sync', { ...streak, deviceId }, this.e2eePeerDeviceId ?? undefined);
512
625
  console.log(`[WS] Sent usage stats sync to mobile`);
513
626
  }
627
+ // Send session loading state to mobile (so mobile shows/hides loading indicator)
628
+ sendSessionLoading(data) {
629
+ this.emitSensitive('session_loading', data, this.e2eePeerDeviceId ?? undefined);
630
+ }
514
631
  // Send Claude session event (sensitive — may contain error messages with paths)
515
632
  sendClaudeSessionEvent(data) {
516
633
  this.emitSensitive('claude_session_event', data, this.e2eePeerDeviceId ?? undefined);
@@ -520,7 +637,7 @@ class WebSocketClient extends events_1.EventEmitter {
520
637
  }
521
638
  }
522
639
  exports.WebSocketClient = WebSocketClient;
523
- WebSocketClient.SENSITIVE_QUEUE_TTL_MS = 30000; // 30 seconds max wait
640
+ WebSocketClient.SENSITIVE_QUEUE_TTL_MS = 120000; // 2 minutes max wait
524
641
  WebSocketClient.MAX_PENDING_SENSITIVE = 200;
525
642
  exports.wsClient = new WebSocketClient();
526
643
  exports.default = exports.wsClient;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forkoff",
3
- "version": "1.0.19",
3
+ "version": "1.1.0",
4
4
  "description": "CLI tool to connect your AI coding tools to mobile | Open Beta - Download the app: https://testflight.apple.com/join/dhh5FrN7",
5
5
  "main": "dist/integration.js",
6
6
  "types": "dist/integration.d.ts",
@@ -50,6 +50,7 @@
50
50
  },
51
51
  "license": "MIT",
52
52
  "dependencies": {
53
+ "@noble/hashes": "^1.8.0",
53
54
  "chalk": "^4.1.2",
54
55
  "chokidar": "^3.6.0",
55
56
  "commander": "^14.0.2",
@@ -60,6 +61,7 @@
60
61
  "ora": "^5.4.1",
61
62
  "qrcode-terminal": "^0.12.0",
62
63
  "socket.io": "^4.8.3",
64
+ "socket.io-client": "^4.8.3",
63
65
  "tweetnacl": "^1.0.3",
64
66
  "tweetnacl-util": "^0.15.1",
65
67
  "uuid": "^8.3.2"