@thegitai/cli 1.0.0-preview.37 → 1.0.0-preview.39
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/README.md +20 -0
- package/dist/bin/ai.js +25 -18
- package/dist/bin/browser-host.js +265 -0
- package/dist/src/agent-mode.js +10 -0
- package/dist/src/api/chat.js +5 -1
- package/dist/src/api/models.js +10 -0
- package/dist/src/browser/bridge.js +232 -0
- package/dist/src/browser/framing.js +42 -0
- package/dist/src/browser/native-host.js +209 -0
- package/dist/src/browser/protocol.js +46 -0
- package/dist/src/browser/session-bridge.js +104 -0
- package/dist/src/client-environment.js +2 -0
- package/dist/src/permissions.js +49 -4
- package/dist/src/session.js +6 -2
- package/dist/src/tool-executor.js +1 -0
- package/dist/src/tools/browser.js +628 -0
- package/dist/src/tools/index.js +19 -0
- package/dist/src/ui/repl.js +24 -1
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -43,8 +43,28 @@ CLI login tokens use a rolling 48-hour inactivity timeout. If one expires during
|
|
|
43
43
|
any server request, the CLI removes the expired credential and signs you in
|
|
44
44
|
again on the next run.
|
|
45
45
|
|
|
46
|
+
## Chrome browser control
|
|
47
|
+
|
|
48
|
+
The TheGitAI Chrome extension is separately installed. After installing it,
|
|
49
|
+
start `ai` normally and ask about your current tab or a website task. It uses
|
|
50
|
+
your existing Chrome profile, website sessions, and terminal login. Connection
|
|
51
|
+
is automatic; no setup/start command, second sign-in, or manual tab attachment
|
|
52
|
+
is required. Tabs used by the agent join a TheGitAI tab group in an existing
|
|
53
|
+
window. The conversation stays in your terminal.
|
|
54
|
+
|
|
55
|
+
Default mode asks before using a website and can remember that site for the
|
|
56
|
+
session. Auto-Accept allows browser actions; Plan mode reads pages without
|
|
57
|
+
clicking, typing, or navigating. Use Stop/Resume in the extension popup to
|
|
58
|
+
pause browser control, or Disable to disconnect it. Chrome's protected pages,
|
|
59
|
+
including browser settings and extension management, cannot be controlled.
|
|
60
|
+
|
|
61
|
+
The extension is currently a development download and is not yet listed in the
|
|
62
|
+
Chrome Web Store. Its version and distribution are independent of this npm
|
|
63
|
+
package. Ubuntu with Chrome is the initial validation target.
|
|
64
|
+
|
|
46
65
|
## Structured questions
|
|
47
66
|
|
|
67
|
+
|
|
48
68
|
The agent can pause its current turn to ask up to four related questions in one
|
|
49
69
|
form. Use **↑ / ↓**, the displayed option number, or **Enter** to choose, and
|
|
50
70
|
**← / →** to move between questions. **Something else / add details** is the
|
package/dist/bin/ai.js
CHANGED
|
@@ -6,9 +6,10 @@ import { isSignInCancelled } from '../src/api/browser-login.js';
|
|
|
6
6
|
import { runSignIn } from '../src/signin.js';
|
|
7
7
|
import { STARTUP_RETRY_BUDGET, authenticationErrorMessage, isAuthenticationError, isTransientNetworkError, } from '../src/api/http.js';
|
|
8
8
|
import { formatCliHelpText } from '../src/help-text.js';
|
|
9
|
-
import { createSession } from '../src/session.js';
|
|
9
|
+
import { createSession, disposeSession } from '../src/session.js';
|
|
10
10
|
import { applySessionSnapshot, listSessionMetadata, loadSessionSnapshot, saveSessionState, sessionHasUserMessage, } from '../src/session-store.js';
|
|
11
11
|
import { runClientInteractive } from '../src/ui/repl.js';
|
|
12
|
+
import { startBrowserDiscoveryInBackground } from '../src/browser/session-bridge.js';
|
|
12
13
|
import { appendPromptToFile } from '../src/ui/prompt-history-store.js';
|
|
13
14
|
import { formatSessionExitNotice } from '../src/session-exit.js';
|
|
14
15
|
import { formatUsageText } from '../src/usage.js';
|
|
@@ -237,26 +238,32 @@ export async function main() {
|
|
|
237
238
|
if (sourceSnapshot) {
|
|
238
239
|
applySessionSnapshot(session, sourceSnapshot);
|
|
239
240
|
}
|
|
241
|
+
startBrowserDiscoveryInBackground({ cwd: rootDir, env: session.env });
|
|
240
242
|
const initialPrompt = prompt || undefined;
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
if (
|
|
253
|
-
|
|
243
|
+
try {
|
|
244
|
+
const outcome = await runClientInteractive({
|
|
245
|
+
appendPromptHistory: (value) => appendPromptHistory(value, session.env),
|
|
246
|
+
authConfig,
|
|
247
|
+
debugUi: whoami.debugUi,
|
|
248
|
+
serverModels,
|
|
249
|
+
serverSessionClient,
|
|
250
|
+
session,
|
|
251
|
+
initialPrompt,
|
|
252
|
+
usageText: async () => formatUsageText(await auth.fetchWhoamiResponse({ config: authConfig })),
|
|
253
|
+
});
|
|
254
|
+
if (outcome.signedOut) {
|
|
255
|
+
if (sessionHasUserMessage(session)) {
|
|
256
|
+
saveSessionState(session);
|
|
257
|
+
}
|
|
258
|
+
console.log(chalk.green('\n✓ Logged out.\n'));
|
|
259
|
+
return;
|
|
254
260
|
}
|
|
255
|
-
|
|
256
|
-
|
|
261
|
+
await saveSessionBoth({ session, serverSessionClient });
|
|
262
|
+
printSessionExit(session);
|
|
263
|
+
}
|
|
264
|
+
finally {
|
|
265
|
+
await disposeSession(session);
|
|
257
266
|
}
|
|
258
|
-
await saveSessionBoth({ session, serverSessionClient });
|
|
259
|
-
printSessionExit(session);
|
|
260
267
|
}
|
|
261
268
|
main().catch((error) => {
|
|
262
269
|
if (isSignInCancelled(error)) {
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import net from 'node:net';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { decodeFrames, encodeFrame, fragmentPayload, MAX_FRAME_BYTES } from '../src/browser/framing.js';
|
|
8
|
+
import { BROWSER_PROTOCOL_VERSION, browserRuntimeDir, sessionKeyFromRecordFile, sessionSocketPath, socketIsFile, } from '../src/browser/protocol.js';
|
|
9
|
+
const SCAN_INTERVAL_MS = 1000;
|
|
10
|
+
const NUL = String.fromCharCode(0);
|
|
11
|
+
const sessions = new Map();
|
|
12
|
+
const runtimeDir = browserRuntimeDir(process.env);
|
|
13
|
+
let extensionVersion = '';
|
|
14
|
+
let extensionProfileId = '';
|
|
15
|
+
let extensionPlatform = '';
|
|
16
|
+
function log(message) {
|
|
17
|
+
process.stderr.write(`[thegitai-browser-host] ${message}\n`);
|
|
18
|
+
}
|
|
19
|
+
function writeToExtension(frame) {
|
|
20
|
+
const payload = Buffer.from(JSON.stringify(frame), 'utf8');
|
|
21
|
+
if (payload.length > MAX_FRAME_BYTES) {
|
|
22
|
+
for (const fragment of fragmentPayload(payload, crypto.randomUUID())) {
|
|
23
|
+
writeToExtension(fragment);
|
|
24
|
+
}
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
process.stdout.write(encodeFrame(payload));
|
|
28
|
+
}
|
|
29
|
+
let inbound = Buffer.alloc(0);
|
|
30
|
+
process.stdin.on('data', (chunk) => {
|
|
31
|
+
inbound = Buffer.concat([inbound, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, 'utf8')]);
|
|
32
|
+
const { frames, rest, unparseable } = decodeFrames(inbound);
|
|
33
|
+
inbound = rest;
|
|
34
|
+
for (let i = 0; i < unparseable; i += 1) {
|
|
35
|
+
log('discarded an unparseable frame from the extension');
|
|
36
|
+
}
|
|
37
|
+
for (const frame of frames)
|
|
38
|
+
handleExtensionFrame(frame);
|
|
39
|
+
});
|
|
40
|
+
process.stdin.on('end', () => {
|
|
41
|
+
for (const link of sessions.values())
|
|
42
|
+
link.socket.destroy();
|
|
43
|
+
process.exit(0);
|
|
44
|
+
});
|
|
45
|
+
function describeParentBrowser() {
|
|
46
|
+
const cmdline = process.platform === 'win32' ? windowsParentCommandLine() : unixParentCommandLine();
|
|
47
|
+
return describeFromCommandLine(cmdline);
|
|
48
|
+
}
|
|
49
|
+
function windowsParentCommandLine() {
|
|
50
|
+
const PASS_THROUGH = /^(cmd|conhost|powershell|pwsh|node)\.exe$/i;
|
|
51
|
+
try {
|
|
52
|
+
const rows = execFileSync('powershell.exe', [
|
|
53
|
+
'-NoProfile',
|
|
54
|
+
'-NonInteractive',
|
|
55
|
+
'-Command',
|
|
56
|
+
'Get-CimInstance Win32_Process | ForEach-Object {' +
|
|
57
|
+
' "{0}`t{1}`t{2}`t{3}" -f $_.ProcessId, $_.ParentProcessId, $_.Name, $_.CommandLine }',
|
|
58
|
+
], {
|
|
59
|
+
encoding: 'utf8',
|
|
60
|
+
timeout: 15000,
|
|
61
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
62
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
63
|
+
});
|
|
64
|
+
const byPid = new Map();
|
|
65
|
+
for (const line of rows.split(/\r?\n/)) {
|
|
66
|
+
const [pid, parent, name, ...rest] = line.split('\t');
|
|
67
|
+
const id = Number(pid);
|
|
68
|
+
if (!Number.isInteger(id))
|
|
69
|
+
continue;
|
|
70
|
+
byPid.set(id, {
|
|
71
|
+
parent: Number(parent),
|
|
72
|
+
name: String(name ?? ''),
|
|
73
|
+
command: rest.join('\t'),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
let current = byPid.get(process.ppid);
|
|
77
|
+
for (let hop = 0; hop < 4 && current && PASS_THROUGH.test(current.name); hop += 1) {
|
|
78
|
+
current = byPid.get(current.parent);
|
|
79
|
+
}
|
|
80
|
+
if (!current?.command)
|
|
81
|
+
return [];
|
|
82
|
+
return (current.command.match(/"[^"]*"|\S+/g) ?? []).map((token) => token.startsWith('"') && token.endsWith('"') ? token.slice(1, -1) : token);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function unixParentCommandLine() {
|
|
89
|
+
try {
|
|
90
|
+
return fs.readFileSync(`/proc/${process.ppid}/cmdline`, 'utf8').split(NUL);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function describeFromCommandLine(cmdline) {
|
|
97
|
+
try {
|
|
98
|
+
if (cmdline.length === 0)
|
|
99
|
+
throw new Error('no command line');
|
|
100
|
+
const brand = path
|
|
101
|
+
.basename(cmdline[0] ?? '')
|
|
102
|
+
.replace(/\.exe$/i, '')
|
|
103
|
+
.replace(/[-_](browser|stable|beta|dev|unstable|bin)$/i, '')
|
|
104
|
+
.replace(/[-_]+/g, ' ')
|
|
105
|
+
.replace(/\b[a-z]/g, (letter) => letter.toUpperCase())
|
|
106
|
+
.trim() || 'Chrome';
|
|
107
|
+
const profileArg = cmdline.find((part) => part.startsWith('--profile-directory='));
|
|
108
|
+
const userDataArg = cmdline.find((part) => part.startsWith('--user-data-dir='));
|
|
109
|
+
const profile = profileArg ? profileArg.slice('--profile-directory='.length) : 'Default';
|
|
110
|
+
const suffix = userDataArg
|
|
111
|
+
? ` (${path.basename(userDataArg.slice('--user-data-dir='.length))})`
|
|
112
|
+
: '';
|
|
113
|
+
return { browser: brand, profile: `${profile}${suffix}` };
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return { browser: 'Chrome', profile: 'Default' };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const parent = describeParentBrowser();
|
|
120
|
+
function browserKey() {
|
|
121
|
+
const seed = extensionProfileId || `${parent.browser} ${parent.profile}`;
|
|
122
|
+
return crypto.createHash('sha256').update(seed).digest('hex').slice(0, 12);
|
|
123
|
+
}
|
|
124
|
+
function identity() {
|
|
125
|
+
return {
|
|
126
|
+
key: browserKey(),
|
|
127
|
+
browser: parent.browser,
|
|
128
|
+
profile: parent.profile !== 'Default' || !extensionProfileId
|
|
129
|
+
? parent.profile
|
|
130
|
+
: `profile ${extensionProfileId.slice(0, 6)}`,
|
|
131
|
+
platform: extensionPlatform || process.platform,
|
|
132
|
+
extensionVersion,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
function handleExtensionFrame(frame) {
|
|
136
|
+
if (frame?.t === 'hello') {
|
|
137
|
+
extensionVersion = String(frame.extensionVersion ?? '');
|
|
138
|
+
extensionProfileId = String(frame.profileId ?? '');
|
|
139
|
+
extensionPlatform = String(frame.platform ?? '');
|
|
140
|
+
writeToExtension({ t: 'hello', protocol: BROWSER_PROTOCOL_VERSION });
|
|
141
|
+
publishSessions();
|
|
142
|
+
for (const link of sessions.values())
|
|
143
|
+
sendHello(link);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (frame?.t === 'res' || frame?.t === 'event') {
|
|
147
|
+
const target = frame.session ? sessions.get(frame.session) : null;
|
|
148
|
+
const { session: _routed, ...rest } = frame;
|
|
149
|
+
if (target) {
|
|
150
|
+
writeToSession(target, rest);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (frame.t === 'event') {
|
|
154
|
+
for (const link of sessions.values())
|
|
155
|
+
writeToSession(link, rest);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function writeToSession(link, frame) {
|
|
160
|
+
try {
|
|
161
|
+
link.socket.write(`${JSON.stringify(frame)}\n`);
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function sendHello(link) {
|
|
167
|
+
writeToSession(link, { t: 'hello', protocol: BROWSER_PROTOCOL_VERSION, browser: identity() });
|
|
168
|
+
}
|
|
169
|
+
function publishSessions() {
|
|
170
|
+
writeToExtension({
|
|
171
|
+
t: 'sessions',
|
|
172
|
+
sessions: [...sessions.values()].map((link) => ({
|
|
173
|
+
session: link.key,
|
|
174
|
+
label: link.record.label,
|
|
175
|
+
cwd: link.record.cwd,
|
|
176
|
+
})),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
function connectToSession(record, socketPath) {
|
|
180
|
+
if (sessions.has(record.session))
|
|
181
|
+
return;
|
|
182
|
+
const socket = net.createConnection(socketPath);
|
|
183
|
+
socket.setNoDelay(true);
|
|
184
|
+
const link = { key: record.session, socket, record };
|
|
185
|
+
socket.on('connect', () => {
|
|
186
|
+
sessions.set(record.session, link);
|
|
187
|
+
sendHello(link);
|
|
188
|
+
publishSessions();
|
|
189
|
+
log(`attached to session ${record.session} (${record.cwd})`);
|
|
190
|
+
});
|
|
191
|
+
let buffer = '';
|
|
192
|
+
socket.on('data', (chunk) => {
|
|
193
|
+
buffer += chunk.toString('utf8');
|
|
194
|
+
let newline = buffer.indexOf('\n');
|
|
195
|
+
while (newline !== -1) {
|
|
196
|
+
const line = buffer.slice(0, newline);
|
|
197
|
+
buffer = buffer.slice(newline + 1);
|
|
198
|
+
newline = buffer.indexOf('\n');
|
|
199
|
+
if (!line.trim())
|
|
200
|
+
continue;
|
|
201
|
+
let frame;
|
|
202
|
+
try {
|
|
203
|
+
frame = JSON.parse(line);
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
writeToExtension({ ...frame, session: record.session });
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
const drop = () => {
|
|
212
|
+
if (sessions.get(record.session) === link) {
|
|
213
|
+
sessions.delete(record.session);
|
|
214
|
+
publishSessions();
|
|
215
|
+
}
|
|
216
|
+
socket.destroy();
|
|
217
|
+
};
|
|
218
|
+
socket.on('close', drop);
|
|
219
|
+
socket.on('error', () => {
|
|
220
|
+
drop();
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
function scanForSessions() {
|
|
224
|
+
let entries;
|
|
225
|
+
try {
|
|
226
|
+
entries = fs.readdirSync(runtimeDir);
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
for (const entry of entries) {
|
|
232
|
+
const sessionKey = sessionKeyFromRecordFile(entry);
|
|
233
|
+
if (!sessionKey)
|
|
234
|
+
continue;
|
|
235
|
+
const recordPath = path.join(runtimeDir, entry);
|
|
236
|
+
let record;
|
|
237
|
+
try {
|
|
238
|
+
record = JSON.parse(fs.readFileSync(recordPath, 'utf8'));
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (record.protocol !== BROWSER_PROTOCOL_VERSION)
|
|
244
|
+
continue;
|
|
245
|
+
try {
|
|
246
|
+
process.kill(record.pid, 0);
|
|
247
|
+
}
|
|
248
|
+
catch (error) {
|
|
249
|
+
if (error?.code !== 'EPERM') {
|
|
250
|
+
try {
|
|
251
|
+
fs.unlinkSync(recordPath);
|
|
252
|
+
if (socketIsFile())
|
|
253
|
+
fs.unlinkSync(sessionSocketPath(runtimeDir, sessionKey));
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
}
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
connectToSession(record, sessionSocketPath(runtimeDir, sessionKey));
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
setInterval(scanForSessions, SCAN_INTERVAL_MS);
|
|
264
|
+
scanForSessions();
|
|
265
|
+
log(`started for ${parent.browser} / ${parent.profile}`);
|
package/dist/src/agent-mode.js
CHANGED
|
@@ -9,6 +9,16 @@ const PLAN_MODE_TOOL_NAMES = new Set([
|
|
|
9
9
|
'run_command',
|
|
10
10
|
'shell_job_output',
|
|
11
11
|
'update_todos',
|
|
12
|
+
'browser',
|
|
13
|
+
'browser_tabs',
|
|
14
|
+
'browser_read_page',
|
|
15
|
+
'browser_get_page_text',
|
|
16
|
+
'browser_find',
|
|
17
|
+
'browser_console',
|
|
18
|
+
'browser_downloads',
|
|
19
|
+
'browser_network',
|
|
20
|
+
'browser_screenshot',
|
|
21
|
+
'browser_batch',
|
|
12
22
|
]);
|
|
13
23
|
const PLAN_MODE_RUN_COMMAND_NAMES = new Set([
|
|
14
24
|
'pwd',
|
package/dist/src/api/chat.js
CHANGED
|
@@ -10,6 +10,7 @@ import { collectProjectOrientation } from '../project-orientation.js';
|
|
|
10
10
|
import { describeSessionImageStore } from '../core/session-image-store.js';
|
|
11
11
|
import { autoAttachImages } from '../core/image-path-extractor.js';
|
|
12
12
|
import { formatTurnFailureMarker } from '../turn-failure-marker.js';
|
|
13
|
+
import { browserExtensionSeen, releaseBrowserAfterTurn } from '../browser/session-bridge.js';
|
|
13
14
|
export class TurnCancelledError extends Error {
|
|
14
15
|
name = 'TurnCancelledError';
|
|
15
16
|
constructor(message = 'Turn cancelled.') {
|
|
@@ -549,7 +550,9 @@ export async function sendServerUserMessage({ config, session, input, imageAttac
|
|
|
549
550
|
projectOrientation: collectProjectOrientation(session.rootDir) ?? undefined,
|
|
550
551
|
sessionImageStore: describeSessionImageStore(session.env) ?? undefined,
|
|
551
552
|
imageAttachments: imageAttachmentsForServer(requestImageAttachments),
|
|
552
|
-
|
|
553
|
+
...(session.maxToolStepsWasExplicit || !browserExtensionSeen(session.env)
|
|
554
|
+
? { maxToolSteps: session.maxToolSteps }
|
|
555
|
+
: {}),
|
|
553
556
|
autoYes: session.autoYes,
|
|
554
557
|
agentMode: session.agentMode,
|
|
555
558
|
};
|
|
@@ -623,5 +626,6 @@ export async function sendServerUserMessage({ config, session, input, imageAttac
|
|
|
623
626
|
}
|
|
624
627
|
finally {
|
|
625
628
|
signal?.removeEventListener('abort', preserveOnAbort);
|
|
629
|
+
releaseBrowserAfterTurn();
|
|
626
630
|
}
|
|
627
631
|
}
|
package/dist/src/api/models.js
CHANGED
|
@@ -122,6 +122,16 @@ export function validateServerModel(modelId, serverModels) {
|
|
|
122
122
|
}
|
|
123
123
|
return requested;
|
|
124
124
|
}
|
|
125
|
+
export function resolveResumedSessionModel(storedModelId, serverModels) {
|
|
126
|
+
if (serverModels.models.some((model) => model.id === storedModelId)) {
|
|
127
|
+
return { modelId: storedModelId, fellBack: false };
|
|
128
|
+
}
|
|
129
|
+
const fallbackId = serverModels.models[0]?.id;
|
|
130
|
+
return {
|
|
131
|
+
modelId: fallbackId ?? storedModelId,
|
|
132
|
+
fellBack: fallbackId != null,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
125
135
|
export function updateSelectedModelCache({ config, selectedModelId, serverModels, env = process.env, }) {
|
|
126
136
|
writeCachedServerModels({
|
|
127
137
|
serverUrl: normalizeServerUrl(config.serverUrl),
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import net from 'node:net';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { registerNativeHost } from './native-host.js';
|
|
6
|
+
import { BROWSER_PROTOCOL_VERSION, browserRuntimeDir, protocolMismatchMessage, sessionKeyFromRecordFile, sessionRecordPath, sessionSocketPath, socketIsFile, } from './protocol.js';
|
|
7
|
+
const DEFAULT_TIMEOUT_MS = 30000;
|
|
8
|
+
export class BrowserBridgeError extends Error {
|
|
9
|
+
code;
|
|
10
|
+
constructor(message, code = 'browser_error') {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = 'BrowserBridgeError';
|
|
13
|
+
this.code = code;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function safeUnlink(target) {
|
|
17
|
+
try {
|
|
18
|
+
fs.unlinkSync(target);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function startBrowserBridge({ sessionKey, label, cwd, env = process.env, }) {
|
|
24
|
+
const dir = browserRuntimeDir(env);
|
|
25
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
26
|
+
const socketPath = sessionSocketPath(dir, sessionKey);
|
|
27
|
+
const recordPath = sessionRecordPath(dir, sessionKey);
|
|
28
|
+
if (socketIsFile())
|
|
29
|
+
safeUnlink(socketPath);
|
|
30
|
+
const browsers = new Map();
|
|
31
|
+
const listeners = new Set();
|
|
32
|
+
let selected = null;
|
|
33
|
+
let closed = false;
|
|
34
|
+
const registration = registerNativeHost({ env });
|
|
35
|
+
const server = net.createServer((socket) => {
|
|
36
|
+
socket.setNoDelay(true);
|
|
37
|
+
let buffer = '';
|
|
38
|
+
let key = null;
|
|
39
|
+
socket.on('data', (chunk) => {
|
|
40
|
+
buffer += chunk.toString('utf8');
|
|
41
|
+
let newline = buffer.indexOf('\n');
|
|
42
|
+
while (newline !== -1) {
|
|
43
|
+
const line = buffer.slice(0, newline);
|
|
44
|
+
buffer = buffer.slice(newline + 1);
|
|
45
|
+
newline = buffer.indexOf('\n');
|
|
46
|
+
if (!line.trim())
|
|
47
|
+
continue;
|
|
48
|
+
let frame;
|
|
49
|
+
try {
|
|
50
|
+
frame = JSON.parse(line);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (frame.t === 'hello') {
|
|
56
|
+
if (frame.protocol !== BROWSER_PROTOCOL_VERSION) {
|
|
57
|
+
const message = protocolMismatchMessage(frame.protocol);
|
|
58
|
+
for (const listener of listeners)
|
|
59
|
+
listener('protocol_mismatch', { message });
|
|
60
|
+
socket.destroy();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const announced = frame.browser?.key ?? crypto.randomUUID();
|
|
64
|
+
const existing = key ? browsers.get(key) : null;
|
|
65
|
+
if (existing) {
|
|
66
|
+
if (announced !== key) {
|
|
67
|
+
browsers.delete(key);
|
|
68
|
+
browsers.set(announced, existing);
|
|
69
|
+
if (selected === key)
|
|
70
|
+
selected = announced;
|
|
71
|
+
}
|
|
72
|
+
existing.identity = frame.browser;
|
|
73
|
+
existing.socket = socket;
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
browsers.set(announced, {
|
|
77
|
+
identity: frame.browser,
|
|
78
|
+
socket,
|
|
79
|
+
pending: new Map(),
|
|
80
|
+
});
|
|
81
|
+
if (!selected)
|
|
82
|
+
selected = announced;
|
|
83
|
+
}
|
|
84
|
+
key = announced;
|
|
85
|
+
for (const listener of listeners) {
|
|
86
|
+
listener('connected', { browser: frame.browser });
|
|
87
|
+
}
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const browser = key ? browsers.get(key) : null;
|
|
91
|
+
if (!browser)
|
|
92
|
+
continue;
|
|
93
|
+
if (frame.t === 'res') {
|
|
94
|
+
const pending = browser.pending.get(frame.id);
|
|
95
|
+
if (!pending)
|
|
96
|
+
continue;
|
|
97
|
+
browser.pending.delete(frame.id);
|
|
98
|
+
clearTimeout(pending.timer);
|
|
99
|
+
if (frame.ok)
|
|
100
|
+
pending.resolve(frame.result);
|
|
101
|
+
else
|
|
102
|
+
pending.reject(new BrowserBridgeError(frame.error ?? 'Browser operation failed.', frame.code ?? 'browser_error'));
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (frame.t === 'event') {
|
|
106
|
+
for (const listener of listeners)
|
|
107
|
+
listener(frame.event, frame.data ?? {});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
const drop = () => {
|
|
112
|
+
if (!key)
|
|
113
|
+
return;
|
|
114
|
+
const browser = browsers.get(key);
|
|
115
|
+
browsers.delete(key);
|
|
116
|
+
if (selected === key)
|
|
117
|
+
selected = browsers.keys().next().value ?? null;
|
|
118
|
+
for (const pending of browser?.pending.values() ?? []) {
|
|
119
|
+
clearTimeout(pending.timer);
|
|
120
|
+
pending.reject(new BrowserBridgeError(`The browser disconnected while ${pending.op} was in flight, so its outcome is unknown. Check the page before doing anything that could repeat.`, 'disconnected'));
|
|
121
|
+
}
|
|
122
|
+
for (const listener of listeners)
|
|
123
|
+
listener('disconnected', { browser: key });
|
|
124
|
+
};
|
|
125
|
+
socket.on('close', drop);
|
|
126
|
+
socket.on('error', drop);
|
|
127
|
+
});
|
|
128
|
+
server.listen(socketPath, () => {
|
|
129
|
+
try {
|
|
130
|
+
fs.chmodSync(socketPath, 0o600);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
}
|
|
134
|
+
const announcement = {
|
|
135
|
+
protocol: BROWSER_PROTOCOL_VERSION,
|
|
136
|
+
session: sessionKey,
|
|
137
|
+
pid: process.pid,
|
|
138
|
+
label,
|
|
139
|
+
cwd,
|
|
140
|
+
startedAt: new Date().toISOString(),
|
|
141
|
+
};
|
|
142
|
+
try {
|
|
143
|
+
fs.writeFileSync(recordPath, `${JSON.stringify(announcement)}\n`, { mode: 0o600 });
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
server.on('error', () => {
|
|
149
|
+
});
|
|
150
|
+
const close = () => {
|
|
151
|
+
if (closed)
|
|
152
|
+
return;
|
|
153
|
+
closed = true;
|
|
154
|
+
for (const browser of browsers.values())
|
|
155
|
+
browser.socket.destroy();
|
|
156
|
+
browsers.clear();
|
|
157
|
+
server.close();
|
|
158
|
+
safeUnlink(socketPath);
|
|
159
|
+
safeUnlink(recordPath);
|
|
160
|
+
};
|
|
161
|
+
process.once('exit', close);
|
|
162
|
+
return {
|
|
163
|
+
sessionKey,
|
|
164
|
+
status() {
|
|
165
|
+
return {
|
|
166
|
+
connected: browsers.size > 0,
|
|
167
|
+
browsers: [...browsers.values()].map((browser) => browser.identity),
|
|
168
|
+
selected,
|
|
169
|
+
socketPath,
|
|
170
|
+
registrationErrors: registration.errors,
|
|
171
|
+
};
|
|
172
|
+
},
|
|
173
|
+
selectBrowser(key) {
|
|
174
|
+
if (browsers.has(key))
|
|
175
|
+
selected = key;
|
|
176
|
+
},
|
|
177
|
+
onEvent(listener) {
|
|
178
|
+
listeners.add(listener);
|
|
179
|
+
return () => listeners.delete(listener);
|
|
180
|
+
},
|
|
181
|
+
request(op, args = {}, options = {}) {
|
|
182
|
+
const target = selected ? browsers.get(selected) : null;
|
|
183
|
+
if (!target) {
|
|
184
|
+
return Promise.reject(new BrowserBridgeError('No browser is connected to this session.', 'not_connected'));
|
|
185
|
+
}
|
|
186
|
+
const id = crypto.randomUUID();
|
|
187
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
188
|
+
return new Promise((resolve, reject) => {
|
|
189
|
+
const timer = setTimeout(() => {
|
|
190
|
+
target.pending.delete(id);
|
|
191
|
+
reject(new BrowserBridgeError(`The browser did not answer ${op} within ${Math.round(timeoutMs / 1000)}s, so its outcome is unknown. Look at the page before repeating anything that changes state.`, 'timeout'));
|
|
192
|
+
}, timeoutMs);
|
|
193
|
+
target.pending.set(id, { resolve, reject, timer, op });
|
|
194
|
+
target.socket.write(`${JSON.stringify({ t: 'req', id, op, args })}\n`, (error) => {
|
|
195
|
+
if (!error)
|
|
196
|
+
return;
|
|
197
|
+
target.pending.delete(id);
|
|
198
|
+
clearTimeout(timer);
|
|
199
|
+
reject(new BrowserBridgeError(`Could not reach the browser: ${error.message}`, 'write_failed'));
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
},
|
|
203
|
+
close,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
export function pruneStaleSessions(env = process.env) {
|
|
207
|
+
const dir = browserRuntimeDir(env);
|
|
208
|
+
let entries;
|
|
209
|
+
try {
|
|
210
|
+
entries = fs.readdirSync(dir);
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
for (const entry of entries) {
|
|
216
|
+
const key = sessionKeyFromRecordFile(entry);
|
|
217
|
+
if (!key)
|
|
218
|
+
continue;
|
|
219
|
+
const file = path.join(dir, entry);
|
|
220
|
+
try {
|
|
221
|
+
const record = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
222
|
+
process.kill(record.pid, 0);
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
if (error?.code === 'EPERM')
|
|
226
|
+
continue;
|
|
227
|
+
safeUnlink(file);
|
|
228
|
+
if (socketIsFile())
|
|
229
|
+
safeUnlink(sessionSocketPath(dir, key));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export const MAX_FRAME_BYTES = 900 * 1024;
|
|
2
|
+
export const FRAGMENT_BYTES = 512 * 1024;
|
|
3
|
+
export function encodeFrame(payload) {
|
|
4
|
+
const header = Buffer.alloc(4);
|
|
5
|
+
header.writeUInt32LE(payload.length, 0);
|
|
6
|
+
return Buffer.concat([header, payload]);
|
|
7
|
+
}
|
|
8
|
+
export function fragmentPayload(payload, id) {
|
|
9
|
+
const total = Math.ceil(payload.length / FRAGMENT_BYTES);
|
|
10
|
+
const frames = [];
|
|
11
|
+
for (let part = 0; part < total; part += 1) {
|
|
12
|
+
frames.push({
|
|
13
|
+
t: 'fragment',
|
|
14
|
+
id,
|
|
15
|
+
part,
|
|
16
|
+
total,
|
|
17
|
+
data: payload.subarray(part * FRAGMENT_BYTES, (part + 1) * FRAGMENT_BYTES).toString('base64'),
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
return frames;
|
|
21
|
+
}
|
|
22
|
+
export function decodeFrames(inbound) {
|
|
23
|
+
const frames = [];
|
|
24
|
+
let rest = inbound;
|
|
25
|
+
let unparseable = 0;
|
|
26
|
+
for (;;) {
|
|
27
|
+
if (rest.length < 4)
|
|
28
|
+
break;
|
|
29
|
+
const length = rest.readUInt32LE(0);
|
|
30
|
+
if (rest.length < 4 + length)
|
|
31
|
+
break;
|
|
32
|
+
const body = rest.subarray(4, 4 + length);
|
|
33
|
+
rest = rest.subarray(4 + length);
|
|
34
|
+
try {
|
|
35
|
+
frames.push(JSON.parse(body.toString('utf8')));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
unparseable += 1;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { frames, rest, unparseable };
|
|
42
|
+
}
|