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.
Files changed (68) hide show
  1. package/dist/assets/AgentLibrary-BeJJa_zv.js +1 -0
  2. package/dist/assets/History-mWbqemhZ.js +2 -0
  3. package/dist/assets/Hooks-D_8sciZu.js +3 -0
  4. package/dist/assets/HostBilko-6aUJ7Smz.js +1 -0
  5. package/dist/assets/Library-CG1LDXLw.js +46 -0
  6. package/dist/assets/ListDetail-CIIJOWwI.js +1 -0
  7. package/dist/assets/MarkdownEditor-D0-y8V-f.js +1 -0
  8. package/dist/assets/McpServers-Kc-djiVi.js +2 -0
  9. package/dist/assets/Memory-B60DGRTW.js +8 -0
  10. package/dist/assets/Panel-CbTPsYHq.js +1 -0
  11. package/dist/assets/Permissions-C8KrSfrd.js +3 -0
  12. package/dist/assets/Plugins-BT8EumHb.js +2 -0
  13. package/dist/assets/ProvenanceBadge-cEqPpFsT.js +1 -0
  14. package/dist/assets/Scheduler-DbKPd0or.js +14 -0
  15. package/dist/assets/ScopeSwitcher-BNesCJ_t.js +1 -0
  16. package/dist/assets/Settings-Bjt7AQ6G.js +3 -0
  17. package/dist/assets/SkillReferenceGraph-DzuaPDM-.js +46 -0
  18. package/dist/assets/Skills-CVL_4X3T.js +3 -0
  19. package/dist/assets/SystemPrompt-Ct__nFF1.js +1 -0
  20. package/dist/assets/TagLibrary-DmrTfw4D.js +1 -0
  21. package/dist/assets/{TiptapBody-Bn-LMce4.js → TiptapBody-DaS0M_Ni.js} +1 -1
  22. package/dist/assets/Toggle-C0a-6xV7.js +1 -0
  23. package/dist/assets/index-CO_7DroC.js +3066 -0
  24. package/dist/assets/{index-DOXvph-Z.css → index-LlWpj2VJ.css} +1 -1
  25. package/dist/assets/listSkills-QORduPIk.js +1 -0
  26. package/dist/assets/settingsSchema-B7dMJaix.js +3 -0
  27. package/dist/assets/skillFrontmatter-Dif5JIg7.js +10 -0
  28. package/dist/index.html +2 -2
  29. package/package.json +1 -3
  30. package/src/main/__tests__/config-readText-bounded.test.cjs +84 -0
  31. package/src/main/__tests__/heapSnapshot.test.cjs +121 -0
  32. package/src/main/__tests__/historyAggregatorIntraday.test.cjs +313 -0
  33. package/src/main/__tests__/runLogRetention.test.cjs +343 -0
  34. package/src/main/__tests__/runVerify-transcript-commit-evidence.test.cjs +215 -0
  35. package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +42 -1
  36. package/src/main/__tests__/scheduler-prd-missing-skip.test.cjs +54 -1
  37. package/src/main/__tests__/transcripts-batch-flush.test.cjs +249 -0
  38. package/src/main/__tests__/transcripts-doFlush-array.test.cjs +6 -0
  39. package/src/main/__tests__/transcripts-paged-reads.test.cjs +6 -0
  40. package/src/main/__tests__/transcriptsUsageFor.test.cjs +6 -0
  41. package/src/main/config.cjs +32 -29
  42. package/src/main/files.cjs +20 -0
  43. package/src/main/health.cjs +32 -0
  44. package/src/main/heapSnapshot.cjs +122 -0
  45. package/src/main/historyAggregator.cjs +176 -36
  46. package/src/main/index.cjs +10 -63
  47. package/src/main/ipcSchemas.cjs +20 -209
  48. package/src/main/lib/opsOwnership.cjs +0 -2
  49. package/src/main/lib/runLogRetention.cjs +358 -0
  50. package/src/main/lib/summarize.cjs +0 -1
  51. package/src/main/runVerify.cjs +55 -6
  52. package/src/main/scheduler.cjs +46 -7
  53. package/src/main/transcripts.cjs +29 -5
  54. package/src/preload/api.d.ts +18 -199
  55. package/src/preload/index.cjs +12 -102
  56. package/dist/assets/index-BfiMRkln.js +0 -3225
  57. package/src/main/__tests__/browserAgentServer.test.cjs +0 -235
  58. package/src/main/__tests__/browserView-destroyed-handler.test.cjs +0 -85
  59. package/src/main/__tests__/browserView-oauth-popup.test.cjs +0 -126
  60. package/src/main/__tests__/web-remote-e2e-pinning.test.cjs +0 -181
  61. package/src/main/browserAgentServer.cjs +0 -224
  62. package/src/main/browserCapture.cjs +0 -728
  63. package/src/main/browserView.cjs +0 -916
  64. package/src/main/lib/__tests__/browserAgentActions.test.cjs +0 -76
  65. package/src/main/lib/browserAgentActions.cjs +0 -114
  66. package/src/main/lib/e2eStateMachine.cjs +0 -39
  67. package/src/main/webRemote.cjs +0 -1367
  68. package/src/preload/browserViewPreload.cjs +0 -118
@@ -1,235 +0,0 @@
1
- /**
2
- * browserAgentServer.test.cjs — unit tests for the loopback-only browser
3
- * agent HTTP API (PRD 535).
4
- *
5
- * Run: timeout 120 node --test src/main/__tests__/browserAgentServer.test.cjs
6
- */
7
-
8
- 'use strict';
9
-
10
- const { test } = require('node:test');
11
- const assert = require('node:assert/strict');
12
- const http = require('node:http');
13
- const fs = require('node:fs');
14
- const path = require('node:path');
15
- const { createBrowserAgentServer, TOKEN_PATH, resolveTokenPath } = require('../browserAgentServer.cjs');
16
-
17
- function request(port, { method = 'GET', path: reqPath, token, body }) {
18
- return new Promise((resolve, reject) => {
19
- const headers = {};
20
- if (token !== undefined) headers.Authorization = `Bearer ${token}`;
21
- let payload;
22
- if (body !== undefined) {
23
- payload = JSON.stringify(body);
24
- headers['Content-Type'] = 'application/json';
25
- headers['Content-Length'] = Buffer.byteLength(payload);
26
- }
27
- const req = http.request({ hostname: '127.0.0.1', port, method, path: reqPath, headers }, (res) => {
28
- const chunks = [];
29
- res.on('data', (c) => chunks.push(c));
30
- res.on('end', () => {
31
- const text = Buffer.concat(chunks).toString('utf8');
32
- let json = null;
33
- try { json = JSON.parse(text); } catch { /* leave null for non-JSON bodies */ }
34
- resolve({ status: res.statusCode, json, text });
35
- });
36
- });
37
- req.on('error', reject);
38
- if (payload) req.write(payload);
39
- req.end();
40
- });
41
- }
42
-
43
- function makeFakeRemote() {
44
- const actions = [];
45
- return {
46
- actions,
47
- async listTabs() {
48
- return [{ viewId: 'v1', url: 'https://example.com', title: 'Example', active: true }];
49
- },
50
- async screenshot({ viewId }) {
51
- if (!viewId && viewId !== undefined) return { ok: false, error: 'unknown viewId' };
52
- return { ok: true, viewId: viewId || 'v1', dataUrl: 'data:image/png;base64,AAA', scale: 1 };
53
- },
54
- async action(params) {
55
- actions.push(params);
56
- if (params.action === 'boom') return { ok: false, error: 'unsupported action: boom' };
57
- return { ok: true };
58
- },
59
- async dom({ viewId }) {
60
- return { ok: true, viewId: viewId || 'v1', text: 'hello', elements: [] };
61
- },
62
- };
63
- }
64
-
65
- test('rejects requests without a bearer token', async () => {
66
- const remote = makeFakeRemote();
67
- const server = createBrowserAgentServer(remote);
68
- const { port } = await server.start();
69
- try {
70
- const res = await request(port, { path: '/agent/browser/tabs' });
71
- assert.equal(res.status, 401);
72
- assert.equal(res.json.ok, false);
73
- } finally {
74
- await server.stop();
75
- }
76
- });
77
-
78
- test('rejects requests with a wrong token', async () => {
79
- const remote = makeFakeRemote();
80
- const server = createBrowserAgentServer(remote);
81
- const { port } = await server.start();
82
- try {
83
- const res = await request(port, { path: '/agent/browser/tabs', token: 'wrong-token' });
84
- assert.equal(res.status, 401);
85
- } finally {
86
- await server.stop();
87
- }
88
- });
89
-
90
- test('GET /agent/browser/tabs lists open tabs', async () => {
91
- const remote = makeFakeRemote();
92
- const server = createBrowserAgentServer(remote);
93
- const { port, token } = await server.start();
94
- try {
95
- const res = await request(port, { path: '/agent/browser/tabs', token });
96
- assert.equal(res.status, 200);
97
- assert.equal(res.json.ok, true);
98
- assert.equal(res.json.tabs.length, 1);
99
- assert.equal(res.json.tabs[0].active, true);
100
- } finally {
101
- await server.stop();
102
- }
103
- });
104
-
105
- test('POST /agent/browser/screenshot returns a resized image payload', async () => {
106
- const remote = makeFakeRemote();
107
- const server = createBrowserAgentServer(remote);
108
- const { port, token } = await server.start();
109
- try {
110
- const res = await request(port, { method: 'POST', path: '/agent/browser/screenshot', token, body: {} });
111
- assert.equal(res.status, 200);
112
- assert.equal(res.json.ok, true);
113
- assert.ok(res.json.dataUrl.startsWith('data:image/png'));
114
- } finally {
115
- await server.stop();
116
- }
117
- });
118
-
119
- test('POST /agent/browser/action dispatches a single action', async () => {
120
- const remote = makeFakeRemote();
121
- const server = createBrowserAgentServer(remote);
122
- const { port, token } = await server.start();
123
- try {
124
- const res = await request(port, {
125
- method: 'POST',
126
- path: '/agent/browser/action',
127
- token,
128
- body: { action: 'left_click', coordinate: [10, 20] },
129
- });
130
- assert.equal(res.status, 200);
131
- assert.equal(res.json.ok, true);
132
- assert.equal(remote.actions.length, 1);
133
- assert.deepEqual(remote.actions[0].coordinate, [10, 20]);
134
- } finally {
135
- await server.stop();
136
- }
137
- });
138
-
139
- test('POST /agent/browser/action without an action field is a 400', async () => {
140
- const remote = makeFakeRemote();
141
- const server = createBrowserAgentServer(remote);
142
- const { port, token } = await server.start();
143
- try {
144
- const res = await request(port, { method: 'POST', path: '/agent/browser/action', token, body: {} });
145
- assert.equal(res.status, 400);
146
- assert.equal(res.json.ok, false);
147
- } finally {
148
- await server.stop();
149
- }
150
- });
151
-
152
- test('POST /agent/browser/action surfaces a remote failure as a 400', async () => {
153
- const remote = makeFakeRemote();
154
- const server = createBrowserAgentServer(remote);
155
- const { port, token } = await server.start();
156
- try {
157
- const res = await request(port, { method: 'POST', path: '/agent/browser/action', token, body: { action: 'boom' } });
158
- assert.equal(res.status, 400);
159
- assert.equal(res.json.ok, false);
160
- } finally {
161
- await server.stop();
162
- }
163
- });
164
-
165
- test('GET /agent/browser/dom returns a text snapshot', async () => {
166
- const remote = makeFakeRemote();
167
- const server = createBrowserAgentServer(remote);
168
- const { port, token } = await server.start();
169
- try {
170
- const res = await request(port, { path: '/agent/browser/dom', token });
171
- assert.equal(res.status, 200);
172
- assert.equal(res.json.text, 'hello');
173
- } finally {
174
- await server.stop();
175
- }
176
- });
177
-
178
- test('unknown route is a 404', async () => {
179
- const remote = makeFakeRemote();
180
- const server = createBrowserAgentServer(remote);
181
- const { port, token } = await server.start();
182
- try {
183
- const res = await request(port, { path: '/agent/browser/nope', token });
184
- assert.equal(res.status, 404);
185
- } finally {
186
- await server.stop();
187
- }
188
- });
189
-
190
- test('SM_DEV=1 writes to a dev-suffixed path, never the production browser-agent-api.json', async () => {
191
- const devPath = path.join(path.dirname(TOKEN_PATH), 'browser-agent-api.dev.json');
192
- const prevMtime = fs.existsSync(TOKEN_PATH) ? fs.statSync(TOKEN_PATH).mtimeMs : null;
193
- const prevEnv = process.env.SM_DEV;
194
- const remote = makeFakeRemote();
195
- let server;
196
- try {
197
- process.env.SM_DEV = '1';
198
- server = createBrowserAgentServer(remote);
199
- const { port, token } = await server.start();
200
- assert.equal(resolveTokenPath(), devPath);
201
- const parsed = JSON.parse(fs.readFileSync(devPath, 'utf8'));
202
- assert.equal(parsed.port, port);
203
- assert.equal(parsed.token, token);
204
- if (prevMtime !== null) {
205
- assert.equal(fs.statSync(TOKEN_PATH).mtimeMs, prevMtime);
206
- } else {
207
- assert.equal(fs.existsSync(TOKEN_PATH), false);
208
- }
209
- } finally {
210
- if (server) await server.stop();
211
- if (prevEnv === undefined) delete process.env.SM_DEV; else process.env.SM_DEV = prevEnv;
212
- fs.rmSync(devPath, { force: true });
213
- }
214
- });
215
-
216
- test('with neither SM_DEV nor SM_E2E set, writes to the original browser-agent-api.json path', async () => {
217
- const prevDev = process.env.SM_DEV;
218
- const prevE2e = process.env.SM_E2E;
219
- const remote = makeFakeRemote();
220
- let server;
221
- try {
222
- delete process.env.SM_DEV;
223
- delete process.env.SM_E2E;
224
- assert.equal(resolveTokenPath(), TOKEN_PATH);
225
- server = createBrowserAgentServer(remote);
226
- const { port, token } = await server.start();
227
- const parsed = JSON.parse(fs.readFileSync(TOKEN_PATH, 'utf8'));
228
- assert.equal(parsed.port, port);
229
- assert.equal(parsed.token, token);
230
- } finally {
231
- if (server) await server.stop();
232
- if (prevDev === undefined) delete process.env.SM_DEV; else process.env.SM_DEV = prevDev;
233
- if (prevE2e === undefined) delete process.env.SM_E2E; else process.env.SM_E2E = prevE2e;
234
- }
235
- });
@@ -1,85 +0,0 @@
1
- /**
2
- * browserView-destroyed-handler.test.cjs — regression test for the
3
- * webContents.id-after-destroy crash (see
4
- * session-manager-operations/feedback/processed/
5
- * 2026-07-30-rca-v0390-blank-screen-unstable-selector-185.md, item 4).
6
- *
7
- * The webContents `'destroyed'` handler used to read `view.webContents.id`
8
- * from inside its own callback, after `view.webContents` could already be
9
- * undefined — throwing "Cannot read properties of undefined (reading 'id')"
10
- * on every app quit with an open Browser view. The fix captures the id at
11
- * creation time, before the destroyed handler is registered.
12
- *
13
- * `electron` isn't installed as a runnable binary here, and browserView.cjs
14
- * pulls it in via a plain CJS `require` (not vite-node's ESM graph), so
15
- * `vi.mock('electron', ...)` doesn't intercept it. Instead we pre-seed
16
- * Node's own require cache for the resolved `electron` path with a fake
17
- * `WebContentsView`, the only export browserView.cjs actually uses.
18
- *
19
- * Run: timeout 300 npx vitest run src/main/__tests__/browserView-destroyed-handler.test.cjs
20
- */
21
-
22
- 'use strict';
23
-
24
- import { test, expect, beforeEach } from 'vitest';
25
- import { EventEmitter } from 'node:events';
26
-
27
- let nextWcId = 1;
28
- class FakeWebContents extends EventEmitter {
29
- constructor() {
30
- super();
31
- this.id = nextWcId++;
32
- this.session = { on() {} };
33
- }
34
- setZoomFactor() {}
35
- setUserAgent() {}
36
- setWindowOpenHandler() {}
37
- isDestroyed() { return false; }
38
- }
39
- class FakeWebContentsView {
40
- constructor() {
41
- this.webContents = new FakeWebContents();
42
- }
43
- }
44
-
45
- const electronPath = require.resolve('electron');
46
- require.cache[electronPath] = {
47
- id: electronPath,
48
- filename: electronPath,
49
- loaded: true,
50
- exports: { WebContentsView: FakeWebContentsView },
51
- };
52
-
53
- const { registerBrowserView, isBrowserViewContents, views } = require('../browserView.cjs');
54
-
55
- function makeFakeIpcMain() {
56
- const handlers = new Map();
57
- return {
58
- handle: (channel, fn) => handlers.set(channel, fn),
59
- on: (channel, fn) => handlers.set(channel, fn),
60
- invoke: (channel, payload) => handlers.get(channel)(null, payload),
61
- };
62
- }
63
-
64
- let ipcMain;
65
- beforeEach(() => {
66
- ipcMain = makeFakeIpcMain();
67
- registerBrowserView({ mainWindow: { isDestroyed: () => true }, ipcMain });
68
- });
69
-
70
- test('destroyed handler does not throw when view.webContents is already undefined', async () => {
71
- const viewId = `t-${Date.now()}-${Math.random().toString(36).slice(2)}`;
72
- const result = await ipcMain.invoke('browser:create', { viewId, partition: 'sm-test' });
73
- expect(result.ok).toBe(true);
74
-
75
- const view = views.get(viewId);
76
- const wc = view.webContents;
77
- expect(isBrowserViewContents(wc.id)).toBe(true);
78
-
79
- // Simulate the real crash precondition: webContents already gone by the
80
- // time the 'destroyed' listener fires (e.g. on app quit).
81
- view.webContents = undefined;
82
-
83
- expect(() => wc.emit('destroyed')).not.toThrow();
84
- expect(isBrowserViewContents(wc.id)).toBe(false);
85
- });
@@ -1,126 +0,0 @@
1
- /**
2
- * browserView-oauth-popup.test.cjs — regression test for Google (and other
3
- * identity-provider) OAuth login failing inside the Browser tab.
4
- *
5
- * Root cause: wc.setWindowOpenHandler() in browserView.cjs unconditionally
6
- * denied every window.open() popup, which kills Google's GIS/FedCM popup —
7
- * it needs a real window.opener to postMessage the credential back to. This
8
- * test asserts identity-provider popups get `{ action: 'allow', ... }` (a
9
- * real child window) while every other popup keeps the existing deny +
10
- * browser:open-tab-request behavior, and that a realistic Chrome
11
- * User-Agent (not Electron's default) is set on both the main view and the
12
- * popup's webContents.
13
- *
14
- * `electron` isn't installed as a runnable binary here, and browserView.cjs
15
- * pulls it in via a plain CJS `require` (not vite-node's ESM graph), so
16
- * `vi.mock('electron', ...)` doesn't intercept it. Instead we pre-seed
17
- * Node's own require cache for the resolved `electron` path with a fake
18
- * `WebContentsView`, the only export browserView.cjs actually uses — same
19
- * pattern as browserView-destroyed-handler.test.cjs.
20
- *
21
- * Run: timeout 300 npx vitest run src/main/__tests__/browserView-oauth-popup.test.cjs
22
- */
23
-
24
- 'use strict';
25
-
26
- import { test, expect, beforeEach } from 'vitest';
27
- import { EventEmitter } from 'node:events';
28
-
29
- let nextWcId = 1;
30
- class FakeWebContents extends EventEmitter {
31
- constructor() {
32
- super();
33
- this.id = nextWcId++;
34
- this.session = { on() {} };
35
- this.userAgent = null;
36
- this._windowOpenHandler = null;
37
- }
38
- setZoomFactor() {}
39
- setUserAgent(ua) { this.userAgent = ua; }
40
- setWindowOpenHandler(fn) { this._windowOpenHandler = fn; }
41
- isDestroyed() { return false; }
42
- }
43
- class FakeWebContentsView {
44
- constructor() {
45
- this.webContents = new FakeWebContents();
46
- }
47
- }
48
- class FakeBrowserWindow {
49
- constructor() {
50
- this.webContents = new FakeWebContents();
51
- }
52
- }
53
-
54
- const electronPath = require.resolve('electron');
55
- require.cache[electronPath] = {
56
- id: electronPath,
57
- filename: electronPath,
58
- loaded: true,
59
- exports: { WebContentsView: FakeWebContentsView },
60
- };
61
-
62
- const { registerBrowserView, views, isBrowserViewContents } = require('../browserView.cjs');
63
-
64
- function makeFakeIpcMain() {
65
- const handlers = new Map();
66
- return {
67
- handle: (channel, fn) => handlers.set(channel, fn),
68
- on: (channel, fn) => handlers.set(channel, fn),
69
- invoke: (channel, payload) => handlers.get(channel)(null, payload),
70
- };
71
- }
72
-
73
- let ipcMain;
74
- beforeEach(() => {
75
- ipcMain = makeFakeIpcMain();
76
- registerBrowserView({ mainWindow: { isDestroyed: () => true }, ipcMain });
77
- });
78
-
79
- test('main view webContents gets a real Chrome user-agent, not the Electron default', async () => {
80
- const viewId = `oauth-${Date.now()}-${Math.random().toString(36).slice(2)}`;
81
- await ipcMain.invoke('browser:create', { viewId, partition: 'sm-test' });
82
- const view = views.get(viewId);
83
- expect(view.webContents.userAgent).toBeTruthy();
84
- expect(view.webContents.userAgent).not.toMatch(/Electron/);
85
- expect(view.webContents.userAgent).toMatch(/Chrome\//);
86
- });
87
-
88
- test('setWindowOpenHandler allows identity-provider popups with a real child window and denies others', async () => {
89
- const viewId = `oauth2-${Date.now()}-${Math.random().toString(36).slice(2)}`;
90
- await ipcMain.invoke('browser:create', { viewId, partition: 'sm-test' });
91
- const view = views.get(viewId);
92
- const handler = view.webContents._windowOpenHandler;
93
- expect(typeof handler).toBe('function');
94
-
95
- const allowed = handler({ url: 'https://accounts.google.com/o/oauth2/v2/auth?foo=bar' });
96
- expect(allowed.action).toBe('allow');
97
- expect(allowed.overrideBrowserWindowOptions.webPreferences).toEqual({
98
- contextIsolation: true,
99
- nodeIntegration: false,
100
- sandbox: true,
101
- });
102
-
103
- const denied = handler({ url: 'https://example.com/some-popup' });
104
- expect(denied).toEqual({ action: 'deny' });
105
- });
106
-
107
- test('identity-provider popup child window gets a Chrome UA and cleans up on destroy', async () => {
108
- const viewId = `oauth3-${Date.now()}-${Math.random().toString(36).slice(2)}`;
109
- await ipcMain.invoke('browser:create', { viewId, partition: 'sm-test' });
110
- const view = views.get(viewId);
111
-
112
- const childWindow = new FakeBrowserWindow();
113
- view.webContents.emit('did-create-window', childWindow);
114
-
115
- expect(childWindow.webContents.userAgent).toBeTruthy();
116
- expect(childWindow.webContents.userAgent).toMatch(/Chrome\//);
117
-
118
- // The popup's webContents.id must be exempted from index.cjs's global
119
- // will-navigate lock the same way the main Browser view is — otherwise
120
- // the OAuth handshake this fix enables would immediately get nav-locked
121
- // shut once the popup tries to navigate through consent/callback pages.
122
- expect(isBrowserViewContents(childWindow.webContents.id)).toBe(true);
123
-
124
- expect(() => childWindow.webContents.emit('destroyed')).not.toThrow();
125
- expect(isBrowserViewContents(childWindow.webContents.id)).toBe(false);
126
- });
@@ -1,181 +0,0 @@
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
- })