claude-code-session-manager 0.62.0 → 0.62.1

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 (33) hide show
  1. package/dist/assets/{TiptapBody-Bn-LMce4.js → TiptapBody-BwN4pNC0.js} +1 -1
  2. package/dist/assets/{index-DOXvph-Z.css → index-CVCCMw5o.css} +1 -1
  3. package/dist/assets/{index-BfiMRkln.js → index-ZjTDk5b7.js} +1083 -1111
  4. package/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/main/__tests__/runVerify-transcript-commit-evidence.test.cjs +215 -0
  7. package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +42 -1
  8. package/src/main/__tests__/scheduler-prd-missing-skip.test.cjs +54 -1
  9. package/src/main/__tests__/transcripts-doFlush-array.test.cjs +6 -0
  10. package/src/main/__tests__/transcripts-paged-reads.test.cjs +6 -0
  11. package/src/main/__tests__/transcriptsUsageFor.test.cjs +6 -0
  12. package/src/main/config.cjs +1 -22
  13. package/src/main/files.cjs +20 -0
  14. package/src/main/index.cjs +1 -63
  15. package/src/main/ipcSchemas.cjs +11 -209
  16. package/src/main/lib/opsOwnership.cjs +0 -2
  17. package/src/main/lib/summarize.cjs +0 -1
  18. package/src/main/runVerify.cjs +55 -6
  19. package/src/main/scheduler.cjs +46 -7
  20. package/src/preload/api.d.ts +2 -197
  21. package/src/preload/index.cjs +1 -100
  22. package/src/main/__tests__/browserAgentServer.test.cjs +0 -235
  23. package/src/main/__tests__/browserView-destroyed-handler.test.cjs +0 -85
  24. package/src/main/__tests__/browserView-oauth-popup.test.cjs +0 -126
  25. package/src/main/__tests__/web-remote-e2e-pinning.test.cjs +0 -181
  26. package/src/main/browserAgentServer.cjs +0 -224
  27. package/src/main/browserCapture.cjs +0 -728
  28. package/src/main/browserView.cjs +0 -916
  29. package/src/main/lib/__tests__/browserAgentActions.test.cjs +0 -76
  30. package/src/main/lib/browserAgentActions.cjs +0 -114
  31. package/src/main/lib/e2eStateMachine.cjs +0 -39
  32. package/src/main/webRemote.cjs +0 -1367
  33. package/src/preload/browserViewPreload.cjs +0 -118
@@ -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
- })
@@ -1,224 +0,0 @@
1
- /**
2
- * browserAgentServer.cjs — loopback-only HTTP API for driving the Browser
3
- * tab one ad-hoc action at a time (PRD 535 foundation).
4
- *
5
- * Deliberately a SEPARATE server from the scheduler's admin HTTP API
6
- * (src/main/lib/localAdminHttp.cjs + scheduler.cjs's registerAdminRoutes),
7
- * not a route added to it: that admin API documents itself as intentionally
8
- * narrow ("Only two routes... writePrd/pause/resume are intentionally NOT
9
- * exposed here"). Arbitrary click/type/navigate/screenshot control of a live
10
- * browser is a materially larger capability surface than "reset one
11
- * scheduler job" and deserves its own security boundary, not creeping scope
12
- * onto the existing narrow one. A future MCP server (PRD 536) wraps this
13
- * HTTP API the same way scripts/scheduler-mcp-server.cjs wraps the admin API.
14
- *
15
- * Security posture (mirrors the scheduler's admin API exactly):
16
- * - Binds 127.0.0.1 only, OS-assigned ephemeral port. Never reachable
17
- * off-box.
18
- * - Bearer token, regenerated every app boot, written to
19
- * ~/.claude/session-manager/browser-agent-api.json with 0600 perms.
20
- * A distinct file from admin-api.json — this token is never shared with
21
- * or derived from the scheduler's token.
22
- * - Token compared with crypto.timingSafeEqual to avoid a timing
23
- * side-channel on the comparison itself.
24
- * - Mode-scoped token file, same rationale/fix as localAdminHttp.cjs's
25
- * resolveTokenPath() (confirmed-live incident 2026-07-29): a dev/e2e
26
- * launch (SM_DEV=1 / SM_E2E=1) writes to browser-agent-api.dev.json /
27
- * browser-agent-api.e2e.json instead of the production file, so it can
28
- * never silently orphan a concurrently-running production instance.
29
- * - Four routes: list tabs (read-only), screenshot, single action
30
- * (click/type/key/scroll/navigate), DOM/text snapshot.
31
- *
32
- * Out of scope for THIS server (see PRD 535's "Out of scope" section):
33
- * no confirmation/consent gating, no domain allowlist/blocklist, no
34
- * content-injection scanning. That safety layer is a deliberate, separate
35
- * follow-up decision, not an oversight.
36
- */
37
-
38
- 'use strict';
39
-
40
- const http = require('node:http');
41
- const os = require('node:os');
42
- const path = require('node:path');
43
- const crypto = require('node:crypto');
44
- const fsp = require('node:fs/promises');
45
- const config = require('./config.cjs');
46
-
47
- const TOKEN_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'browser-agent-api.json');
48
- // Screenshots are base64 PNG data URLs — bigger cap than the admin API's 1MB,
49
- // still bounded so a malformed/malicious body can't exhaust memory.
50
- const BODY_MAX_BYTES = 8 * 1024 * 1024;
51
-
52
- /**
53
- * Mode-aware token file path, resolved fresh on every call — see
54
- * localAdminHttp.cjs's resolveTokenPath() for the full rationale.
55
- */
56
- function resolveTokenPath() {
57
- if (process.env.SM_DEV === '1') {
58
- return path.join(os.homedir(), '.claude', 'session-manager', 'browser-agent-api.dev.json');
59
- }
60
- if (process.env.SM_E2E === '1') {
61
- return path.join(os.homedir(), '.claude', 'session-manager', 'browser-agent-api.e2e.json');
62
- }
63
- return TOKEN_PATH;
64
- }
65
-
66
- function timingSafeEqualStrings(a, b) {
67
- const bufA = Buffer.from(String(a ?? ''));
68
- const bufB = Buffer.from(String(b ?? ''));
69
- if (bufA.length !== bufB.length) {
70
- // Compare against a same-length buffer anyway so the failure path still
71
- // takes constant time relative to the (fixed) token length, rather than
72
- // returning early on a length mismatch.
73
- crypto.timingSafeEqual(bufA, bufA);
74
- return false;
75
- }
76
- return crypto.timingSafeEqual(bufA, bufB);
77
- }
78
-
79
- function readBody(req, maxBytes = BODY_MAX_BYTES) {
80
- return new Promise((resolve, reject) => {
81
- let total = 0;
82
- const chunks = [];
83
- req.on('data', (chunk) => {
84
- total += chunk.length;
85
- if (total > maxBytes) {
86
- reject(new Error('body too large'));
87
- req.destroy();
88
- return;
89
- }
90
- chunks.push(chunk);
91
- });
92
- req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
93
- req.on('error', reject);
94
- });
95
- }
96
-
97
- function sendJson(res, status, obj) {
98
- const body = JSON.stringify(obj);
99
- res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) });
100
- res.end(body);
101
- }
102
-
103
- function safeParseJson(raw) {
104
- if (!raw) return {};
105
- try {
106
- return JSON.parse(raw);
107
- } catch {
108
- return undefined;
109
- }
110
- }
111
-
112
- /**
113
- * Create the browser-agent HTTP server. `remote` exposes the browserView.cjs
114
- * functions this server dispatches to (injected, not required directly, so
115
- * this module stays testable without booting Electron) — shape:
116
- * { listTabs(): Promise<Array>, screenshot({viewId}): Promise<obj>,
117
- * action(params): Promise<obj>, dom({viewId}): Promise<obj> }
118
- */
119
- function createBrowserAgentServer(remote) {
120
- let server = null;
121
- let token = null;
122
-
123
- async function ensureToken() {
124
- token = crypto.randomBytes(32).toString('hex');
125
- const tokenPath = resolveTokenPath();
126
- await config.writeJson(tokenPath, { port: null, token });
127
- try { await fsp.chmod(tokenPath, 0o600); } catch { /* best-effort on platforms without POSIX perms */ }
128
- return token;
129
- }
130
-
131
- async function persistPort(port) {
132
- const tokenPath = resolveTokenPath();
133
- await config.writeJson(tokenPath, { port, token });
134
- try { await fsp.chmod(tokenPath, 0o600); } catch { /* */ }
135
- }
136
-
137
- function authorized(req) {
138
- const header = req.headers.authorization || '';
139
- const match = /^Bearer (.+)$/.exec(header);
140
- if (!match) return false;
141
- return timingSafeEqualStrings(match[1], token);
142
- }
143
-
144
- async function handleRequest(req, res) {
145
- if (!authorized(req)) {
146
- sendJson(res, 401, { ok: false, error: 'unauthorized' });
147
- return;
148
- }
149
- try {
150
- const url = new URL(req.url, 'http://127.0.0.1');
151
-
152
- if (req.method === 'GET' && url.pathname === '/agent/browser/tabs') {
153
- const tabs = await remote.listTabs();
154
- sendJson(res, 200, { ok: true, tabs });
155
- return;
156
- }
157
-
158
- if (req.method === 'POST' && url.pathname === '/agent/browser/screenshot') {
159
- const raw = await readBody(req);
160
- const parsed = safeParseJson(raw);
161
- if (parsed === undefined) {
162
- sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
163
- return;
164
- }
165
- const result = await remote.screenshot({ viewId: typeof parsed.viewId === 'string' ? parsed.viewId : undefined });
166
- sendJson(res, result.ok ? 200 : 400, result);
167
- return;
168
- }
169
-
170
- if (req.method === 'POST' && url.pathname === '/agent/browser/action') {
171
- const raw = await readBody(req);
172
- const parsed = safeParseJson(raw);
173
- if (parsed === undefined) {
174
- sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
175
- return;
176
- }
177
- if (typeof parsed.action !== 'string' || !parsed.action) {
178
- sendJson(res, 400, { ok: false, error: 'missing action' });
179
- return;
180
- }
181
- const result = await remote.action(parsed);
182
- sendJson(res, result.ok ? 200 : 400, result);
183
- return;
184
- }
185
-
186
- if (req.method === 'GET' && url.pathname === '/agent/browser/dom') {
187
- const viewId = url.searchParams.get('viewId') || undefined;
188
- const result = await remote.dom({ viewId });
189
- sendJson(res, result.ok ? 200 : 400, result);
190
- return;
191
- }
192
-
193
- sendJson(res, 404, { ok: false, error: 'not found' });
194
- } catch (e) {
195
- sendJson(res, 500, { ok: false, error: e?.message ?? 'internal error' });
196
- }
197
- }
198
-
199
- async function start() {
200
- await ensureToken();
201
- server = http.createServer((req, res) => {
202
- handleRequest(req, res).catch(() => {
203
- try { sendJson(res, 500, { ok: false, error: 'internal error' }); } catch { /* */ }
204
- });
205
- });
206
- await new Promise((resolve, reject) => {
207
- server.once('error', reject);
208
- server.listen(0, '127.0.0.1', resolve);
209
- });
210
- const { port } = server.address();
211
- await persistPort(port);
212
- return { port, token };
213
- }
214
-
215
- async function stop() {
216
- if (!server) return;
217
- await new Promise((resolve) => server.close(() => resolve()));
218
- server = null;
219
- }
220
-
221
- return { start, stop, get token() { return token; }, get server() { return server; } };
222
- }
223
-
224
- module.exports = { createBrowserAgentServer, TOKEN_PATH, resolveTokenPath };