claude-code-session-manager 0.35.17 → 0.35.18

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/index.html CHANGED
@@ -7,7 +7,7 @@
7
7
  <link rel="preconnect" href="https://fonts.googleapis.com">
8
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
9
  <link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,400&family=Geist:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
10
- <script type="module" crossorigin src="./assets/index-DuoC6oCy.js"></script>
10
+ <script type="module" crossorigin src="./assets/index-4dJkn9nT.js"></script>
11
11
  <link rel="modulepreload" crossorigin href="./assets/monaco-editor-BW5C4Iv1.js">
12
12
  <link rel="stylesheet" crossorigin href="./assets/monaco-editor-BTnBOi8r.css">
13
13
  <link rel="stylesheet" crossorigin href="./assets/index-DX2w2YhJ.css">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-session-manager",
3
- "version": "0.35.17",
3
+ "version": "0.35.18",
4
4
  "description": "Local cockpit for the Claude Code CLI — multi-tab terminal, full config surface, scheduler, voice dictation, and live observability.",
5
5
  "type": "module",
6
6
  "main": "src/main/index.cjs",
@@ -0,0 +1,46 @@
1
+ /**
2
+ * pty-write-result.test.cjs — unit tests for PtyManager.write()'s return contract.
3
+ *
4
+ * Run: timeout 300 npx vitest run src/main/__tests__/pty-write-result.test.cjs
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ import { test, expect } from 'vitest';
10
+ const { manager } = require('../pty.cjs');
11
+
12
+ test('write to an existing session succeeds', () => {
13
+ const tabId = 'tab-existing';
14
+ const proc = { write: () => {} };
15
+ manager.sessions.set(tabId, { proc, cwd: '/tmp', created: 0 });
16
+ try {
17
+ const result = manager.write({ tabId, data: 'hello' });
18
+ expect(result).toEqual({ ok: true });
19
+ } finally {
20
+ manager.sessions.delete(tabId);
21
+ }
22
+ });
23
+
24
+ test('write to a nonexistent tabId returns no-pty', () => {
25
+ const result = manager.write({ tabId: 'tab-does-not-exist', data: 'hello' });
26
+ expect(result).toEqual({ ok: false, reason: 'no-pty' });
27
+ });
28
+
29
+ test('write where proc.write throws returns the error reason without throwing', () => {
30
+ const tabId = 'tab-throws';
31
+ const proc = {
32
+ write: () => {
33
+ throw new Error('EPIPE: write after end');
34
+ },
35
+ };
36
+ manager.sessions.set(tabId, { proc, cwd: '/tmp', created: 0 });
37
+ try {
38
+ let result;
39
+ expect(() => {
40
+ result = manager.write({ tabId, data: 'hello' });
41
+ }).not.toThrow();
42
+ expect(result).toEqual({ ok: false, reason: 'EPIPE: write after end' });
43
+ } finally {
44
+ manager.sessions.delete(tabId);
45
+ }
46
+ });
@@ -0,0 +1,181 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * web-remote-e2e-pinning.test.cjs — TOFU pinning for the web-remote E2E handshake.
5
+ *
6
+ * Once a browser's SPKI pubkey is manually SAS-confirmed for a paired device,
7
+ * a later `e2e:hello` presenting the SAME pubkey for that device should
8
+ * auto-authenticate (no `confirm-sas` needed); a DIFFERENT pubkey must still
9
+ * fall through to the manual `pending_sas` flow.
10
+ *
11
+ * Runs against a tmp HOME (config.cjs / webRemote.cjs both resolve paths off
12
+ * os.homedir()) and stubs the `electron` module in require.cache, mirroring
13
+ * the tmp-HOME + require.cache-stub pattern used by exchanges.test.cjs.
14
+ *
15
+ * Run: timeout 300 npx vitest run src/main/__tests__/web-remote-e2e-pinning.test.cjs
16
+ */
17
+
18
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest'
19
+
20
+ const fs = require('node:fs')
21
+ const fsp = require('node:fs/promises')
22
+ const path = require('node:path')
23
+ const os = require('node:os')
24
+ const crypto = require('node:crypto')
25
+
26
+ /** Generate a P-256 keypair in the same encoding webRemote.cjs uses (SPKI/PKCS8, base64url). */
27
+ function genP256KeyPairB64() {
28
+ const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', {
29
+ namedCurve: 'P-256',
30
+ publicKeyEncoding: { type: 'spki', format: 'der' },
31
+ privateKeyEncoding: { type: 'pkcs8', format: 'der' },
32
+ })
33
+ return {
34
+ priv: privateKey.toString('base64url'),
35
+ pub: publicKey.toString('base64url'),
36
+ }
37
+ }
38
+
39
+ let tmpHome
40
+ let origHome
41
+ let webRemote
42
+ let configPath
43
+ let handlers // captured ipcMain.handle(name, fn) map
44
+
45
+ function freshRequire(mod) {
46
+ delete require.cache[require.resolve(mod)]
47
+ return require(mod)
48
+ }
49
+
50
+ async function readConfig() {
51
+ const text = await fsp.readFile(configPath, 'utf8')
52
+ return JSON.parse(text)
53
+ }
54
+
55
+ async function writeConfig(cfg) {
56
+ await fsp.mkdir(path.dirname(configPath), { recursive: true })
57
+ await fsp.writeFile(configPath, JSON.stringify(cfg, null, 2) + '\n')
58
+ }
59
+
60
+ beforeAll(async () => {
61
+ tmpHome = await fsp.mkdtemp(path.join(os.tmpdir(), 'web-remote-e2e-pinning-'))
62
+ origHome = process.env.HOME
63
+ process.env.HOME = tmpHome
64
+
65
+ // Stub 'electron' before webRemote.cjs (and logs.cjs, transitively) require it —
66
+ // outside a real Electron process, require('electron') just resolves to a path
67
+ // string, so destructuring { ipcMain, app } off it would throw at module load.
68
+ handlers = new Map()
69
+ const electronPath = require.resolve('electron')
70
+ require.cache[electronPath] = {
71
+ id: electronPath,
72
+ filename: electronPath,
73
+ loaded: true,
74
+ exports: {
75
+ ipcMain: {
76
+ handle: (name, fn) => handlers.set(name, fn),
77
+ on: () => {},
78
+ },
79
+ app: {
80
+ getPath: () => tmpHome,
81
+ getVersion: () => '0.0.0-test',
82
+ },
83
+ },
84
+ }
85
+
86
+ // Clear anything already cached under the real HOME so paths re-resolve under tmpHome.
87
+ for (const mod of ['../webRemote.cjs', '../config.cjs', '../logs.cjs', './e2eStateMachine.cjs']) {
88
+ try { delete require.cache[require.resolve(mod)] } catch { /* not yet loaded */ }
89
+ }
90
+
91
+ webRemote = freshRequire('../webRemote.cjs')
92
+ configPath = path.join(tmpHome, '.claude', 'session-manager', 'web-remote.json')
93
+
94
+ webRemote.registerRemoteHandlers()
95
+ })
96
+
97
+ afterAll(async () => {
98
+ process.env.HOME = origHome
99
+ await fsp.rm(tmpHome, { recursive: true, force: true })
100
+ })
101
+
102
+ describe('web-remote E2E SAS pinning', () => {
103
+ const deviceKeys = genP256KeyPairB64() // desktop's own E2E keypair
104
+ const browserA = genP256KeyPairB64() // first browser's keypair
105
+ const browserB = genP256KeyPairB64() // a different browser's keypair
106
+ const deviceId = 'device-under-test'
107
+
108
+ function baseDeviceRow(overrides = {}) {
109
+ return {
110
+ deviceId,
111
+ deviceToken: 'tok-abc',
112
+ e2ePrivateKey: deviceKeys.priv,
113
+ e2ePublicKey: deviceKeys.pub,
114
+ deviceName: 'Test Device',
115
+ issuedAt: new Date(0).toISOString(),
116
+ lastConnectedAt: null,
117
+ verifiedPeerPubKey: null,
118
+ ...overrides,
119
+ }
120
+ }
121
+
122
+ it('1) first e2e:hello for a never-confirmed device lands in pending_sas', async () => {
123
+ await writeConfig({ remoteEnabled: true, remoteControlEnabled: false, devices: [baseDeviceRow()] })
124
+
125
+ const cfg = await readConfig()
126
+ const device = cfg.devices[0]
127
+ await webRemote._internal.handleMessage(
128
+ JSON.stringify({ type: 'e2e:hello', id: 'm1', payload: { pubKey: browserA.pub } }),
129
+ device
130
+ )
131
+
132
+ expect(webRemote._internal.getE2eState().state).toBe('pending_sas')
133
+ })
134
+
135
+ it('2) a manual confirm-sas persists the connected device\'s verifiedPeerPubKey', async () => {
136
+ const confirmSas = handlers.get('webRemote:confirm-sas')
137
+ expect(typeof confirmSas).toBe('function')
138
+
139
+ const result = await confirmSas()
140
+ expect(result.ok).toBe(true)
141
+ expect(webRemote._internal.getE2eState().state).toBe('authenticated')
142
+
143
+ const cfg = await readConfig()
144
+ const persisted = cfg.devices.find((d) => d.deviceId === deviceId)
145
+ expect(persisted.verifiedPeerPubKey).toBe(browserA.pub)
146
+ })
147
+
148
+ it('3) a second e2e:hello presenting the SAME browserPubKey auto-authenticates (no confirm-sas)', async () => {
149
+ // Simulate a fresh reconnect: new WS session resets E2E state, and the device
150
+ // row is reloaded from disk (as connect() would), now carrying the pinned key.
151
+ webRemote._internal.resetE2e()
152
+ expect(webRemote._internal.getE2eState().state).toBe('idle')
153
+
154
+ const cfg = await readConfig()
155
+ const device = cfg.devices.find((d) => d.deviceId === deviceId)
156
+ expect(device.verifiedPeerPubKey).toBe(browserA.pub)
157
+
158
+ await webRemote._internal.handleMessage(
159
+ JSON.stringify({ type: 'e2e:hello', id: 'm2', payload: { pubKey: browserA.pub } }),
160
+ device
161
+ )
162
+
163
+ expect(webRemote._internal.getE2eState().state).toBe('authenticated')
164
+ })
165
+
166
+ it('4) a e2e:hello presenting a DIFFERENT browserPubKey still lands in pending_sas', async () => {
167
+ webRemote._internal.resetE2e()
168
+ expect(webRemote._internal.getE2eState().state).toBe('idle')
169
+
170
+ const cfg = await readConfig()
171
+ const device = cfg.devices.find((d) => d.deviceId === deviceId)
172
+ expect(device.verifiedPeerPubKey).toBe(browserA.pub) // still pinned to A, not B
173
+
174
+ await webRemote._internal.handleMessage(
175
+ JSON.stringify({ type: 'e2e:hello', id: 'm3', payload: { pubKey: browserB.pub } }),
176
+ device
177
+ )
178
+
179
+ expect(webRemote._internal.getE2eState().state).toBe('pending_sas')
180
+ })
181
+ })
package/src/main/pty.cjs CHANGED
@@ -188,19 +188,19 @@ class PtyManager {
188
188
  // Tab was removed or never existed — tell the renderer so it can surface
189
189
  // "skipped" feedback rather than silently dropping the write.
190
190
  sendIfAlive(this.window, 'pty:write-error', { tabId, reason: 'no-pty' });
191
- return;
191
+ return { ok: false, reason: 'no-pty' };
192
192
  }
193
193
  try {
194
194
  s.proc.write(data);
195
+ return { ok: true };
195
196
  } catch (err) {
196
197
  // node-pty throws synchronously (or the underlying net.Socket emits an
197
198
  // error that node-pty re-throws) when writing to an exited process.
198
199
  // Catch here so the uncaught-exception handler never sees it, and notify
199
200
  // the renderer to surface "skipped" feedback.
200
- sendIfAlive(this.window, 'pty:write-error', {
201
- tabId,
202
- reason: String(err?.message || 'write-failed'),
203
- });
201
+ const reason = String(err?.message || 'write-failed');
202
+ sendIfAlive(this.window, 'pty:write-error', { tabId, reason });
203
+ return { ok: false, reason };
204
204
  }
205
205
  }
206
206
 
@@ -189,6 +189,11 @@ let _destroyed = false; // set at app shutdown to stop reconnect loops
189
189
  // E2E session state — reset on each new WS connection.
190
190
  // .state: 'idle' | 'pending_sas' | 'authenticated' | 'failed'
191
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;
192
197
 
193
198
  // ─── Config helpers ───────────────────────────────────────────────────────────
194
199
 
@@ -317,6 +322,8 @@ async function getDeviceTicket(deviceToken) {
317
322
 
318
323
  function resetE2e(state = 'idle') {
319
324
  _e2e = makeState(state);
325
+ _e2ePeerPubKey = null;
326
+ _e2eDeviceId = null;
320
327
  }
321
328
 
322
329
  // ─── WebSocket lifecycle ──────────────────────────────────────────────────────
@@ -781,6 +788,19 @@ async function maybeSummarize(w) {
781
788
  });
782
789
  }
783
790
 
791
+ /**
792
+ * Shared finish-up for any path that transitions _e2e into 'authenticated'
793
+ * (manual SAS confirmation or pinned-key auto-trust): log, broadcast the new
794
+ * status, and flush the session list immediately rather than waiting for the
795
+ * next SESSION_LIST_PUSH_MS tick.
796
+ */
797
+ function finalizeE2eAuthenticated(logMessage) {
798
+ logs.writeLine({ scope: 'webRemote', level: 'info', message: logMessage });
799
+ broadcastStatus();
800
+ _lastSessionListJson = null;
801
+ pushSessionList().catch(() => {});
802
+ }
803
+
784
804
  // ─── Message handling & command dispatch ─────────────────────────────────────
785
805
 
786
806
  async function handleMessage(raw, device) {
@@ -883,6 +903,18 @@ async function handleMessage(raw, device) {
883
903
  broadcastStatus();
884
904
  return;
885
905
  }
906
+ _e2ePeerPubKey = browserPubKey;
907
+ _e2eDeviceId = device.deviceId;
908
+ // TOFU pinning: if this exact (deviceId, browserPubKey) pair was already
909
+ // manually SAS-confirmed once, skip pending_sas and auto-authenticate — the
910
+ // user already verified this browser. A different/first-seen pubKey always
911
+ // falls through to the manual pending_sas flow below, unchanged.
912
+ if (device.verifiedPeerPubKey && device.verifiedPeerPubKey === browserPubKey) {
913
+ _e2e = makeState('authenticated', sessionKey, null);
914
+ finalizeE2eAuthenticated('E2E session auto-authenticated — pinned browser key matched');
915
+ respond(id, undefined, 'e2e:ready');
916
+ return;
917
+ }
886
918
  _e2e = makeState('pending_sas', sessionKey, pendingSas);
887
919
  logs.writeLine({ scope: 'webRemote', level: 'info', message: 'E2E session key established — SAS pending confirmation' });
888
920
  broadcastStatus();
@@ -1047,8 +1079,7 @@ function getDispatchMap() {
1047
1079
 
1048
1080
  'cmd:pty:write': async (payload) => {
1049
1081
  const parsed = schemas.ptyWrite.parse(payload);
1050
- ptyManager.write(parsed);
1051
- return { ok: true };
1082
+ return ptyManager.write(parsed);
1052
1083
  },
1053
1084
 
1054
1085
  'cmd:pty:resize': async (payload) => {
@@ -1167,6 +1198,9 @@ async function pair(otp) {
1167
1198
  deviceName: `Device (paired ${new Date().toISOString().slice(0, 10)})`,
1168
1199
  issuedAt: new Date().toISOString(),
1169
1200
  lastConnectedAt: null,
1201
+ // Set once a browser's SAS is manually confirmed; TOFU-pinned for auto-trust
1202
+ // on future reconnects from the same (deviceId, browserPubKey) pair.
1203
+ verifiedPeerPubKey: null,
1170
1204
  }];
1171
1205
 
1172
1206
  await saveConfig({ ...cfg, devices });
@@ -1234,13 +1268,23 @@ function registerRemoteHandlers() {
1234
1268
  return { ok: false, error };
1235
1269
  }
1236
1270
  _e2e = next;
1237
- logs.writeLine({ scope: 'webRemote', level: 'info', message: 'E2E session authenticated SAS confirmed by user' });
1238
- broadcastStatus();
1271
+ // Pin this browser's pubkey to the device so future reconnects with the same
1272
+ // key auto-authenticate (see e2e:hello above) instead of re-prompting forever.
1273
+ if (_e2ePeerPubKey && _e2eDeviceId) {
1274
+ try {
1275
+ const cfg = await loadConfig();
1276
+ const devices = (cfg.devices || []).map((d) =>
1277
+ d.deviceId === _e2eDeviceId ? { ...d, verifiedPeerPubKey: _e2ePeerPubKey } : d
1278
+ );
1279
+ await saveConfig({ ...cfg, devices });
1280
+ } catch (e) {
1281
+ logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'failed to persist pinned peer pubkey', meta: { error: e?.message } });
1282
+ }
1283
+ }
1239
1284
  // Flush the session list immediately — the push loop was suppressed while
1240
1285
  // state !== 'authenticated', so the mobile app would otherwise wait up to
1241
1286
  // SESSION_LIST_PUSH_MS for the first useful data.
1242
- _lastSessionListJson = null;
1243
- pushSessionList().catch(() => {});
1287
+ finalizeE2eAuthenticated('E2E session authenticated — SAS confirmed by user');
1244
1288
  return { ok: true };
1245
1289
  });
1246
1290
 
@@ -1334,4 +1378,11 @@ module.exports = {
1334
1378
  registerRemoteHandlers,
1335
1379
  init,
1336
1380
  destroy,
1381
+ // Test-only internals — not part of the IPC surface. Lets unit tests drive
1382
+ // e2e:hello / SAS-pinning transitions without a real relay WS connection.
1383
+ _internal: {
1384
+ handleMessage,
1385
+ resetE2e,
1386
+ getE2eState: () => _e2e,
1387
+ },
1337
1388
  };