@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
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { normalizeProjectRelativePath } from '../artifact-policy.js';
|
|
4
|
+
import { BrowserBridgeError } from '../browser/bridge.js';
|
|
5
|
+
import { storeSessionImageBytes } from '../core/session-image-store.js';
|
|
6
|
+
import { browserAvailability, drainBrowserEvents, ensureBrowserBridge, } from '../browser/session-bridge.js';
|
|
7
|
+
import { ensurePermission, originForUrl } from '../permissions.js';
|
|
8
|
+
const READ_TIMEOUT_MS = 20000;
|
|
9
|
+
const NAVIGATE_TIMEOUT_MS = 45000;
|
|
10
|
+
const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
|
|
11
|
+
export const READ_ONLY_BROWSER_TOOLS = new Set([
|
|
12
|
+
'browser',
|
|
13
|
+
'browser_tabs',
|
|
14
|
+
'browser_read_page',
|
|
15
|
+
'browser_get_page_text',
|
|
16
|
+
'browser_find',
|
|
17
|
+
'browser_console',
|
|
18
|
+
'browser_network',
|
|
19
|
+
'browser_screenshot',
|
|
20
|
+
]);
|
|
21
|
+
function missing(argument, tool) {
|
|
22
|
+
return {
|
|
23
|
+
ok: false,
|
|
24
|
+
error: `${argument} is required.`,
|
|
25
|
+
failureCategory: 'missing_required_argument',
|
|
26
|
+
failureDetails: {
|
|
27
|
+
category: 'missing_required_argument',
|
|
28
|
+
tool,
|
|
29
|
+
missing: [argument],
|
|
30
|
+
action: `Call ${tool} again with ${argument}.`,
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function notConnected(context) {
|
|
35
|
+
const availability = browserAvailability({
|
|
36
|
+
cwd: context.rootDir,
|
|
37
|
+
env: context.env,
|
|
38
|
+
});
|
|
39
|
+
if (availability.connected)
|
|
40
|
+
return { ok: true };
|
|
41
|
+
return {
|
|
42
|
+
ok: false,
|
|
43
|
+
error: `TheGitAI is not connected to a browser. ${availability.hint ?? ''}`.trim(),
|
|
44
|
+
failureCategory: 'external_service',
|
|
45
|
+
failureDetails: {
|
|
46
|
+
category: 'external_service',
|
|
47
|
+
tool: 'browser',
|
|
48
|
+
action: 'Report the connection instructions. The extension is a separate installation. Do not retry in a loop or invent a download URL.',
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function bridgeFailure(error, tool) {
|
|
53
|
+
const code = error instanceof BrowserBridgeError ? error.code : 'browser_error';
|
|
54
|
+
const message = String(error?.message ?? error);
|
|
55
|
+
return {
|
|
56
|
+
ok: false,
|
|
57
|
+
error: message,
|
|
58
|
+
failureCategory: code === 'not_connected' ? 'external_service' : 'tool_exception',
|
|
59
|
+
browserErrorCode: code,
|
|
60
|
+
failureDetails: {
|
|
61
|
+
category: code === 'not_connected' ? 'external_service' : 'tool_exception',
|
|
62
|
+
tool,
|
|
63
|
+
action: code === 'timeout' || code === 'disconnected'
|
|
64
|
+
? 'The outcome is unknown. Read the page before repeating anything that submits, sends, buys, or deletes.'
|
|
65
|
+
: code === 'stale_refs' || code === 'unknown_ref'
|
|
66
|
+
? 'Re-read the page and use the references from that read.'
|
|
67
|
+
: 'Report what failed. Do not retry the same call more than once.',
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
async function call(context, op, args = {}, timeoutMs = READ_TIMEOUT_MS) {
|
|
72
|
+
const bridge = ensureBrowserBridge({ cwd: context.rootDir, env: context.env });
|
|
73
|
+
return bridge.request(op, args, { timeoutMs });
|
|
74
|
+
}
|
|
75
|
+
async function currentTab(context, tabId) {
|
|
76
|
+
const result = await call(context, 'context', { includeAll: true });
|
|
77
|
+
const tabs = Array.isArray(result?.tabs) ? result.tabs : [];
|
|
78
|
+
return tabs.find((tab) => Number(tab.tabId) === tabId) ?? null;
|
|
79
|
+
}
|
|
80
|
+
async function ensureSiteAllowed(context, { url, tool, summary, }) {
|
|
81
|
+
const origin = originForUrl(String(url ?? ''));
|
|
82
|
+
if (!origin) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
return ensurePermission({
|
|
86
|
+
autoYes: context.autoYes,
|
|
87
|
+
grants: context.grants,
|
|
88
|
+
requestPermission: context.requestPermission,
|
|
89
|
+
}, {
|
|
90
|
+
bucket: 'browse',
|
|
91
|
+
origin,
|
|
92
|
+
title: `TheGitAI wants to use ${origin}`,
|
|
93
|
+
body: summary,
|
|
94
|
+
}, tool);
|
|
95
|
+
}
|
|
96
|
+
function attachEvents(result) {
|
|
97
|
+
const events = drainBrowserEvents();
|
|
98
|
+
if (!events.length)
|
|
99
|
+
return result;
|
|
100
|
+
const notable = events.filter((entry) => entry.event === 'detached' || entry.event === 'stopped' || entry.event === 'group_removed');
|
|
101
|
+
if (!notable.length)
|
|
102
|
+
return result;
|
|
103
|
+
return {
|
|
104
|
+
...result,
|
|
105
|
+
browserNotices: notable.map((entry) => String(entry.data?.message ?? `Browser ${entry.event}.`)),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
export async function browserGateway(context) {
|
|
109
|
+
const availability = browserAvailability({ cwd: context.rootDir, env: context.env });
|
|
110
|
+
if (!availability.connected) {
|
|
111
|
+
return notConnected(context);
|
|
112
|
+
}
|
|
113
|
+
const platform = availability.browsers[0]?.platform ?? process.platform;
|
|
114
|
+
return {
|
|
115
|
+
ok: true,
|
|
116
|
+
connected: true,
|
|
117
|
+
browsers: availability.browsers.map((browser) => ({
|
|
118
|
+
key: browser.key,
|
|
119
|
+
name: `${browser.browser} — ${browser.profile}`,
|
|
120
|
+
extensionVersion: browser.extensionVersion,
|
|
121
|
+
})),
|
|
122
|
+
selected: availability.selected,
|
|
123
|
+
shortcutModifier: platform === 'darwin' ? 'cmd' : 'ctrl',
|
|
124
|
+
platform,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
export async function browserTabs(context, args) {
|
|
128
|
+
const guard = notConnected(context);
|
|
129
|
+
if (guard.ok === false)
|
|
130
|
+
return guard;
|
|
131
|
+
try {
|
|
132
|
+
const result = await call(context, 'context', {
|
|
133
|
+
includeAll: args.include_all === true || args.includeAll === true,
|
|
134
|
+
});
|
|
135
|
+
return attachEvents({ ok: true, ...result });
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
return bridgeFailure(error, 'browser_tabs');
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
export async function browserTabNew(context) {
|
|
142
|
+
const guard = notConnected(context);
|
|
143
|
+
if (guard.ok === false)
|
|
144
|
+
return guard;
|
|
145
|
+
try {
|
|
146
|
+
const result = await call(context, 'tab.create', {});
|
|
147
|
+
return attachEvents({ ok: true, ...result });
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
return bridgeFailure(error, 'browser_tab_new');
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
export async function browserTabClose(context, args) {
|
|
154
|
+
const guard = notConnected(context);
|
|
155
|
+
if (guard.ok === false)
|
|
156
|
+
return guard;
|
|
157
|
+
const tabId = Number(args.tabId ?? args.tab_id);
|
|
158
|
+
if (!Number.isInteger(tabId))
|
|
159
|
+
return missing('tabId', 'browser_tab_close');
|
|
160
|
+
try {
|
|
161
|
+
const result = await call(context, 'tab.close', { tabId });
|
|
162
|
+
return attachEvents({ ok: true, ...result });
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
return bridgeFailure(error, 'browser_tab_close');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
export async function browserNavigate(context, args) {
|
|
169
|
+
const guard = notConnected(context);
|
|
170
|
+
if (guard.ok === false)
|
|
171
|
+
return guard;
|
|
172
|
+
const url = String(args.url ?? '').trim();
|
|
173
|
+
if (!url)
|
|
174
|
+
return missing('url', 'browser_navigate');
|
|
175
|
+
let tabId = Number(args.tabId ?? args.tab_id);
|
|
176
|
+
try {
|
|
177
|
+
if (!Number.isInteger(tabId)) {
|
|
178
|
+
const context_ = await call(context, 'context');
|
|
179
|
+
tabId = Number(context_?.tabs?.find((tab) => tab.inSession && tab.available)?.tabId);
|
|
180
|
+
if (!Number.isInteger(tabId)) {
|
|
181
|
+
const created = await call(context, 'tab.create', {});
|
|
182
|
+
tabId = Number(created.tabId);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (url !== 'back' && url !== 'forward') {
|
|
186
|
+
const denied = await ensureSiteAllowed(context, {
|
|
187
|
+
url,
|
|
188
|
+
tool: 'browser_navigate',
|
|
189
|
+
summary: `Open ${url} in TheGitAI's tab group.`,
|
|
190
|
+
});
|
|
191
|
+
if (denied)
|
|
192
|
+
return denied;
|
|
193
|
+
}
|
|
194
|
+
const result = await call(context, 'navigate', { tabId, url }, NAVIGATE_TIMEOUT_MS);
|
|
195
|
+
return attachEvents({ ok: true, ...result });
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
return bridgeFailure(error, 'browser_navigate');
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
async function gatedTabCall(context, { tool, op, args, summary, timeoutMs = READ_TIMEOUT_MS, }) {
|
|
202
|
+
const guard = notConnected(context);
|
|
203
|
+
if (guard.ok === false)
|
|
204
|
+
return guard;
|
|
205
|
+
const tabId = Number(args.tabId ?? args.tab_id);
|
|
206
|
+
if (!Number.isInteger(tabId))
|
|
207
|
+
return missing('tabId', tool);
|
|
208
|
+
try {
|
|
209
|
+
const tab = await currentTab(context, tabId);
|
|
210
|
+
if (!tab) {
|
|
211
|
+
return {
|
|
212
|
+
ok: false,
|
|
213
|
+
error: `Tab ${tabId} is no longer available. Call browser_tabs for the current list.`,
|
|
214
|
+
failureCategory: 'not_found',
|
|
215
|
+
failureDetails: {
|
|
216
|
+
category: 'not_found',
|
|
217
|
+
tool,
|
|
218
|
+
action: 'List the session tabs and use an id from that list.',
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
const denied = await ensureSiteAllowed(context, {
|
|
223
|
+
url: tab.url,
|
|
224
|
+
tool,
|
|
225
|
+
summary: summary(tab.url),
|
|
226
|
+
});
|
|
227
|
+
if (denied)
|
|
228
|
+
return denied;
|
|
229
|
+
const result = await call(context, op, {
|
|
230
|
+
...args, tabId, expectedOrigin: new URL(tab.url || 'about:blank').origin,
|
|
231
|
+
}, timeoutMs);
|
|
232
|
+
return attachEvents({ ok: true, tabId, url: tab.url, ...result });
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
return bridgeFailure(error, tool);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
export function browserReadPage(context, args) {
|
|
239
|
+
return gatedTabCall(context, {
|
|
240
|
+
tool: 'browser_read_page',
|
|
241
|
+
op: 'read_page',
|
|
242
|
+
args: {
|
|
243
|
+
tabId: args.tabId ?? args.tab_id,
|
|
244
|
+
filter: args.filter === 'interactive' ? 'interactive' : 'all',
|
|
245
|
+
depth: Number(args.depth ?? 15),
|
|
246
|
+
maxChars: Number(args.max_chars ?? args.maxChars ?? 50000),
|
|
247
|
+
},
|
|
248
|
+
summary: (url) => `Read the accessibility tree of ${url}.`,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
export function browserGetPageText(context, args) {
|
|
252
|
+
return gatedTabCall(context, {
|
|
253
|
+
tool: 'browser_get_page_text',
|
|
254
|
+
op: 'page_text',
|
|
255
|
+
args: { tabId: args.tabId ?? args.tab_id },
|
|
256
|
+
summary: (url) => `Read the text of ${url}.`,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
export function browserFind(context, args) {
|
|
260
|
+
const query = String(args.query ?? '').trim();
|
|
261
|
+
if (!query)
|
|
262
|
+
return Promise.resolve(missing('query', 'browser_find'));
|
|
263
|
+
return gatedTabCall(context, {
|
|
264
|
+
tool: 'browser_find',
|
|
265
|
+
op: 'find_candidates',
|
|
266
|
+
args: { tabId: args.tabId ?? args.tab_id },
|
|
267
|
+
summary: (url) => `Look for "${query}" on ${url}.`,
|
|
268
|
+
}).then((result) => (result.ok ? { ...result, query } : result));
|
|
269
|
+
}
|
|
270
|
+
export async function browserComputer(context, args) {
|
|
271
|
+
const action = String(args.action ?? '').trim();
|
|
272
|
+
if (!action)
|
|
273
|
+
return missing('action', 'browser_computer');
|
|
274
|
+
const result = await gatedTabCall(context, {
|
|
275
|
+
tool: 'browser_computer',
|
|
276
|
+
op: 'computer',
|
|
277
|
+
args: { ...args, tabId: args.tabId ?? args.tab_id, action },
|
|
278
|
+
summary: (url) => `${describeComputerAction(action, args)} on ${url}.`,
|
|
279
|
+
timeoutMs: action === 'wait' ? 30000 : READ_TIMEOUT_MS,
|
|
280
|
+
});
|
|
281
|
+
if (!result.ok || typeof result.base64Data !== 'string')
|
|
282
|
+
return result;
|
|
283
|
+
return reshapeScreenshot(context, result, args);
|
|
284
|
+
}
|
|
285
|
+
function reshapeScreenshot(context, result, args) {
|
|
286
|
+
const { base64Data, mimeType, geometry, ...rest } = result;
|
|
287
|
+
const payload = {
|
|
288
|
+
...rest,
|
|
289
|
+
viewport: geometry,
|
|
290
|
+
coordinateSpace: geometry?.scale === 1 && !geometry?.offsetX && !geometry?.offsetY
|
|
291
|
+
? 'Image pixels are viewport CSS pixels. Use coordinates directly.'
|
|
292
|
+
: 'Convert image pixels to viewport CSS coordinates: x = imageX / viewport.scale + viewport.offsetX; y = imageY / viewport.scale + viewport.offsetY.',
|
|
293
|
+
imageBytes: { base64Data, mimeType: mimeType ?? 'image/png' },
|
|
294
|
+
};
|
|
295
|
+
if (args.save_to_disk === true || args.saveToDisk === true) {
|
|
296
|
+
const sessionId = context.sessionId;
|
|
297
|
+
if (sessionId) {
|
|
298
|
+
try {
|
|
299
|
+
const stored = storeSessionImageBytes({
|
|
300
|
+
sessionId,
|
|
301
|
+
base64Data,
|
|
302
|
+
mimeType: 'image/png',
|
|
303
|
+
env: context.env,
|
|
304
|
+
});
|
|
305
|
+
payload.savedPath = stored.cachePath;
|
|
306
|
+
payload.imageBytes.cachePath = stored.cachePath;
|
|
307
|
+
payload.imageBytes.index = stored.index;
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
payload.saveError = String(error?.message ?? error);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return payload;
|
|
315
|
+
}
|
|
316
|
+
function describeComputerAction(action, args) {
|
|
317
|
+
if (action === 'type')
|
|
318
|
+
return `Type ${JSON.stringify(String(args.text ?? '').slice(0, 60))}`;
|
|
319
|
+
if (action === 'key')
|
|
320
|
+
return `Press ${String(args.text ?? '')}`;
|
|
321
|
+
if (action === 'screenshot')
|
|
322
|
+
return 'Take a screenshot';
|
|
323
|
+
if (action === 'zoom')
|
|
324
|
+
return 'Take a close-up screenshot';
|
|
325
|
+
if (action === 'scroll')
|
|
326
|
+
return `Scroll ${String(args.scroll_direction ?? '')}`;
|
|
327
|
+
if (action === 'hover')
|
|
328
|
+
return 'Hover';
|
|
329
|
+
return `${action.replace(/_/g, ' ')}`;
|
|
330
|
+
}
|
|
331
|
+
export function browserFormInput(context, args) {
|
|
332
|
+
const ref = String(args.ref ?? '').trim();
|
|
333
|
+
if (!ref)
|
|
334
|
+
return Promise.resolve(missing('ref', 'browser_form_input'));
|
|
335
|
+
if (args.value === undefined) {
|
|
336
|
+
return Promise.resolve(missing('value', 'browser_form_input'));
|
|
337
|
+
}
|
|
338
|
+
return gatedTabCall(context, {
|
|
339
|
+
tool: 'browser_form_input',
|
|
340
|
+
op: 'form_input',
|
|
341
|
+
args: { tabId: args.tabId ?? args.tab_id, ref, value: args.value },
|
|
342
|
+
summary: (url) => `Set ${ref} on ${url}.`,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
export function browserEvaluate(context, args) {
|
|
346
|
+
const text = String(args.text ?? args.code ?? '').trim();
|
|
347
|
+
if (!text)
|
|
348
|
+
return Promise.resolve(missing('text', 'browser_evaluate'));
|
|
349
|
+
return gatedTabCall(context, {
|
|
350
|
+
tool: 'browser_evaluate',
|
|
351
|
+
op: 'evaluate',
|
|
352
|
+
args: { tabId: args.tabId ?? args.tab_id, text },
|
|
353
|
+
summary: (url) => `Run JavaScript on ${url}.`,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
export function browserConsole(context, args) {
|
|
357
|
+
return gatedTabCall(context, {
|
|
358
|
+
tool: 'browser_console',
|
|
359
|
+
op: 'console',
|
|
360
|
+
args: {
|
|
361
|
+
tabId: args.tabId ?? args.tab_id,
|
|
362
|
+
pattern: args.pattern,
|
|
363
|
+
onlyErrors: args.onlyErrors === true || args.only_errors === true,
|
|
364
|
+
limit: Number(args.limit ?? 100),
|
|
365
|
+
clear: args.clear === true,
|
|
366
|
+
},
|
|
367
|
+
summary: (url) => `Read console output from ${url}.`,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
export function browserNetwork(context, args) {
|
|
371
|
+
return gatedTabCall(context, {
|
|
372
|
+
tool: 'browser_network',
|
|
373
|
+
op: 'network',
|
|
374
|
+
args: {
|
|
375
|
+
tabId: args.tabId ?? args.tab_id,
|
|
376
|
+
urlPattern: args.urlPattern ?? args.url_pattern,
|
|
377
|
+
limit: Number(args.limit ?? 100),
|
|
378
|
+
clear: args.clear === true,
|
|
379
|
+
},
|
|
380
|
+
summary: (url) => `Read network requests from ${url}.`,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
export async function browserDownloads(context, args) {
|
|
384
|
+
const action = String(args.action ?? 'list').trim();
|
|
385
|
+
if (context.agentMode === 'plan' && action !== 'list') {
|
|
386
|
+
return {
|
|
387
|
+
ok: false,
|
|
388
|
+
failureCategory: 'policy_blocked',
|
|
389
|
+
error: `Plan mode blocked browser_downloads ${action}. Listing downloads is allowed; saving or cancelling one changes the user's disk. Switch to Default mode to decide on a download.`,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
const guard = notConnected(context);
|
|
393
|
+
if (guard.ok === false)
|
|
394
|
+
return guard;
|
|
395
|
+
if (action === 'list') {
|
|
396
|
+
const result = await call(context, 'downloads');
|
|
397
|
+
const downloads = Array.isArray(result?.downloads) ? result.downloads : [];
|
|
398
|
+
return {
|
|
399
|
+
ok: true,
|
|
400
|
+
downloads,
|
|
401
|
+
...(downloads.length
|
|
402
|
+
? {
|
|
403
|
+
instructions: 'Each of these is paused and nothing is on disk yet. Tell the user the filename, the size and the site it came from, and only call accept after they say yes.',
|
|
404
|
+
}
|
|
405
|
+
: {}),
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
if (action !== 'accept' && action !== 'cancel') {
|
|
409
|
+
return {
|
|
410
|
+
ok: false,
|
|
411
|
+
error: 'browser_downloads action must be "list", "accept" or "cancel".',
|
|
412
|
+
failureCategory: 'invalid_argument',
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
const downloadId = Number(args.downloadId ?? args.download_id);
|
|
416
|
+
if (!Number.isInteger(downloadId))
|
|
417
|
+
return missing('downloadId', 'browser_downloads');
|
|
418
|
+
if (action === 'accept') {
|
|
419
|
+
const listed = await call(context, 'downloads');
|
|
420
|
+
const record = (Array.isArray(listed?.downloads) ? listed.downloads : []).find((entry) => Number(entry?.downloadId) === downloadId);
|
|
421
|
+
const size = record?.bytes ? `${(record.bytes / (1024 * 1024)).toFixed(1)} MB` : 'unknown size';
|
|
422
|
+
if (!context.autoYes && context.requestPermission) {
|
|
423
|
+
const decision = await context.requestPermission({
|
|
424
|
+
bucket: 'browse',
|
|
425
|
+
title: 'TheGitAI wants to save a file to this machine',
|
|
426
|
+
body: `"${record?.filename ?? downloadId}" (${size}) from ${record?.url ?? 'an unknown source'}.`,
|
|
427
|
+
options: [
|
|
428
|
+
{ label: 'Save this file', decision: { kind: 'once' } },
|
|
429
|
+
{ label: 'No, cancel the download', decision: { kind: 'deny' } },
|
|
430
|
+
],
|
|
431
|
+
});
|
|
432
|
+
if (decision?.kind !== 'once' && decision?.kind !== 'always-bucket') {
|
|
433
|
+
await call(context, 'download.decide', { downloadId, accept: false });
|
|
434
|
+
return {
|
|
435
|
+
ok: false,
|
|
436
|
+
error: 'The user declined the download. It was cancelled and nothing was saved.',
|
|
437
|
+
failureCategory: 'user_declined',
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
const result = await call(context, 'download.decide', {
|
|
443
|
+
downloadId,
|
|
444
|
+
accept: action === 'accept',
|
|
445
|
+
});
|
|
446
|
+
return { ok: true, ...result };
|
|
447
|
+
}
|
|
448
|
+
export function browserResize(context, args) {
|
|
449
|
+
const width = Number(args.width);
|
|
450
|
+
const height = Number(args.height);
|
|
451
|
+
if (!Number.isFinite(width) || !Number.isFinite(height)) {
|
|
452
|
+
return Promise.resolve(missing('width and height', 'browser_resize'));
|
|
453
|
+
}
|
|
454
|
+
return gatedTabCall(context, {
|
|
455
|
+
tool: 'browser_resize',
|
|
456
|
+
op: 'resize',
|
|
457
|
+
args: { tabId: args.tabId ?? args.tab_id, width, height },
|
|
458
|
+
summary: () => `Resize the browser window to ${width}x${height}.`,
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
export async function browserFileUpload(context, args) {
|
|
462
|
+
const guard = notConnected(context);
|
|
463
|
+
if (guard.ok === false)
|
|
464
|
+
return guard;
|
|
465
|
+
const ref = String(args.ref ?? '').trim();
|
|
466
|
+
if (!ref)
|
|
467
|
+
return missing('ref', 'browser_file_upload');
|
|
468
|
+
const rawPaths = Array.isArray(args.paths)
|
|
469
|
+
? args.paths
|
|
470
|
+
: args.path
|
|
471
|
+
? [args.path]
|
|
472
|
+
: [];
|
|
473
|
+
if (!rawPaths.length)
|
|
474
|
+
return missing('paths', 'browser_file_upload');
|
|
475
|
+
const files = [];
|
|
476
|
+
let total = 0;
|
|
477
|
+
for (const raw of rawPaths) {
|
|
478
|
+
const candidate = String(raw ?? '').trim();
|
|
479
|
+
if (!candidate)
|
|
480
|
+
continue;
|
|
481
|
+
let resolved;
|
|
482
|
+
if (path.isAbsolute(candidate)) {
|
|
483
|
+
resolved = candidate;
|
|
484
|
+
}
|
|
485
|
+
else if (normalizeProjectRelativePath(context.rootDir, candidate)) {
|
|
486
|
+
resolved = path.resolve(context.rootDir, candidate);
|
|
487
|
+
}
|
|
488
|
+
else {
|
|
489
|
+
return {
|
|
490
|
+
ok: false,
|
|
491
|
+
error: `Refusing to upload a path outside the project root: ${candidate}`,
|
|
492
|
+
failureCategory: 'permission_denied',
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
let stat;
|
|
496
|
+
try {
|
|
497
|
+
stat = fs.statSync(resolved);
|
|
498
|
+
}
|
|
499
|
+
catch {
|
|
500
|
+
return {
|
|
501
|
+
ok: false,
|
|
502
|
+
error: `No such file: ${resolved}`,
|
|
503
|
+
failureCategory: 'not_found',
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
if (!stat.isFile()) {
|
|
507
|
+
return {
|
|
508
|
+
ok: false,
|
|
509
|
+
error: `Not a file: ${resolved}`,
|
|
510
|
+
failureCategory: 'invalid_argument',
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
total += stat.size;
|
|
514
|
+
if (total > MAX_UPLOAD_BYTES) {
|
|
515
|
+
return {
|
|
516
|
+
ok: false,
|
|
517
|
+
error: `Uploads are capped at ${MAX_UPLOAD_BYTES / (1024 * 1024)} MB in total; these files exceed it.`,
|
|
518
|
+
failureCategory: 'budget_exceeded',
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
files.push({
|
|
522
|
+
name: path.basename(resolved),
|
|
523
|
+
mimeType: guessMimeType(resolved),
|
|
524
|
+
base64: fs.readFileSync(resolved).toString('base64'),
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
const tabId = Number(args.tabId ?? args.tab_id);
|
|
528
|
+
if (!Number.isInteger(tabId))
|
|
529
|
+
return missing('tabId', 'browser_file_upload');
|
|
530
|
+
try {
|
|
531
|
+
const tab = await currentTab(context, tabId);
|
|
532
|
+
const denied = await ensureSiteAllowed(context, {
|
|
533
|
+
url: tab?.url,
|
|
534
|
+
tool: 'browser_file_upload',
|
|
535
|
+
summary: `Upload ${files.map((file) => file.name).join(', ')} to ${tab?.url ?? 'the page'}.`,
|
|
536
|
+
});
|
|
537
|
+
if (denied)
|
|
538
|
+
return denied;
|
|
539
|
+
const result = await call(context, 'upload', { tabId, ref, files }, NAVIGATE_TIMEOUT_MS);
|
|
540
|
+
return attachEvents({ ok: true, ...result, files: files.map((file) => file.name) });
|
|
541
|
+
}
|
|
542
|
+
catch (error) {
|
|
543
|
+
return bridgeFailure(error, 'browser_file_upload');
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
function guessMimeType(file) {
|
|
547
|
+
const extension = path.extname(file).toLowerCase();
|
|
548
|
+
const table = {
|
|
549
|
+
'.png': 'image/png',
|
|
550
|
+
'.jpg': 'image/jpeg',
|
|
551
|
+
'.jpeg': 'image/jpeg',
|
|
552
|
+
'.gif': 'image/gif',
|
|
553
|
+
'.webp': 'image/webp',
|
|
554
|
+
'.svg': 'image/svg+xml',
|
|
555
|
+
'.pdf': 'application/pdf',
|
|
556
|
+
'.json': 'application/json',
|
|
557
|
+
'.csv': 'text/csv',
|
|
558
|
+
'.txt': 'text/plain',
|
|
559
|
+
'.md': 'text/markdown',
|
|
560
|
+
'.html': 'text/html',
|
|
561
|
+
'.zip': 'application/zip',
|
|
562
|
+
};
|
|
563
|
+
return table[extension] ?? 'application/octet-stream';
|
|
564
|
+
}
|
|
565
|
+
const BATCH_TOOLS = {
|
|
566
|
+
browser_tabs: browserTabs,
|
|
567
|
+
browser_tab_new: (context) => browserTabNew(context),
|
|
568
|
+
browser_tab_close: browserTabClose,
|
|
569
|
+
browser_navigate: browserNavigate,
|
|
570
|
+
browser_read_page: browserReadPage,
|
|
571
|
+
browser_get_page_text: browserGetPageText,
|
|
572
|
+
browser_computer: browserComputer,
|
|
573
|
+
browser_form_input: browserFormInput,
|
|
574
|
+
browser_evaluate: browserEvaluate,
|
|
575
|
+
browser_console: browserConsole,
|
|
576
|
+
browser_network: browserNetwork,
|
|
577
|
+
browser_screenshot: (context, args) => browserComputer(context, { ...args, action: 'screenshot' }),
|
|
578
|
+
browser_resize: browserResize,
|
|
579
|
+
browser_file_upload: browserFileUpload,
|
|
580
|
+
browser_downloads: browserDownloads,
|
|
581
|
+
};
|
|
582
|
+
export async function browserBatch(context, args) {
|
|
583
|
+
const guard = notConnected(context);
|
|
584
|
+
if (guard.ok === false)
|
|
585
|
+
return guard;
|
|
586
|
+
const actions = Array.isArray(args.actions) ? args.actions : [];
|
|
587
|
+
if (!actions.length)
|
|
588
|
+
return missing('actions', 'browser_batch');
|
|
589
|
+
if (actions.length > 20) {
|
|
590
|
+
return {
|
|
591
|
+
ok: false,
|
|
592
|
+
error: 'A batch may hold at most 20 actions.',
|
|
593
|
+
failureCategory: 'invalid_argument',
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
const results = [];
|
|
597
|
+
for (const [index, entry] of actions.entries()) {
|
|
598
|
+
const name = String(entry?.name ?? '').trim();
|
|
599
|
+
const input = (entry?.input ?? {});
|
|
600
|
+
const handler = BATCH_TOOLS[name];
|
|
601
|
+
if (!handler) {
|
|
602
|
+
results.push({ step: index + 1, name, ok: false, error: `Unknown browser tool: ${name}` });
|
|
603
|
+
return { ok: false, stoppedAt: index + 1, results, error: `Unknown browser tool: ${name}` };
|
|
604
|
+
}
|
|
605
|
+
if (context.agentMode === 'plan' && !READ_ONLY_BROWSER_TOOLS.has(name)) {
|
|
606
|
+
results.push({ step: index + 1, name, ok: false, error: 'Plan mode blocks this action.' });
|
|
607
|
+
return {
|
|
608
|
+
ok: false,
|
|
609
|
+
stoppedAt: index + 1,
|
|
610
|
+
results,
|
|
611
|
+
error: `Plan mode allows only read-only browser actions; ${name} changes the page.`,
|
|
612
|
+
failureCategory: 'policy_blocked',
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
const result = await handler(context, input);
|
|
616
|
+
results.push({ step: index + 1, name, ...result });
|
|
617
|
+
if (result.ok === false) {
|
|
618
|
+
return {
|
|
619
|
+
ok: false,
|
|
620
|
+
stoppedAt: index + 1,
|
|
621
|
+
results,
|
|
622
|
+
error: `Batch stopped at step ${index + 1} (${name}): ${result.error ?? 'failed'}`,
|
|
623
|
+
failureCategory: result.failureCategory,
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
return { ok: true, results };
|
|
628
|
+
}
|
package/dist/src/tools/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { browserBatch, browserComputer, browserConsole, browserDownloads, browserEvaluate, browserFileUpload, browserFind, browserFormInput, browserGateway, browserGetPageText, browserNavigate, browserNetwork, browserReadPage, browserResize, browserTabClose, browserTabNew, browserTabs, } from './browser.js';
|
|
1
2
|
import { shellJobOutput } from './shell-job-output.js';
|
|
2
3
|
import { deleteFile } from './delete-file.js';
|
|
3
4
|
import { getDiagnostics } from './get-diagnostics.js';
|
|
@@ -43,6 +44,24 @@ export const TOOL_MAP = {
|
|
|
43
44
|
shell_job_kill: shellJobKill,
|
|
44
45
|
update_todos: updateTodos,
|
|
45
46
|
analyze_image: (context, args) => readImageFile(context, args),
|
|
47
|
+
browser: (context) => browserGateway(context),
|
|
48
|
+
browser_tabs: browserTabs,
|
|
49
|
+
browser_tab_new: (context) => browserTabNew(context),
|
|
50
|
+
browser_tab_close: browserTabClose,
|
|
51
|
+
browser_navigate: browserNavigate,
|
|
52
|
+
browser_read_page: browserReadPage,
|
|
53
|
+
browser_get_page_text: browserGetPageText,
|
|
54
|
+
browser_find: browserFind,
|
|
55
|
+
browser_computer: browserComputer,
|
|
56
|
+
browser_screenshot: ((context, args) => browserComputer(context, { ...args, action: 'screenshot' })),
|
|
57
|
+
browser_form_input: browserFormInput,
|
|
58
|
+
browser_evaluate: browserEvaluate,
|
|
59
|
+
browser_console: browserConsole,
|
|
60
|
+
browser_network: browserNetwork,
|
|
61
|
+
browser_resize: browserResize,
|
|
62
|
+
browser_file_upload: browserFileUpload,
|
|
63
|
+
browser_downloads: browserDownloads,
|
|
64
|
+
browser_batch: browserBatch,
|
|
46
65
|
generate_image: (_context, args) => {
|
|
47
66
|
if (!String(args.base64Data ?? '').trim()) {
|
|
48
67
|
return {
|