forkoff 1.0.17 → 1.0.19

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.
Files changed (158) hide show
  1. package/LICENSE +11 -7
  2. package/README.md +77 -118
  3. package/dist/approval.d.ts +1 -0
  4. package/dist/approval.js +9 -0
  5. package/dist/config.d.ts +3 -0
  6. package/dist/config.js +62 -16
  7. package/dist/crypto/e2eeManager.d.ts +49 -52
  8. package/dist/crypto/e2eeManager.js +256 -181
  9. package/dist/crypto/encryption.d.ts +8 -10
  10. package/dist/crypto/encryption.js +29 -94
  11. package/dist/crypto/index.d.ts +10 -0
  12. package/dist/crypto/index.js +22 -0
  13. package/dist/crypto/keyExchange.d.ts +6 -20
  14. package/dist/crypto/keyExchange.js +18 -110
  15. package/dist/crypto/keyGeneration.d.ts +2 -13
  16. package/dist/crypto/keyGeneration.js +14 -88
  17. package/dist/crypto/keyStorage.d.ts +32 -5
  18. package/dist/crypto/keyStorage.js +152 -8
  19. package/dist/crypto/sessionPersistence.d.ts +7 -13
  20. package/dist/crypto/sessionPersistence.js +108 -33
  21. package/dist/crypto/types.d.ts +24 -3
  22. package/dist/crypto/types.js +2 -1
  23. package/dist/crypto/websocketE2EE.d.ts +6 -17
  24. package/dist/crypto/websocketE2EE.js +21 -38
  25. package/dist/index.js +203 -280
  26. package/dist/integration.d.ts +0 -1
  27. package/dist/integration.js +2 -4
  28. package/dist/logger.d.ts +15 -0
  29. package/dist/logger.js +209 -1
  30. package/dist/server.d.ts +30 -0
  31. package/dist/server.js +162 -0
  32. package/dist/startup.js +15 -6
  33. package/dist/terminal.d.ts +1 -0
  34. package/dist/terminal.js +94 -1
  35. package/dist/tools/claude-process.d.ts +8 -0
  36. package/dist/tools/claude-process.js +199 -26
  37. package/dist/tools/claude-sessions.d.ts +1 -0
  38. package/dist/tools/claude-sessions.js +36 -10
  39. package/dist/tools/detector.js +11 -3
  40. package/dist/tools/permission-hook.js +94 -27
  41. package/dist/tools/permission-ipc.d.ts +1 -0
  42. package/dist/tools/permission-ipc.js +61 -14
  43. package/dist/transcript-streamer.d.ts +1 -0
  44. package/dist/transcript-streamer.js +18 -4
  45. package/dist/usage-tracker.d.ts +45 -0
  46. package/dist/usage-tracker.js +243 -0
  47. package/dist/websocket.d.ts +43 -12
  48. package/dist/websocket.js +418 -214
  49. package/package.json +5 -4
  50. package/dist/__tests__/cli-commands.test.d.ts +0 -6
  51. package/dist/__tests__/cli-commands.test.d.ts.map +0 -1
  52. package/dist/__tests__/cli-commands.test.js +0 -213
  53. package/dist/__tests__/cli-commands.test.js.map +0 -1
  54. package/dist/__tests__/crypto/e2e-integration.test.d.ts +0 -17
  55. package/dist/__tests__/crypto/e2e-integration.test.d.ts.map +0 -1
  56. package/dist/__tests__/crypto/e2e-integration.test.js +0 -338
  57. package/dist/__tests__/crypto/e2e-integration.test.js.map +0 -1
  58. package/dist/__tests__/crypto/e2eeManager.test.d.ts +0 -2
  59. package/dist/__tests__/crypto/e2eeManager.test.d.ts.map +0 -1
  60. package/dist/__tests__/crypto/e2eeManager.test.js +0 -242
  61. package/dist/__tests__/crypto/e2eeManager.test.js.map +0 -1
  62. package/dist/__tests__/crypto/encryption.test.d.ts +0 -2
  63. package/dist/__tests__/crypto/encryption.test.d.ts.map +0 -1
  64. package/dist/__tests__/crypto/encryption.test.js +0 -116
  65. package/dist/__tests__/crypto/encryption.test.js.map +0 -1
  66. package/dist/__tests__/crypto/keyExchange.test.d.ts +0 -2
  67. package/dist/__tests__/crypto/keyExchange.test.d.ts.map +0 -1
  68. package/dist/__tests__/crypto/keyExchange.test.js +0 -84
  69. package/dist/__tests__/crypto/keyExchange.test.js.map +0 -1
  70. package/dist/__tests__/crypto/keyGeneration.test.d.ts +0 -2
  71. package/dist/__tests__/crypto/keyGeneration.test.d.ts.map +0 -1
  72. package/dist/__tests__/crypto/keyGeneration.test.js +0 -61
  73. package/dist/__tests__/crypto/keyGeneration.test.js.map +0 -1
  74. package/dist/__tests__/crypto/keyStorage.test.d.ts +0 -2
  75. package/dist/__tests__/crypto/keyStorage.test.d.ts.map +0 -1
  76. package/dist/__tests__/crypto/keyStorage.test.js +0 -133
  77. package/dist/__tests__/crypto/keyStorage.test.js.map +0 -1
  78. package/dist/__tests__/crypto/websocketIntegration.test.d.ts +0 -2
  79. package/dist/__tests__/crypto/websocketIntegration.test.d.ts.map +0 -1
  80. package/dist/__tests__/crypto/websocketIntegration.test.js +0 -259
  81. package/dist/__tests__/crypto/websocketIntegration.test.js.map +0 -1
  82. package/dist/__tests__/startup.test.d.ts +0 -11
  83. package/dist/__tests__/startup.test.d.ts.map +0 -1
  84. package/dist/__tests__/startup.test.js +0 -241
  85. package/dist/__tests__/startup.test.js.map +0 -1
  86. package/dist/__tests__/tools/claude-process.test.d.ts +0 -8
  87. package/dist/__tests__/tools/claude-process.test.d.ts.map +0 -1
  88. package/dist/__tests__/tools/claude-process.test.js +0 -430
  89. package/dist/__tests__/tools/claude-process.test.js.map +0 -1
  90. package/dist/__tests__/tools/permission-hook.test.d.ts +0 -17
  91. package/dist/__tests__/tools/permission-hook.test.d.ts.map +0 -1
  92. package/dist/__tests__/tools/permission-hook.test.js +0 -616
  93. package/dist/__tests__/tools/permission-hook.test.js.map +0 -1
  94. package/dist/__tests__/tools/permission-ipc.test.d.ts +0 -11
  95. package/dist/__tests__/tools/permission-ipc.test.d.ts.map +0 -1
  96. package/dist/__tests__/tools/permission-ipc.test.js +0 -612
  97. package/dist/__tests__/tools/permission-ipc.test.js.map +0 -1
  98. package/dist/__tests__/websocket.test.d.ts +0 -13
  99. package/dist/__tests__/websocket.test.d.ts.map +0 -1
  100. package/dist/__tests__/websocket.test.js +0 -204
  101. package/dist/__tests__/websocket.test.js.map +0 -1
  102. package/dist/api.d.ts +0 -44
  103. package/dist/api.d.ts.map +0 -1
  104. package/dist/api.js +0 -76
  105. package/dist/api.js.map +0 -1
  106. package/dist/approval.d.ts.map +0 -1
  107. package/dist/approval.js.map +0 -1
  108. package/dist/config.d.ts.map +0 -1
  109. package/dist/config.js.map +0 -1
  110. package/dist/crypto/e2eeManager.d.ts.map +0 -1
  111. package/dist/crypto/e2eeManager.js.map +0 -1
  112. package/dist/crypto/encryption.d.ts.map +0 -1
  113. package/dist/crypto/encryption.js.map +0 -1
  114. package/dist/crypto/keyExchange.d.ts.map +0 -1
  115. package/dist/crypto/keyExchange.js.map +0 -1
  116. package/dist/crypto/keyGeneration.d.ts.map +0 -1
  117. package/dist/crypto/keyGeneration.js.map +0 -1
  118. package/dist/crypto/keyStorage.d.ts.map +0 -1
  119. package/dist/crypto/keyStorage.js.map +0 -1
  120. package/dist/crypto/sessionPersistence.d.ts.map +0 -1
  121. package/dist/crypto/sessionPersistence.js.map +0 -1
  122. package/dist/crypto/types.d.ts.map +0 -1
  123. package/dist/crypto/types.js.map +0 -1
  124. package/dist/crypto/websocketE2EE.d.ts.map +0 -1
  125. package/dist/crypto/websocketE2EE.js.map +0 -1
  126. package/dist/index.d.ts.map +0 -1
  127. package/dist/index.js.map +0 -1
  128. package/dist/integration.d.ts.map +0 -1
  129. package/dist/integration.js.map +0 -1
  130. package/dist/logger.d.ts.map +0 -1
  131. package/dist/logger.js.map +0 -1
  132. package/dist/startup.d.ts.map +0 -1
  133. package/dist/startup.js.map +0 -1
  134. package/dist/terminal.d.ts.map +0 -1
  135. package/dist/terminal.js.map +0 -1
  136. package/dist/tools/__tests__/claude-sessions.test.d.ts +0 -2
  137. package/dist/tools/__tests__/claude-sessions.test.d.ts.map +0 -1
  138. package/dist/tools/__tests__/claude-sessions.test.js +0 -306
  139. package/dist/tools/__tests__/claude-sessions.test.js.map +0 -1
  140. package/dist/tools/claude-hooks.d.ts.map +0 -1
  141. package/dist/tools/claude-hooks.js.map +0 -1
  142. package/dist/tools/claude-process.d.ts.map +0 -1
  143. package/dist/tools/claude-process.js.map +0 -1
  144. package/dist/tools/claude-sessions.d.ts.map +0 -1
  145. package/dist/tools/claude-sessions.js.map +0 -1
  146. package/dist/tools/detector.d.ts.map +0 -1
  147. package/dist/tools/detector.js.map +0 -1
  148. package/dist/tools/index.d.ts.map +0 -1
  149. package/dist/tools/index.js.map +0 -1
  150. package/dist/tools/permission-hook.d.ts.map +0 -1
  151. package/dist/tools/permission-hook.js.map +0 -1
  152. package/dist/tools/permission-ipc.d.ts.map +0 -1
  153. package/dist/tools/permission-ipc.js.map +0 -1
  154. package/dist/transcript-streamer.d.ts.map +0 -1
  155. package/dist/transcript-streamer.js.map +0 -1
  156. package/dist/websocket.d.ts.map +0 -1
  157. package/dist/websocket.js.map +0 -1
  158. package/jest.config.js +0 -18
package/dist/websocket.js CHANGED
@@ -1,17 +1,108 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.wsClient = exports.WebSocketClient = void 0;
4
- const socket_io_client_1 = require("socket.io-client");
5
4
  const config_1 = require("./config");
6
5
  const events_1 = require("events");
7
6
  const uuid_1 = require("uuid");
7
+ const e2eeManager_1 = require("./crypto/e2eeManager");
8
+ const server_1 = require("./server");
9
+ // Whitelist of events allowed via encrypted_message channel (inbound from mobile)
10
+ const ALLOWED_ENCRYPTED_EVENTS = new Set([
11
+ 'terminal_command',
12
+ 'terminal_create',
13
+ 'terminal_resize',
14
+ 'terminal_close',
15
+ 'claude_start_session',
16
+ 'claude_resume_session',
17
+ 'claude_stop_session',
18
+ 'user_message',
19
+ 'directory_list',
20
+ 'read_file',
21
+ 'transcript_fetch',
22
+ 'transcript_subscribe',
23
+ 'transcript_unsubscribe',
24
+ 'permission_response',
25
+ 'permission_rules_sync',
26
+ 'session_settings_update',
27
+ 'transcript_subscribe_sdk',
28
+ 'tab_complete',
29
+ 'claude_approval_response',
30
+ 'approval_response',
31
+ 'sdk_session_history',
32
+ 'claude_abort',
33
+ 'usage_stats_request',
34
+ ]);
35
+ // Events that carry user data and MUST be encrypted — plaintext fallback is refused.
36
+ // If E2EE is not established, these are queued (not sent in plaintext).
37
+ const ENFORCED_SENSITIVE_EVENTS = new Set([
38
+ // Core sensitive data
39
+ 'terminal_output',
40
+ 'read_file_response',
41
+ 'directory_list_response',
42
+ 'permission_prompt',
43
+ // Transcript data (contains full code, file contents, conversation history)
44
+ 'transcript_history',
45
+ 'transcript_update',
46
+ // Claude reasoning and session data
47
+ 'thinking_content',
48
+ 'task_progress',
49
+ 'tool_activity',
50
+ // Approval context
51
+ 'claude_approval_request',
52
+ 'approval_request',
53
+ // Session metadata (contains directory paths, file paths, working directories)
54
+ 'claude_session_update',
55
+ 'claude_session_batch_update',
56
+ 'terminal_cwd',
57
+ 'file_changed',
58
+ // Token usage (contains session identifiers)
59
+ '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
65
+ 'usage_stats_sync',
66
+ 'daily_usage_sync',
67
+ 'streak_info_sync',
68
+ ]);
69
+ // SECURITY: Inbound events from mobile that MUST arrive via E2EE decryption when active.
70
+ // Plaintext versions are dropped when E2EE session is established with mobile peer.
71
+ const ENFORCED_INBOUND_EVENTS = new Set([
72
+ 'terminal_command', 'user_message', 'read_file', 'directory_list',
73
+ 'tab_complete', 'permission_response', 'claude_approval_response',
74
+ 'approval_response', 'rpc_response', 'terminal_create',
75
+ 'sdk_session_history', 'claude_abort', 'claude_start_session',
76
+ 'claude_resume_session', 'transcript_fetch', 'transcript_subscribe',
77
+ 'permission_rules_sync',
78
+ 'usage_stats_request',
79
+ ]);
80
+ /** Events forwarded from server that need plaintext-drop checking */
81
+ const PLAINTEXT_DROP_EVENTS = [
82
+ 'terminal_create', 'terminal_command', 'approval_response',
83
+ 'user_message', 'claude_resume_session', 'claude_start_session',
84
+ 'directory_list', 'transcript_fetch', 'transcript_subscribe',
85
+ 'read_file', 'claude_approval_response', 'permission_response',
86
+ 'permission_rules_sync', 'claude_abort', 'tab_complete',
87
+ 'usage_stats_request',
88
+ ];
89
+ /** Events forwarded from server that do NOT need plaintext-drop checking */
90
+ const PASSTHROUGH_EVENTS = [
91
+ 'transcript_unsubscribe', 'claude_sessions_request', 'pair_device',
92
+ ];
8
93
  class WebSocketClient extends events_1.EventEmitter {
9
94
  constructor() {
10
95
  super(...arguments);
11
- this.socket = null;
12
- this.reconnectAttempts = 0;
96
+ this.server = null;
13
97
  this.heartbeatInterval = null;
14
98
  this._sessionId = '';
99
+ this.e2eeManager = null;
100
+ this.e2eeInitialized = false;
101
+ // The mobile device ID learned from key exchange (used for encrypting CLI→mobile messages)
102
+ this.e2eePeerDeviceId = null;
103
+ // Queue for sensitive messages waiting for E2EE session establishment
104
+ this.pendingSensitiveMessages = [];
105
+ this.usageTracker = null;
15
106
  }
16
107
  // Unique session ID for this CLI connection
17
108
  get sessionId() {
@@ -20,173 +111,275 @@ class WebSocketClient extends events_1.EventEmitter {
20
111
  }
21
112
  return this._sessionId;
22
113
  }
23
- connect() {
24
- return new Promise((resolve, reject) => {
25
- if (this.socket?.connected) {
26
- resolve();
114
+ /** Start the embedded relay server and wire up event forwarding */
115
+ async startServer(port) {
116
+ const deviceId = config_1.config.deviceId;
117
+ if (!deviceId) {
118
+ throw new Error('Device not registered');
119
+ }
120
+ this.server = new server_1.EmbeddedRelayServer({
121
+ port,
122
+ deviceId,
123
+ deviceName: config_1.config.deviceName,
124
+ });
125
+ await this.server.start();
126
+ // When mobile connects, emit connected + start heartbeat + initiate E2EE
127
+ this.server.on('mobile_connected', (data) => {
128
+ console.log(`[WS] Mobile connected: ${data.deviceId}`);
129
+ this.emit('connected');
130
+ this.startHeartbeat();
131
+ // Initiate E2EE key exchange with the connected mobile device
132
+ if (this.e2eeManager && this.e2eeInitialized) {
133
+ 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);
139
+ this.server?.emitToMobile('encrypted_key_exchange_init', {
140
+ ...initPayload,
141
+ recipientDeviceId: data.deviceId,
142
+ });
143
+ // E2EE key exchange initiated
144
+ }
145
+ catch (err) {
146
+ console.warn('[E2EE] Failed to initiate key exchange');
147
+ }
148
+ }
149
+ });
150
+ this.server.on('mobile_disconnected', (data) => {
151
+ console.log(`[WS] Mobile disconnected: ${data.reason}`);
152
+ this.emit('disconnected', data.reason);
153
+ this.stopHeartbeat();
154
+ });
155
+ // Forward events that need plaintext-drop check
156
+ for (const event of PLAINTEXT_DROP_EVENTS) {
157
+ this.server.on(event, (data) => {
158
+ if (this.shouldDropPlaintextInbound())
159
+ return;
160
+ if (process.env.DEBUG) {
161
+ console.log(`[WS] Received ${event}`);
162
+ }
163
+ this.emit(event, data);
164
+ });
165
+ }
166
+ // Forward events that pass through without plaintext-drop check
167
+ for (const event of PASSTHROUGH_EVENTS) {
168
+ this.server.on(event, (data) => {
169
+ if (process.env.DEBUG) {
170
+ console.log(`[WS] Received ${event}`);
171
+ }
172
+ this.emit(event, data);
173
+ });
174
+ }
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.
177
+ this.server.on('pair_device', (data) => {
178
+ const mobileDeviceId = data.mobileDeviceId;
179
+ if (mobileDeviceId && this.e2eeManager) {
180
+ this.e2eeManager.clearTrustOnly(mobileDeviceId);
181
+ // Reset TOFU trust for re-pair
182
+ }
183
+ });
184
+ // E2EE key exchange events — forwarded from server, handled here
185
+ this.server.on('encrypted_key_exchange_init', (data) => {
186
+ if (!this.e2eeManager)
27
187
  return;
188
+ try {
189
+ const ack = this.e2eeManager.handleKeyExchangeInit(data);
190
+ this.e2eePeerDeviceId = data.senderDeviceId;
191
+ this.server?.emitToMobile('encrypted_key_exchange_ack', {
192
+ ...ack,
193
+ senderDeviceId: config_1.config.deviceId,
194
+ recipientDeviceId: data.senderDeviceId,
195
+ });
196
+ this.emit('e2ee_established', { peerDeviceId: data.senderDeviceId });
197
+ this.flushSensitiveQueue();
198
+ this.sendAllUsageStats();
199
+ }
200
+ catch (err) {
201
+ console.error('[E2EE] Key exchange init failed');
28
202
  }
29
- const deviceId = config_1.config.deviceId;
30
- if (!deviceId) {
31
- reject(new Error('Device not registered'));
203
+ });
204
+ this.server.on('encrypted_key_exchange_ack', (data) => {
205
+ if (!this.e2eeManager)
32
206
  return;
207
+ try {
208
+ this.e2eeManager.handleKeyExchangeAck(data);
209
+ this.e2eePeerDeviceId = data.senderDeviceId;
210
+ this.emit('e2ee_established', { peerDeviceId: data.senderDeviceId });
211
+ this.flushSensitiveQueue();
212
+ this.sendAllUsageStats();
33
213
  }
34
- // Generate unique session ID for this CLI connection
35
- const sessionId = this.sessionId;
36
- const userId = config_1.config.userId; // Pass userId from config for user-based routing
37
- this.socket = (0, socket_io_client_1.io)(config_1.config.wsUrl, {
38
- auth: {
39
- deviceId,
40
- userId, // Include userId so API can track by user even if device not in DB
41
- clientType: 'session-scoped',
42
- sessionId,
43
- cliVersion: require('../package.json').version,
44
- },
45
- transports: ['websocket'],
46
- reconnection: true,
47
- reconnectionAttempts: Infinity,
48
- reconnectionDelay: 1000,
49
- reconnectionDelayMax: 30000,
50
- randomizationFactor: 0.5,
51
- });
52
- // Track whether the initial connect promise has been settled
53
- let settled = false;
54
- // 30-second timeout for initial connection attempt
55
- const connectTimeout = setTimeout(() => {
56
- if (!settled) {
57
- settled = true;
58
- reject(new Error('Initial connection timed out after 30 seconds'));
59
- }
60
- }, 30000);
61
- // Debug logging gated behind DEBUG env var to prevent flooding stdout
214
+ catch (err) {
215
+ console.error('[E2EE] Key exchange ack failed');
216
+ }
217
+ });
218
+ // Encrypted messages — decrypt and re-emit as original event
219
+ this.server.on('encrypted_message', (data) => {
220
+ if (!this.e2eeManager)
221
+ return;
222
+ let plaintext;
223
+ try {
224
+ plaintext = this.e2eeManager.decryptMessage(data, data.senderDeviceId);
225
+ }
226
+ catch {
227
+ console.error('[E2EE] Decryption failed — message dropped');
228
+ return;
229
+ }
230
+ // Validate JSON structure separately from decryption
231
+ let parsed;
232
+ try {
233
+ parsed = JSON.parse(plaintext);
234
+ }
235
+ catch {
236
+ console.error('[E2EE] Invalid JSON in decrypted message — dropped');
237
+ return;
238
+ }
239
+ // Validate payload structure
240
+ if (!parsed || typeof parsed !== 'object') {
241
+ console.error('[E2EE] Decrypted payload is not an object dropped');
242
+ return;
243
+ }
244
+ const payload = parsed;
245
+ const eventName = payload._event;
246
+ if (typeof eventName !== 'string') {
247
+ console.error('[E2EE] Missing or invalid _event in decrypted payload — dropped');
248
+ return;
249
+ }
250
+ if (!ALLOWED_ENCRYPTED_EVENTS.has(eventName)) {
251
+ console.warn('[E2EE] Decrypted event not in whitelist — dropped');
252
+ return;
253
+ }
254
+ this.emit(eventName, payload._data);
255
+ });
256
+ // Initialize E2EE (non-blocking — don't delay server start)
257
+ this.initE2EE().catch((err) => {
258
+ console.warn('[E2EE] \u26a0 End-to-end encryption initialization failed. Messages will be sent without E2EE protection.');
62
259
  if (process.env.DEBUG) {
63
- this.socket.onAny((eventName, ...args) => {
64
- if (eventName === 'device_heartbeat_ack' || eventName === 'pong' || eventName === 'ping')
65
- return;
66
- console.log(`[WS-DEBUG] ${eventName}`, JSON.stringify(args).substring(0, 200));
67
- });
260
+ console.warn(`[E2EE] Init error detail:`, err.message);
261
+ }
262
+ });
263
+ }
264
+ /** Set pairing code on the embedded server for in-process validation */
265
+ setPairingCode(code) {
266
+ this.server?.setPairingCode(code);
267
+ }
268
+ /**
269
+ * Initialize E2EE manager: generate/load keys.
270
+ * Called automatically on startServer. Non-blocking.
271
+ */
272
+ async initE2EE() {
273
+ const deviceId = config_1.config.deviceId;
274
+ if (!deviceId)
275
+ return;
276
+ this.e2eeManager = new e2eeManager_1.E2EEManager(deviceId);
277
+ await this.e2eeManager.initialize();
278
+ this.e2eeInitialized = true;
279
+ // Restore any persisted sessions
280
+ const persisted = this.e2eeManager.listPersistedDevices();
281
+ for (const targetDeviceId of persisted) {
282
+ const restored = await this.e2eeManager.restorePersistedSession(targetDeviceId);
283
+ if (restored) {
284
+ // Restored persisted E2EE session
68
285
  }
69
- this.socket.on('connect', () => {
70
- if (this.reconnectAttempts > 0) {
71
- console.log(`[WS] Reconnected after ${this.reconnectAttempts} attempt(s)`);
72
- this.emit('reconnected', { attempts: this.reconnectAttempts });
73
- this.sendHeartbeat();
286
+ }
287
+ // E2EE initialized
288
+ }
289
+ /**
290
+ * Send a sensitive event: encrypt if E2EE session exists with the target device.
291
+ * For events in ENFORCED_SENSITIVE_EVENTS, plaintext fallback is REFUSED — messages
292
+ * are queued until E2EE session establishes, or dropped after timeout.
293
+ */
294
+ emitSensitive(event, data, targetDeviceId) {
295
+ // If E2EE session exists, encrypt and send
296
+ if (this.e2eeManager && targetDeviceId && this.e2eeManager.hasSessionKey(targetDeviceId)) {
297
+ try {
298
+ const plaintext = JSON.stringify({ _event: event, _data: data });
299
+ const encrypted = this.e2eeManager.encryptMessage(plaintext, targetDeviceId, this._sessionId || 'default');
300
+ this.server?.emitToMobile('encrypted_message', encrypted);
301
+ return;
302
+ }
303
+ catch (err) {
304
+ console.error('[E2EE] Encryption failed, message NOT sent (refusing plaintext fallback)');
305
+ return;
306
+ }
307
+ }
308
+ // For enforced sensitive events: NEVER send plaintext — queue until E2EE establishes
309
+ if (ENFORCED_SENSITIVE_EVENTS.has(event)) {
310
+ if (this.e2eeInitialized) {
311
+ // E2EE initialized but no session yet — queue for when session establishes
312
+ if (this.pendingSensitiveMessages.length >= WebSocketClient.MAX_PENDING_SENSITIVE) {
313
+ const dropped = this.pendingSensitiveMessages.shift();
314
+ if (dropped) {
315
+ console.warn('[E2EE] Sensitive queue full, dropped oldest');
316
+ }
74
317
  }
75
- this.reconnectAttempts = 0;
76
- console.log(`[WS] Connected with deviceId: ${deviceId}, sessionId: ${sessionId}`);
77
- this.emit('connected');
78
- this.startHeartbeat();
79
- if (!settled) {
80
- settled = true;
81
- clearTimeout(connectTimeout);
82
- resolve();
318
+ this.pendingSensitiveMessages.push({
319
+ event,
320
+ data,
321
+ targetDeviceId: targetDeviceId || '__pending__',
322
+ queuedAt: Date.now(),
323
+ });
324
+ if (this.pendingSensitiveMessages.length === 1) {
325
+ console.warn('[E2EE] Queued sensitive event — waiting for E2EE session');
83
326
  }
84
- });
85
- this.socket.on('disconnect', (reason) => {
86
- console.log(`[WS] Disconnected: ${reason}will continue retrying`);
87
- this.emit('disconnected', reason);
88
- this.stopHeartbeat();
89
- });
90
- this.socket.on('connect_error', (error) => {
91
- this.reconnectAttempts++;
92
- this.emit('error', error);
93
- this.emit('reconnecting', { attempt: this.reconnectAttempts });
94
- });
95
- // Listen for terminal create requests from mobile app
96
- this.socket.on('terminal_create', (data) => {
97
- console.log(`[WS] Received terminal_create:`, JSON.stringify(data));
98
- this.emit('terminal_create', data);
99
- });
100
- // Listen for terminal commands from mobile app
101
- this.socket.on('terminal_command', (data) => {
102
- console.log(`[WS] Received terminal_command:`, JSON.stringify(data));
103
- this.emit('terminal_command', data);
104
- });
105
- // Listen for approval responses
106
- this.socket.on('approval_response', (data) => {
107
- this.emit('approval_response', data);
108
- });
109
- // Listen for user messages from mobile app
110
- this.socket.on('user_message', (data) => {
111
- console.log(`[WS] Received user_message: ${data.message.substring(0, 50)}...`);
112
- this.emit('user_message', data);
113
- });
114
- // Listen for Claude session requests from mobile app
115
- this.socket.on('claude_resume_session', (data) => {
116
- console.log(`[WS] Received claude_resume_session:`, JSON.stringify(data));
117
- this.emit('claude_resume_session', data);
118
- });
119
- this.socket.on('claude_start_session', (data) => {
120
- console.log(`[WS] Received claude_start_session:`, JSON.stringify(data));
121
- this.emit('claude_start_session', data);
122
- });
123
- this.socket.on('directory_list', (data) => {
124
- console.log(`[WS] Received directory_list:`, JSON.stringify(data));
125
- this.emit('directory_list', data);
126
- });
127
- // Listen for transcript fetch requests from mobile app
128
- this.socket.on('transcript_fetch', (data) => {
129
- console.log(`[WS] Received transcript_fetch:`, JSON.stringify(data));
130
- this.emit('transcript_fetch', data);
131
- });
132
- // Listen for transcript subscribe requests
133
- this.socket.on('transcript_subscribe', (data) => {
134
- console.log(`[WS] Received transcript_subscribe:`, JSON.stringify(data));
135
- this.emit('transcript_subscribe', data);
136
- });
137
- // Listen for transcript unsubscribe requests
138
- this.socket.on('transcript_unsubscribe', (data) => {
139
- console.log(`[WS] Received transcript_unsubscribe:`, JSON.stringify(data));
140
- this.emit('transcript_unsubscribe', data);
141
- });
142
- // Listen for SDK subscribe start requests from API
143
- // This is sent when mobile uses transcript_subscribe_sdk
144
- this.socket.on('transcript_subscribe_sdk_start', (data) => {
145
- console.log(`[WS] Received transcript_subscribe_sdk_start:`, JSON.stringify(data));
146
- this.emit('transcript_subscribe_sdk_start', data);
147
- });
148
- // Listen for claude sessions request (mobile wants current state)
149
- this.socket.on('claude_sessions_request', (data) => {
150
- console.log(`[WS] Received claude_sessions_request`);
151
- this.emit('claude_sessions_request', data);
152
- });
153
- // Listen for RPC requests from the API gateway
154
- this.socket.on('rpc_request', (data) => {
155
- console.log(`[WS] Received rpc_request: ${data.method}, requestId: ${data.requestId}`);
156
- this.emit('rpc_request', data);
157
- });
158
- // Listen for read file requests from mobile app
159
- this.socket.on('read_file', (data) => {
160
- console.log(`[WS] Received read_file: ${data.filePath}`);
161
- this.emit('read_file', data);
162
- });
163
- // Listen for Claude approval responses from mobile
164
- this.socket.on('claude_approval_response', (data) => {
165
- console.log(`[WS] Received claude_approval_response: ${data.approvalId}, response: ${data.response}`);
166
- this.emit('claude_approval_response', data);
167
- });
168
- // Listen for permission responses from mobile (hook-based approval system)
169
- this.socket.on('permission_response', (data) => {
170
- console.log(`[WS] Received permission_response: ${data.promptId} -> ${data.decision}`);
171
- this.emit('permission_response', data);
172
- });
173
- // Listen for permission rules sync from mobile
174
- this.socket.on('permission_rules_sync', (data) => {
175
- console.log(`[WS] Received permission_rules_sync: ${data.rules?.length || 0} rules`);
176
- this.emit('permission_rules_sync', data);
177
- });
178
- // Listen for mobile disconnect notification from backend
179
- this.socket.on('mobile_disconnected', (data) => {
180
- console.log(`[WS] Received mobile_disconnected for user ${data.userId}`);
181
- this.emit('mobile_disconnected', data);
182
- });
183
- });
327
+ return;
328
+ }
329
+ // E2EE not initialized drop silently (no user data leaks)
330
+ console.error('[E2EE] Dropped sensitive event — E2EE not available, refusing plaintext');
331
+ return;
332
+ }
333
+ // Non-sensitive events: plaintext is acceptable
334
+ this.server?.emitToMobile(event, data);
335
+ }
336
+ /**
337
+ * Flush queued sensitive messages now that E2EE session is established.
338
+ * Drops messages older than SENSITIVE_QUEUE_TTL_MS.
339
+ */
340
+ flushSensitiveQueue() {
341
+ if (this.pendingSensitiveMessages.length === 0)
342
+ return;
343
+ const now = Date.now();
344
+ let sent = 0;
345
+ let dropped = 0;
346
+ for (const msg of this.pendingSensitiveMessages) {
347
+ if (now - msg.queuedAt > WebSocketClient.SENSITIVE_QUEUE_TTL_MS) {
348
+ dropped++;
349
+ continue;
350
+ }
351
+ // Resolve pending target device ID
352
+ const target = msg.targetDeviceId === '__pending__'
353
+ ? this.e2eePeerDeviceId ?? undefined
354
+ : msg.targetDeviceId;
355
+ // Attempt to send via encryption
356
+ this.emitSensitive(msg.event, msg.data, target);
357
+ sent++;
358
+ }
359
+ this.pendingSensitiveMessages = [];
360
+ // Flushed sensitive queue
361
+ }
362
+ /** Get the E2EE manager (for external key exchange initiation) */
363
+ getE2EEManager() {
364
+ return this.e2eeManager;
365
+ }
366
+ /** Check if E2EE session is active with a device */
367
+ isE2EEActive(deviceId) {
368
+ return this.e2eeManager?.hasSessionKey(deviceId) ?? false;
369
+ }
370
+ /** SECURITY: Check if plaintext inbound events should be dropped (E2EE active with peer) */
371
+ shouldDropPlaintextInbound() {
372
+ return !!(this.e2eePeerDeviceId && this.e2eeManager?.hasSessionKey(this.e2eePeerDeviceId));
184
373
  }
185
374
  disconnect() {
186
375
  this.stopHeartbeat();
187
- if (this.socket) {
188
- this.socket.disconnect();
189
- this.socket = null;
376
+ this.e2eeManager?.cleanup(false);
377
+ this.e2eeManager = null;
378
+ this.e2eeInitialized = false;
379
+ this.e2eePeerDeviceId = null;
380
+ if (this.server) {
381
+ this.server.stop();
382
+ this.server = null;
190
383
  }
191
384
  }
192
385
  startHeartbeat() {
@@ -200,124 +393,135 @@ class WebSocketClient extends events_1.EventEmitter {
200
393
  this.heartbeatInterval = null;
201
394
  }
202
395
  }
203
- // Send heartbeat to keep connection alive
396
+ // Send heartbeat to connected mobile
204
397
  sendHeartbeat(status = 'online') {
205
- this.socket?.emit('device_heartbeat', { status });
398
+ this.server?.emitToMobile('device_status', { status, deviceId: config_1.config.deviceId });
206
399
  }
207
400
  // Update device status
208
401
  updateStatus(status) {
209
- this.socket?.emit('device_heartbeat', { status });
210
- }
211
- // Send syncing status
212
- setSyncing(syncing) {
213
- this.socket?.emit('device_syncing', { syncing });
402
+ this.server?.emitToMobile('device_status', { status, deviceId: config_1.config.deviceId });
214
403
  }
215
- // Send Claude session update
404
+ // Send Claude session update (sensitive — contains directory paths)
216
405
  sendClaudeSessionUpdate(session) {
217
- this.socket?.emit('claude_session_update', session);
406
+ this.emitSensitive('claude_session_update', { ...session, deviceId: config_1.config.deviceId }, this.e2eePeerDeviceId ?? undefined);
218
407
  }
219
- // Send multiple Claude sessions as a single batch event
408
+ // Send multiple Claude sessions as a single batch event (sensitive — contains directory paths)
220
409
  sendClaudeSessions(sessions) {
221
410
  if (sessions.length > 0) {
222
- this.socket?.emit('claude_session_batch_update', { sessions });
411
+ const withDeviceId = sessions.map(s => ({ ...s, deviceId: config_1.config.deviceId }));
412
+ this.emitSensitive('claude_session_batch_update', { sessions: withDeviceId }, this.e2eePeerDeviceId ?? undefined);
223
413
  }
224
414
  }
225
415
  // Send tool status update (e.g., Claude active/inactive)
226
416
  sendToolStatusUpdate(toolType, status) {
227
- this.socket?.emit('tool_status_update', {
417
+ this.server?.emitToMobile('tool_status_update', {
228
418
  toolType,
229
419
  status,
230
420
  timestamp: new Date().toISOString(),
231
421
  });
232
422
  }
233
- // Send approval request
423
+ // Send approval request (sensitive — contains code change descriptions)
234
424
  sendApprovalRequest(data) {
235
- this.socket?.emit('approval_request', data);
425
+ this.emitSensitive('approval_request', data, this.e2eePeerDeviceId ?? undefined);
236
426
  }
237
- // Send terminal output
427
+ // Send terminal output (sensitive — encrypted if E2EE active)
238
428
  sendTerminalOutput(data) {
239
- this.socket?.emit('terminal_output', data);
429
+ this.emitSensitive('terminal_output', data, this.e2eePeerDeviceId ?? undefined);
240
430
  }
241
- // Send working directory change
431
+ // Send working directory change (sensitive — contains working directory path)
242
432
  sendTerminalCwd(data) {
243
- this.socket?.emit('terminal_cwd', data);
433
+ this.emitSensitive('terminal_cwd', data, this.e2eePeerDeviceId ?? undefined);
244
434
  }
245
- // Send directory listing response
435
+ // Send directory listing response (sensitive — encrypted if E2EE active)
246
436
  sendDirectoryListResponse(data) {
247
- this.socket?.emit('directory_list_response', data);
437
+ this.emitSensitive('directory_list_response', data, this.e2eePeerDeviceId ?? undefined);
248
438
  }
249
- // Send read file response
439
+ // Send read file response (sensitive — encrypted if E2EE active)
250
440
  sendReadFileResponse(data) {
251
- this.socket?.emit('read_file_response', data);
441
+ this.emitSensitive('read_file_response', data, this.e2eePeerDeviceId ?? undefined);
252
442
  }
253
- // Send file change notification
443
+ // Send file change notification (sensitive — contains file paths)
254
444
  sendFileChanged(data) {
255
- this.socket?.emit('file_changed', data);
445
+ this.emitSensitive('file_changed', data, this.e2eePeerDeviceId ?? undefined);
256
446
  }
257
- // Send transcript history to mobile app
447
+ // Send transcript history to mobile app (sensitive — contains code and conversation)
258
448
  sendTranscriptHistory(data) {
259
- this.socket?.emit('transcript_history', data);
449
+ this.emitSensitive('transcript_history', data, this.e2eePeerDeviceId ?? undefined);
260
450
  }
261
- // Send transcript update (new entry) to mobile app
451
+ // Send transcript update (new entry) to mobile app (sensitive — contains code)
262
452
  sendTranscriptUpdate(data) {
263
- this.socket?.emit('transcript_update', data);
453
+ this.emitSensitive('transcript_update', data, this.e2eePeerDeviceId ?? undefined);
264
454
  }
265
- // Send RPC response back to API gateway
266
- sendRpcResponse(data) {
267
- console.log(`[WS] Sending rpc_response: ${data.requestId}, hasResult: ${!!data.result}, hasError: ${!!data.error}`);
268
- this.socket?.emit('rpc_response', data);
269
- }
270
- // Send Claude approval request to mobile
455
+ // Send Claude approval request to mobile (sensitive — contains context)
271
456
  sendClaudeApprovalRequest(data) {
272
457
  console.log(`[WS] Sending claude_approval_request: ${data.approvalId}`);
273
- this.socket?.emit('claude_approval_request', data);
458
+ this.emitSensitive('claude_approval_request', data, this.e2eePeerDeviceId ?? undefined);
274
459
  }
275
- // Send tool activity notification to mobile (non-blocking)
460
+ // Send tool activity notification to mobile (sensitive — contains tool inputs)
276
461
  sendToolActivity(data) {
277
462
  console.log(`[WS] Sending tool_activity: ${data.toolName} (${data.toolId})`);
278
- this.socket?.emit('tool_activity', data);
463
+ this.emitSensitive('tool_activity', data, this.e2eePeerDeviceId ?? undefined);
279
464
  }
280
- // Send permission prompt to mobile (interactive approval from hook system)
465
+ // Send permission prompt to mobile (sensitive encrypted if E2EE active)
281
466
  sendPermissionPrompt(data) {
282
467
  console.log(`[WS] Sending permission_prompt: ${data.toolName} (${data.promptId})`);
283
- this.socket?.emit('permission_prompt', data);
468
+ this.emitSensitive('permission_prompt', data, this.e2eePeerDeviceId ?? undefined);
284
469
  }
285
- // Send thinking content to mobile
470
+ // Send thinking content to mobile (sensitive — contains Claude's reasoning about code)
286
471
  sendThinkingContent(data) {
287
- this.socket?.emit('thinking_content', data);
472
+ this.emitSensitive('thinking_content', data, this.e2eePeerDeviceId ?? undefined);
288
473
  }
289
- // Send token usage to mobile
474
+ // Send token usage to mobile (sensitive — contains session identifiers)
290
475
  sendTokenUsage(data) {
291
- this.socket?.emit('token_usage', data);
476
+ this.emitSensitive('token_usage', data, this.e2eePeerDeviceId ?? undefined);
292
477
  }
293
- // Send pending permissions sync to mobile (on take-over)
478
+ // Send pending permissions sync to mobile (sensitive — contains prompt details)
294
479
  sendPendingPermissionsSync(data) {
295
480
  console.log(`[WS] Sending pending_permissions_sync: ${data.prompts.length} prompt(s)`);
296
- this.socket?.emit('pending_permissions_sync', data);
481
+ this.emitSensitive('pending_permissions_sync', data, this.e2eePeerDeviceId ?? undefined);
297
482
  }
298
- // Send task progress to mobile
483
+ // Send task progress to mobile (sensitive — contains task subjects/descriptions)
299
484
  sendTaskProgress(data) {
300
- this.socket?.emit('task_progress', data);
485
+ this.emitSensitive('task_progress', data, this.e2eePeerDeviceId ?? undefined);
301
486
  }
302
- // E2EE event emitters
487
+ // E2EE event emitters — send to mobile via server
303
488
  emitKeyExchangeInit(data) {
304
- this.socket?.emit('encrypted_key_exchange_init', data);
489
+ this.server?.emitToMobile('encrypted_key_exchange_init', data);
305
490
  }
306
491
  emitKeyExchangeAck(data) {
307
- this.socket?.emit('encrypted_key_exchange_ack', data);
492
+ this.server?.emitToMobile('encrypted_key_exchange_ack', data);
308
493
  }
309
494
  emitEncryptedMessage(data) {
310
- this.socket?.emit('encrypted_message', data);
311
- }
312
- // Send Claude session event
495
+ this.server?.emitToMobile('encrypted_message', data);
496
+ }
497
+ /** Set the usage tracker instance (called from index.ts after instantiation) */
498
+ setUsageTracker(tracker) {
499
+ this.usageTracker = tracker;
500
+ }
501
+ /** Send all usage stats to mobile (called after E2EE established and on request) */
502
+ sendAllUsageStats() {
503
+ if (!this.usageTracker)
504
+ return;
505
+ const deviceId = config_1.config.deviceId;
506
+ const stats = this.usageTracker.getUsageStats('all');
507
+ const daily = this.usageTracker.getDailyUsage();
508
+ const streak = this.usageTracker.getStreakInfo();
509
+ this.emitSensitive('usage_stats_sync', { ...stats, deviceId }, this.e2eePeerDeviceId ?? undefined);
510
+ this.emitSensitive('daily_usage_sync', { daily, deviceId }, this.e2eePeerDeviceId ?? undefined);
511
+ this.emitSensitive('streak_info_sync', { ...streak, deviceId }, this.e2eePeerDeviceId ?? undefined);
512
+ console.log(`[WS] Sent usage stats sync to mobile`);
513
+ }
514
+ // Send Claude session event (sensitive — may contain error messages with paths)
313
515
  sendClaudeSessionEvent(data) {
314
- this.socket?.emit('claude_session_event', data);
516
+ this.emitSensitive('claude_session_event', data, this.e2eePeerDeviceId ?? undefined);
315
517
  }
316
518
  get isConnected() {
317
- return this.socket?.connected ?? false;
519
+ return this.server?.hasMobileConnection() ?? false;
318
520
  }
319
521
  }
320
522
  exports.WebSocketClient = WebSocketClient;
523
+ WebSocketClient.SENSITIVE_QUEUE_TTL_MS = 30000; // 30 seconds max wait
524
+ WebSocketClient.MAX_PENDING_SENSITIVE = 200;
321
525
  exports.wsClient = new WebSocketClient();
322
526
  exports.default = exports.wsClient;
323
527
  //# sourceMappingURL=websocket.js.map