claude-code-session-manager 0.35.13 → 0.35.14
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/assets/{TiptapBody-B_2hT1QJ.js → TiptapBody-o1hQcwtq.js} +1 -1
- package/dist/assets/index-B39Qiq0n.css +32 -0
- package/dist/assets/{index-LqC9hNy3.js → index-DqWSSMkp.js} +463 -463
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/main/__tests__/browserAgentServer.test.cjs +186 -0
- package/src/main/__tests__/scheduler-autofix-select.test.cjs +45 -0
- package/src/main/browserAgentServer.cjs +202 -0
- package/src/main/browserView.cjs +197 -1
- package/src/main/index.cjs +11 -0
- package/src/main/lib/__tests__/browserAgentActions.test.cjs +76 -0
- package/src/main/lib/browserAgentActions.cjs +114 -0
- package/src/main/lib/schedulerBatch.cjs +40 -33
- package/src/main/scheduler.cjs +105 -13
- package/src/preload/api.d.ts +8 -2
- package/dist/assets/index-C7NyYuXu.css +0 -32
package/dist/index.html
CHANGED
|
@@ -7,10 +7,10 @@
|
|
|
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-
|
|
10
|
+
<script type="module" crossorigin src="./assets/index-DqWSSMkp.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
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
13
|
+
<link rel="stylesheet" crossorigin href="./assets/index-B39Qiq0n.css">
|
|
14
14
|
</head>
|
|
15
15
|
<body class="bg-bg text-fg font-sans antialiased">
|
|
16
16
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-session-manager",
|
|
3
|
-
"version": "0.35.
|
|
3
|
+
"version": "0.35.14",
|
|
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,186 @@
|
|
|
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 { createBrowserAgentServer } = require('../browserAgentServer.cjs');
|
|
14
|
+
|
|
15
|
+
function request(port, { method = 'GET', path: reqPath, token, body }) {
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
const headers = {};
|
|
18
|
+
if (token !== undefined) headers.Authorization = `Bearer ${token}`;
|
|
19
|
+
let payload;
|
|
20
|
+
if (body !== undefined) {
|
|
21
|
+
payload = JSON.stringify(body);
|
|
22
|
+
headers['Content-Type'] = 'application/json';
|
|
23
|
+
headers['Content-Length'] = Buffer.byteLength(payload);
|
|
24
|
+
}
|
|
25
|
+
const req = http.request({ hostname: '127.0.0.1', port, method, path: reqPath, headers }, (res) => {
|
|
26
|
+
const chunks = [];
|
|
27
|
+
res.on('data', (c) => chunks.push(c));
|
|
28
|
+
res.on('end', () => {
|
|
29
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
30
|
+
let json = null;
|
|
31
|
+
try { json = JSON.parse(text); } catch { /* leave null for non-JSON bodies */ }
|
|
32
|
+
resolve({ status: res.statusCode, json, text });
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
req.on('error', reject);
|
|
36
|
+
if (payload) req.write(payload);
|
|
37
|
+
req.end();
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function makeFakeRemote() {
|
|
42
|
+
const actions = [];
|
|
43
|
+
return {
|
|
44
|
+
actions,
|
|
45
|
+
async listTabs() {
|
|
46
|
+
return [{ viewId: 'v1', url: 'https://example.com', title: 'Example', active: true }];
|
|
47
|
+
},
|
|
48
|
+
async screenshot({ viewId }) {
|
|
49
|
+
if (!viewId && viewId !== undefined) return { ok: false, error: 'unknown viewId' };
|
|
50
|
+
return { ok: true, viewId: viewId || 'v1', dataUrl: 'data:image/png;base64,AAA', scale: 1 };
|
|
51
|
+
},
|
|
52
|
+
async action(params) {
|
|
53
|
+
actions.push(params);
|
|
54
|
+
if (params.action === 'boom') return { ok: false, error: 'unsupported action: boom' };
|
|
55
|
+
return { ok: true };
|
|
56
|
+
},
|
|
57
|
+
async dom({ viewId }) {
|
|
58
|
+
return { ok: true, viewId: viewId || 'v1', text: 'hello', elements: [] };
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
test('rejects requests without a bearer token', async () => {
|
|
64
|
+
const remote = makeFakeRemote();
|
|
65
|
+
const server = createBrowserAgentServer(remote);
|
|
66
|
+
const { port } = await server.start();
|
|
67
|
+
try {
|
|
68
|
+
const res = await request(port, { path: '/agent/browser/tabs' });
|
|
69
|
+
assert.equal(res.status, 401);
|
|
70
|
+
assert.equal(res.json.ok, false);
|
|
71
|
+
} finally {
|
|
72
|
+
await server.stop();
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('rejects requests with a wrong token', async () => {
|
|
77
|
+
const remote = makeFakeRemote();
|
|
78
|
+
const server = createBrowserAgentServer(remote);
|
|
79
|
+
const { port } = await server.start();
|
|
80
|
+
try {
|
|
81
|
+
const res = await request(port, { path: '/agent/browser/tabs', token: 'wrong-token' });
|
|
82
|
+
assert.equal(res.status, 401);
|
|
83
|
+
} finally {
|
|
84
|
+
await server.stop();
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('GET /agent/browser/tabs lists open tabs', async () => {
|
|
89
|
+
const remote = makeFakeRemote();
|
|
90
|
+
const server = createBrowserAgentServer(remote);
|
|
91
|
+
const { port, token } = await server.start();
|
|
92
|
+
try {
|
|
93
|
+
const res = await request(port, { path: '/agent/browser/tabs', token });
|
|
94
|
+
assert.equal(res.status, 200);
|
|
95
|
+
assert.equal(res.json.ok, true);
|
|
96
|
+
assert.equal(res.json.tabs.length, 1);
|
|
97
|
+
assert.equal(res.json.tabs[0].active, true);
|
|
98
|
+
} finally {
|
|
99
|
+
await server.stop();
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('POST /agent/browser/screenshot returns a resized image payload', async () => {
|
|
104
|
+
const remote = makeFakeRemote();
|
|
105
|
+
const server = createBrowserAgentServer(remote);
|
|
106
|
+
const { port, token } = await server.start();
|
|
107
|
+
try {
|
|
108
|
+
const res = await request(port, { method: 'POST', path: '/agent/browser/screenshot', token, body: {} });
|
|
109
|
+
assert.equal(res.status, 200);
|
|
110
|
+
assert.equal(res.json.ok, true);
|
|
111
|
+
assert.ok(res.json.dataUrl.startsWith('data:image/png'));
|
|
112
|
+
} finally {
|
|
113
|
+
await server.stop();
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('POST /agent/browser/action dispatches a single action', async () => {
|
|
118
|
+
const remote = makeFakeRemote();
|
|
119
|
+
const server = createBrowserAgentServer(remote);
|
|
120
|
+
const { port, token } = await server.start();
|
|
121
|
+
try {
|
|
122
|
+
const res = await request(port, {
|
|
123
|
+
method: 'POST',
|
|
124
|
+
path: '/agent/browser/action',
|
|
125
|
+
token,
|
|
126
|
+
body: { action: 'left_click', coordinate: [10, 20] },
|
|
127
|
+
});
|
|
128
|
+
assert.equal(res.status, 200);
|
|
129
|
+
assert.equal(res.json.ok, true);
|
|
130
|
+
assert.equal(remote.actions.length, 1);
|
|
131
|
+
assert.deepEqual(remote.actions[0].coordinate, [10, 20]);
|
|
132
|
+
} finally {
|
|
133
|
+
await server.stop();
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('POST /agent/browser/action without an action field is a 400', async () => {
|
|
138
|
+
const remote = makeFakeRemote();
|
|
139
|
+
const server = createBrowserAgentServer(remote);
|
|
140
|
+
const { port, token } = await server.start();
|
|
141
|
+
try {
|
|
142
|
+
const res = await request(port, { method: 'POST', path: '/agent/browser/action', token, body: {} });
|
|
143
|
+
assert.equal(res.status, 400);
|
|
144
|
+
assert.equal(res.json.ok, false);
|
|
145
|
+
} finally {
|
|
146
|
+
await server.stop();
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('POST /agent/browser/action surfaces a remote failure as a 400', async () => {
|
|
151
|
+
const remote = makeFakeRemote();
|
|
152
|
+
const server = createBrowserAgentServer(remote);
|
|
153
|
+
const { port, token } = await server.start();
|
|
154
|
+
try {
|
|
155
|
+
const res = await request(port, { method: 'POST', path: '/agent/browser/action', token, body: { action: 'boom' } });
|
|
156
|
+
assert.equal(res.status, 400);
|
|
157
|
+
assert.equal(res.json.ok, false);
|
|
158
|
+
} finally {
|
|
159
|
+
await server.stop();
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('GET /agent/browser/dom returns a text snapshot', async () => {
|
|
164
|
+
const remote = makeFakeRemote();
|
|
165
|
+
const server = createBrowserAgentServer(remote);
|
|
166
|
+
const { port, token } = await server.start();
|
|
167
|
+
try {
|
|
168
|
+
const res = await request(port, { path: '/agent/browser/dom', token });
|
|
169
|
+
assert.equal(res.status, 200);
|
|
170
|
+
assert.equal(res.json.text, 'hello');
|
|
171
|
+
} finally {
|
|
172
|
+
await server.stop();
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('unknown route is a 404', async () => {
|
|
177
|
+
const remote = makeFakeRemote();
|
|
178
|
+
const server = createBrowserAgentServer(remote);
|
|
179
|
+
const { port, token } = await server.start();
|
|
180
|
+
try {
|
|
181
|
+
const res = await request(port, { path: '/agent/browser/nope', token });
|
|
182
|
+
assert.equal(res.status, 404);
|
|
183
|
+
} finally {
|
|
184
|
+
await server.stop();
|
|
185
|
+
}
|
|
186
|
+
});
|
|
@@ -11,6 +11,7 @@ const assert = require('node:assert/strict');
|
|
|
11
11
|
const {
|
|
12
12
|
selectAutoFixTargets, isUnresolvableNeedsReview, isRescanCandidate,
|
|
13
13
|
healTargetForFix, isFixPlanBeyondDepthCap, MAX_INVESTIGATION_DEPTH,
|
|
14
|
+
isEligibleForImmediateAutoFix,
|
|
14
15
|
} = require('../scheduler.cjs');
|
|
15
16
|
|
|
16
17
|
const noSiblingOnDisk = () => false;
|
|
@@ -213,4 +214,48 @@ test('healTargetForFix: depth-2 fix-of-a-fix slug resolves back to the depth-1 f
|
|
|
213
214
|
assert.strictEqual(resolved, depth1FixJob);
|
|
214
215
|
});
|
|
215
216
|
|
|
217
|
+
// ---- isEligibleForImmediateAutoFix (spawnJob's same-tick auto-fix trigger,
|
|
218
|
+
// PRD 2026-07-12: needs_review jobs no longer wait up to 10 min for
|
|
219
|
+
// reverifyNeedsReview()'s periodic pass) ----
|
|
220
|
+
|
|
221
|
+
test('isEligibleForImmediateAutoFix: fresh needs_review job (fresh from spawnJob, not reverifyNeedsReview) is eligible', () => {
|
|
222
|
+
const job = makeJob();
|
|
223
|
+
const result = isEligibleForImmediateAutoFix(job, [job], noSiblingOnDisk);
|
|
224
|
+
assert.strictEqual(result, true);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('isEligibleForImmediateAutoFix: job already autoFixAttempted is excluded (prevents double-spawn with the periodic pass)', () => {
|
|
228
|
+
const job = makeJob({ autoFixAttempted: true });
|
|
229
|
+
const result = isEligibleForImmediateAutoFix(job, [job], noSiblingOnDisk);
|
|
230
|
+
assert.strictEqual(result, false);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test('isEligibleForImmediateAutoFix: fix sibling already on disk excludes the job', () => {
|
|
234
|
+
const job = makeJob();
|
|
235
|
+
// fixSlug = '05-fix-my-feature'
|
|
236
|
+
const result = isEligibleForImmediateAutoFix(job, [job], (s) => s === '05-fix-my-feature');
|
|
237
|
+
assert.strictEqual(result, false);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('isEligibleForImmediateAutoFix: fix-plan job beyond the depth cap is excluded', () => {
|
|
241
|
+
const job = makeJob({ slug: '05-fix-foo', investigationDepth: 3 });
|
|
242
|
+
const result = isEligibleForImmediateAutoFix(job, [job], noSiblingOnDisk);
|
|
243
|
+
assert.strictEqual(result, false);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test('isEligibleForImmediateAutoFix: a different job in the same queue does not affect this job\'s eligibility', () => {
|
|
247
|
+
const job = makeJob();
|
|
248
|
+
const other = { slug: '05-fix-my-feature', status: 'pending', runId: null };
|
|
249
|
+
const result = isEligibleForImmediateAutoFix(job, [job, other], noSiblingOnDisk);
|
|
250
|
+
// The fix sibling is already queued, so this job is excluded too (matches
|
|
251
|
+
// selectAutoFixTargets's "no fix sibling in queue" rule).
|
|
252
|
+
assert.strictEqual(result, false);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test('isEligibleForImmediateAutoFix: a job whose status is not needs_review (e.g. still pending) is excluded', () => {
|
|
256
|
+
const job = makeJob({ status: 'pending' });
|
|
257
|
+
const result = isEligibleForImmediateAutoFix(job, [job], noSiblingOnDisk);
|
|
258
|
+
assert.strictEqual(result, false);
|
|
259
|
+
});
|
|
260
|
+
|
|
216
261
|
console.log('scheduler-autofix-select tests: PASS');
|
|
@@ -0,0 +1,202 @@
|
|
|
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 adminServer.cjs, not a route added to
|
|
6
|
+
* it: adminServer.cjs documents itself as intentionally narrow ("Only two
|
|
7
|
+
* routes... writePrd/pause/resume are intentionally NOT exposed here").
|
|
8
|
+
* Arbitrary click/type/navigate/screenshot control of a live browser is a
|
|
9
|
+
* materially larger capability surface than "reset one scheduler job" and
|
|
10
|
+
* deserves its own security boundary, not creeping scope onto the existing
|
|
11
|
+
* narrow one. A future MCP server (PRD 536) wraps this HTTP API the same way
|
|
12
|
+
* scripts/scheduler-mcp-server.cjs wraps adminServer.cjs.
|
|
13
|
+
*
|
|
14
|
+
* Security posture (mirrors adminServer.cjs exactly):
|
|
15
|
+
* - Binds 127.0.0.1 only, OS-assigned ephemeral port. Never reachable
|
|
16
|
+
* off-box.
|
|
17
|
+
* - Bearer token, regenerated every app boot, written to
|
|
18
|
+
* ~/.claude/session-manager/browser-agent-api.json with 0600 perms.
|
|
19
|
+
* A distinct file from admin-api.json — this token is never shared with
|
|
20
|
+
* or derived from the scheduler's token.
|
|
21
|
+
* - Token compared with crypto.timingSafeEqual to avoid a timing
|
|
22
|
+
* side-channel on the comparison itself.
|
|
23
|
+
* - Four routes: list tabs (read-only), screenshot, single action
|
|
24
|
+
* (click/type/key/scroll/navigate), DOM/text snapshot.
|
|
25
|
+
*
|
|
26
|
+
* Out of scope for THIS server (see PRD 535's "Out of scope" section):
|
|
27
|
+
* no confirmation/consent gating, no domain allowlist/blocklist, no
|
|
28
|
+
* content-injection scanning. That safety layer is a deliberate, separate
|
|
29
|
+
* follow-up decision, not an oversight.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
'use strict';
|
|
33
|
+
|
|
34
|
+
const http = require('node:http');
|
|
35
|
+
const os = require('node:os');
|
|
36
|
+
const path = require('node:path');
|
|
37
|
+
const crypto = require('node:crypto');
|
|
38
|
+
const fsp = require('node:fs/promises');
|
|
39
|
+
const config = require('./config.cjs');
|
|
40
|
+
|
|
41
|
+
const TOKEN_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'browser-agent-api.json');
|
|
42
|
+
// Screenshots are base64 PNG data URLs — bigger cap than adminServer's 1MB,
|
|
43
|
+
// still bounded so a malformed/malicious body can't exhaust memory.
|
|
44
|
+
const BODY_MAX_BYTES = 8 * 1024 * 1024;
|
|
45
|
+
|
|
46
|
+
function timingSafeEqualStrings(a, b) {
|
|
47
|
+
const bufA = Buffer.from(String(a ?? ''));
|
|
48
|
+
const bufB = Buffer.from(String(b ?? ''));
|
|
49
|
+
if (bufA.length !== bufB.length) {
|
|
50
|
+
// Compare against a same-length buffer anyway so the failure path still
|
|
51
|
+
// takes constant time relative to the (fixed) token length, rather than
|
|
52
|
+
// returning early on a length mismatch.
|
|
53
|
+
crypto.timingSafeEqual(bufA, bufA);
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
return crypto.timingSafeEqual(bufA, bufB);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function readBody(req, maxBytes = BODY_MAX_BYTES) {
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
let total = 0;
|
|
62
|
+
const chunks = [];
|
|
63
|
+
req.on('data', (chunk) => {
|
|
64
|
+
total += chunk.length;
|
|
65
|
+
if (total > maxBytes) {
|
|
66
|
+
reject(new Error('body too large'));
|
|
67
|
+
req.destroy();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
chunks.push(chunk);
|
|
71
|
+
});
|
|
72
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
73
|
+
req.on('error', reject);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function sendJson(res, status, obj) {
|
|
78
|
+
const body = JSON.stringify(obj);
|
|
79
|
+
res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) });
|
|
80
|
+
res.end(body);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function safeParseJson(raw) {
|
|
84
|
+
if (!raw) return {};
|
|
85
|
+
try {
|
|
86
|
+
return JSON.parse(raw);
|
|
87
|
+
} catch {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Create the browser-agent HTTP server. `remote` exposes the browserView.cjs
|
|
94
|
+
* functions this server dispatches to (injected, not required directly, so
|
|
95
|
+
* this module stays testable without booting Electron) — shape:
|
|
96
|
+
* { listTabs(): Promise<Array>, screenshot({viewId}): Promise<obj>,
|
|
97
|
+
* action(params): Promise<obj>, dom({viewId}): Promise<obj> }
|
|
98
|
+
*/
|
|
99
|
+
function createBrowserAgentServer(remote) {
|
|
100
|
+
let server = null;
|
|
101
|
+
let token = null;
|
|
102
|
+
|
|
103
|
+
async function ensureToken() {
|
|
104
|
+
token = crypto.randomBytes(32).toString('hex');
|
|
105
|
+
await config.writeJson(TOKEN_PATH, { port: null, token });
|
|
106
|
+
try { await fsp.chmod(TOKEN_PATH, 0o600); } catch { /* best-effort on platforms without POSIX perms */ }
|
|
107
|
+
return token;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function persistPort(port) {
|
|
111
|
+
await config.writeJson(TOKEN_PATH, { port, token });
|
|
112
|
+
try { await fsp.chmod(TOKEN_PATH, 0o600); } catch { /* */ }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function authorized(req) {
|
|
116
|
+
const header = req.headers.authorization || '';
|
|
117
|
+
const match = /^Bearer (.+)$/.exec(header);
|
|
118
|
+
if (!match) return false;
|
|
119
|
+
return timingSafeEqualStrings(match[1], token);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function handleRequest(req, res) {
|
|
123
|
+
if (!authorized(req)) {
|
|
124
|
+
sendJson(res, 401, { ok: false, error: 'unauthorized' });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
129
|
+
|
|
130
|
+
if (req.method === 'GET' && url.pathname === '/agent/browser/tabs') {
|
|
131
|
+
const tabs = await remote.listTabs();
|
|
132
|
+
sendJson(res, 200, { ok: true, tabs });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (req.method === 'POST' && url.pathname === '/agent/browser/screenshot') {
|
|
137
|
+
const raw = await readBody(req);
|
|
138
|
+
const parsed = safeParseJson(raw);
|
|
139
|
+
if (parsed === undefined) {
|
|
140
|
+
sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const result = await remote.screenshot({ viewId: typeof parsed.viewId === 'string' ? parsed.viewId : undefined });
|
|
144
|
+
sendJson(res, result.ok ? 200 : 400, result);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (req.method === 'POST' && url.pathname === '/agent/browser/action') {
|
|
149
|
+
const raw = await readBody(req);
|
|
150
|
+
const parsed = safeParseJson(raw);
|
|
151
|
+
if (parsed === undefined) {
|
|
152
|
+
sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (typeof parsed.action !== 'string' || !parsed.action) {
|
|
156
|
+
sendJson(res, 400, { ok: false, error: 'missing action' });
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const result = await remote.action(parsed);
|
|
160
|
+
sendJson(res, result.ok ? 200 : 400, result);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (req.method === 'GET' && url.pathname === '/agent/browser/dom') {
|
|
165
|
+
const viewId = url.searchParams.get('viewId') || undefined;
|
|
166
|
+
const result = await remote.dom({ viewId });
|
|
167
|
+
sendJson(res, result.ok ? 200 : 400, result);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
sendJson(res, 404, { ok: false, error: 'not found' });
|
|
172
|
+
} catch (e) {
|
|
173
|
+
sendJson(res, 500, { ok: false, error: e?.message ?? 'internal error' });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function start() {
|
|
178
|
+
await ensureToken();
|
|
179
|
+
server = http.createServer((req, res) => {
|
|
180
|
+
handleRequest(req, res).catch(() => {
|
|
181
|
+
try { sendJson(res, 500, { ok: false, error: 'internal error' }); } catch { /* */ }
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
await new Promise((resolve, reject) => {
|
|
185
|
+
server.once('error', reject);
|
|
186
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
187
|
+
});
|
|
188
|
+
const { port } = server.address();
|
|
189
|
+
await persistPort(port);
|
|
190
|
+
return { port, token };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function stop() {
|
|
194
|
+
if (!server) return;
|
|
195
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
196
|
+
server = null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return { start, stop, get token() { return token; }, get server() { return server; } };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
module.exports = { createBrowserAgentServer, TOKEN_PATH };
|