@kooka/agent-sdk 0.1.0 → 0.1.4
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 +143 -2
- package/dist/agent/agent.d.ts +11 -0
- package/dist/agent/agent.d.ts.map +1 -1
- package/dist/agent/agent.js +132 -7
- package/dist/agent/agent.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -1
- package/dist/index.js.map +1 -1
- package/dist/persistence/index.d.ts +5 -0
- package/dist/persistence/index.d.ts.map +1 -0
- package/dist/persistence/index.js +3 -0
- package/dist/persistence/index.js.map +1 -0
- package/dist/persistence/sessionSnapshot.d.ts +37 -0
- package/dist/persistence/sessionSnapshot.d.ts.map +1 -0
- package/dist/persistence/sessionSnapshot.js +53 -0
- package/dist/persistence/sessionSnapshot.js.map +1 -0
- package/dist/persistence/sqliteSessionStore.d.ts +38 -0
- package/dist/persistence/sqliteSessionStore.d.ts.map +1 -0
- package/dist/persistence/sqliteSessionStore.js +75 -0
- package/dist/persistence/sqliteSessionStore.js.map +1 -0
- package/dist/tools/agentBrowser.d.ts +131 -0
- package/dist/tools/agentBrowser.d.ts.map +1 -0
- package/dist/tools/agentBrowser.js +747 -0
- package/dist/tools/agentBrowser.js.map +1 -0
- package/dist/tools/builtin/index.d.ts +3 -0
- package/dist/tools/builtin/index.d.ts.map +1 -1
- package/dist/tools/builtin/index.js.map +1 -1
- package/dist/tools/builtin/skill.d.ts.map +1 -1
- package/dist/tools/builtin/skill.js +5 -7
- package/dist/tools/builtin/skill.js.map +1 -1
- package/dist/types.d.ts +12 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +19 -12
- package/LICENSE +0 -201
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
import * as crypto from 'node:crypto';
|
|
2
|
+
import * as fsSync from 'node:fs';
|
|
3
|
+
import * as fs from 'node:fs/promises';
|
|
4
|
+
import * as os from 'node:os';
|
|
5
|
+
import * as path from 'node:path';
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import { isSubPath, optionalBoolean, optionalNumber, optionalString, redactFsPathForPrompt, requireString } from '@kooka/core';
|
|
8
|
+
function clampInt(value, min, max) {
|
|
9
|
+
const num = Math.floor(Number(value));
|
|
10
|
+
if (!Number.isFinite(num))
|
|
11
|
+
return min;
|
|
12
|
+
return Math.max(min, Math.min(max, num));
|
|
13
|
+
}
|
|
14
|
+
function normalizeUrlInput(url, allowHttp) {
|
|
15
|
+
const trimmed = String(url || '').trim();
|
|
16
|
+
if (!trimmed)
|
|
17
|
+
return '';
|
|
18
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed))
|
|
19
|
+
return trimmed;
|
|
20
|
+
return `${allowHttp ? 'http' : 'https'}://${trimmed}`;
|
|
21
|
+
}
|
|
22
|
+
function isPrivateHostname(hostname) {
|
|
23
|
+
const host = String(hostname || '').trim().toLowerCase();
|
|
24
|
+
if (!host)
|
|
25
|
+
return true;
|
|
26
|
+
if (host === 'localhost')
|
|
27
|
+
return true;
|
|
28
|
+
if (host.endsWith('.localhost'))
|
|
29
|
+
return true;
|
|
30
|
+
if (host.endsWith('.local'))
|
|
31
|
+
return true;
|
|
32
|
+
if (host.endsWith('.internal'))
|
|
33
|
+
return true;
|
|
34
|
+
if (host.endsWith('.home'))
|
|
35
|
+
return true;
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
function validatePublicUrl(input, opts) {
|
|
39
|
+
const normalized = normalizeUrlInput(input, opts.allowHttp);
|
|
40
|
+
if (!normalized)
|
|
41
|
+
return { ok: false, reason: 'url is required' };
|
|
42
|
+
if (normalized.length > 2048)
|
|
43
|
+
return { ok: false, reason: 'url is too long' };
|
|
44
|
+
let parsed;
|
|
45
|
+
try {
|
|
46
|
+
parsed = new URL(normalized);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return { ok: false, reason: 'invalid url' };
|
|
50
|
+
}
|
|
51
|
+
if (!opts.allowHttp && parsed.protocol !== 'https:')
|
|
52
|
+
return { ok: false, reason: 'only https:// urls are allowed' };
|
|
53
|
+
if (opts.allowHttp && parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
54
|
+
return { ok: false, reason: 'only http(s):// urls are allowed' };
|
|
55
|
+
}
|
|
56
|
+
if (parsed.username || parsed.password)
|
|
57
|
+
return { ok: false, reason: 'url must not include credentials' };
|
|
58
|
+
if (!parsed.hostname)
|
|
59
|
+
return { ok: false, reason: 'url hostname is required' };
|
|
60
|
+
if (!opts.allowPrivateHosts && isPrivateHostname(parsed.hostname)) {
|
|
61
|
+
return { ok: false, reason: 'private/localhost domains are not allowed' };
|
|
62
|
+
}
|
|
63
|
+
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(parsed.hostname) || parsed.hostname.includes(':')) {
|
|
64
|
+
return { ok: false, reason: 'ip address hosts are not allowed' };
|
|
65
|
+
}
|
|
66
|
+
return { ok: true, url: parsed };
|
|
67
|
+
}
|
|
68
|
+
function validateSessionId(raw) {
|
|
69
|
+
const sessionId = String(raw || '').trim();
|
|
70
|
+
if (!sessionId)
|
|
71
|
+
return { ok: false, reason: 'sessionId is required' };
|
|
72
|
+
if (sessionId.length > 64)
|
|
73
|
+
return { ok: false, reason: 'sessionId is too long' };
|
|
74
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(sessionId)) {
|
|
75
|
+
return { ok: false, reason: 'sessionId must match /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/' };
|
|
76
|
+
}
|
|
77
|
+
return { ok: true, sessionId };
|
|
78
|
+
}
|
|
79
|
+
function safeTruncate(text, maxChars) {
|
|
80
|
+
const limit = Math.max(1, Math.floor(maxChars));
|
|
81
|
+
const value = String(text || '');
|
|
82
|
+
return value.length <= limit ? value : value.slice(0, limit);
|
|
83
|
+
}
|
|
84
|
+
function getCwd(context) {
|
|
85
|
+
return context.workspaceRoot ? path.resolve(context.workspaceRoot) : process.cwd();
|
|
86
|
+
}
|
|
87
|
+
function resolveAgentBrowserCommand(context, options) {
|
|
88
|
+
const override = String(options.agentBrowserBin || process.env.AGENT_BROWSER_BIN || '').trim();
|
|
89
|
+
if (override)
|
|
90
|
+
return override;
|
|
91
|
+
const binName = process.platform === 'win32' ? 'agent-browser.cmd' : 'agent-browser';
|
|
92
|
+
const cwd = getCwd(context);
|
|
93
|
+
const localBin = path.join(cwd, 'node_modules', '.bin', binName);
|
|
94
|
+
if (fsSync.existsSync(localBin))
|
|
95
|
+
return localBin;
|
|
96
|
+
return binName;
|
|
97
|
+
}
|
|
98
|
+
function shouldFallbackToPath(binPath) {
|
|
99
|
+
const normalized = String(binPath || '').trim();
|
|
100
|
+
if (!normalized)
|
|
101
|
+
return true;
|
|
102
|
+
if (normalized === 'agent-browser' || normalized === 'agent-browser.cmd')
|
|
103
|
+
return true;
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
async function runAgentBrowserProcess(args, options) {
|
|
107
|
+
const timeoutMs = Math.max(2_000, Math.floor(options.timeoutMs));
|
|
108
|
+
const cmd = shouldFallbackToPath(options.bin) ? (process.platform === 'win32' ? 'agent-browser.cmd' : 'agent-browser') : options.bin;
|
|
109
|
+
return await new Promise((resolve) => {
|
|
110
|
+
const child = spawn(cmd, [...args, '--json'], {
|
|
111
|
+
cwd: options.cwd,
|
|
112
|
+
env: { ...process.env },
|
|
113
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
114
|
+
});
|
|
115
|
+
let stdout = '';
|
|
116
|
+
let stderr = '';
|
|
117
|
+
child.stdout.on('data', (chunk) => {
|
|
118
|
+
stdout += chunk.toString('utf8');
|
|
119
|
+
if (stdout.length > 2_000_000)
|
|
120
|
+
stdout = stdout.slice(-2_000_000);
|
|
121
|
+
});
|
|
122
|
+
child.stderr.on('data', (chunk) => {
|
|
123
|
+
stderr += chunk.toString('utf8');
|
|
124
|
+
if (stderr.length > 200_000)
|
|
125
|
+
stderr = stderr.slice(-200_000);
|
|
126
|
+
});
|
|
127
|
+
const timer = setTimeout(() => {
|
|
128
|
+
child.kill('SIGKILL');
|
|
129
|
+
resolve({ success: false, data: null, error: `agent-browser timeout after ${timeoutMs}ms` });
|
|
130
|
+
}, timeoutMs);
|
|
131
|
+
timer.unref?.();
|
|
132
|
+
child.on('error', (err) => {
|
|
133
|
+
clearTimeout(timer);
|
|
134
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
135
|
+
const hint = /ENOENT/i.test(message) || /not found/i.test(message)
|
|
136
|
+
? 'agent-browser not found. Install it (npm i -g agent-browser; agent-browser install) or set AGENT_BROWSER_BIN.'
|
|
137
|
+
: message;
|
|
138
|
+
resolve({ success: false, data: null, error: hint });
|
|
139
|
+
});
|
|
140
|
+
child.on('close', () => {
|
|
141
|
+
clearTimeout(timer);
|
|
142
|
+
const out = stdout.trim();
|
|
143
|
+
const line = out
|
|
144
|
+
.split(/\r?\n/g)
|
|
145
|
+
.map((l) => l.trim())
|
|
146
|
+
.find((l) => l.startsWith('{') && l.endsWith('}'));
|
|
147
|
+
if (!line) {
|
|
148
|
+
const message = (stderr || stdout || '').trim();
|
|
149
|
+
resolve({ success: false, data: null, error: message || 'agent-browser returned no JSON output' });
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
resolve(JSON.parse(line));
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
resolve({
|
|
157
|
+
success: false,
|
|
158
|
+
data: null,
|
|
159
|
+
error: `Failed to parse agent-browser JSON output: ${err instanceof Error ? err.message : String(err)}`,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
function resolveArtifactsDir(context, dirName) {
|
|
166
|
+
const raw = String(dirName || '').trim() || '.kooka/agent-browser';
|
|
167
|
+
if (context.workspaceRoot) {
|
|
168
|
+
return path.resolve(context.workspaceRoot, raw);
|
|
169
|
+
}
|
|
170
|
+
const safe = raw.replace(/[\\/]/g, '-').replace(/[^\w.-]+/g, '-');
|
|
171
|
+
return path.join(os.tmpdir(), safe || 'kooka-agent-browser');
|
|
172
|
+
}
|
|
173
|
+
function sanitizeArtifactName(input, fallbackExt) {
|
|
174
|
+
const raw = String(input || '').trim();
|
|
175
|
+
const base = raw ? path.basename(raw) : '';
|
|
176
|
+
const cleaned = base.replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 120);
|
|
177
|
+
const safe = cleaned === '.' || cleaned === '..' ? '' : cleaned;
|
|
178
|
+
const withExt = safe && path.extname(safe) ? safe : safe ? `${safe}${fallbackExt}` : '';
|
|
179
|
+
if (withExt)
|
|
180
|
+
return withExt;
|
|
181
|
+
return `artifact-${Date.now().toString(36)}${fallbackExt}`;
|
|
182
|
+
}
|
|
183
|
+
async function resolveArtifactPath(params) {
|
|
184
|
+
const dir = resolveArtifactsDir(params.context, params.artifactsDir);
|
|
185
|
+
await fs.mkdir(dir, { recursive: true });
|
|
186
|
+
const fileName = sanitizeArtifactName(params.name || '', params.defaultExt);
|
|
187
|
+
const abs = path.resolve(dir, fileName);
|
|
188
|
+
if (!isSubPath(abs, dir)) {
|
|
189
|
+
throw new Error('Invalid artifact path (must be inside artifactsDir)');
|
|
190
|
+
}
|
|
191
|
+
const displayPath = redactFsPathForPrompt(abs, { workspaceRoot: params.context.workspaceRoot });
|
|
192
|
+
return { absPath: abs, displayPath };
|
|
193
|
+
}
|
|
194
|
+
class BrowserSessionManager {
|
|
195
|
+
closeFn;
|
|
196
|
+
defaultTtlMs;
|
|
197
|
+
sessions = new Map();
|
|
198
|
+
constructor(closeFn, defaultTtlMs) {
|
|
199
|
+
this.closeFn = closeFn;
|
|
200
|
+
this.defaultTtlMs = defaultTtlMs;
|
|
201
|
+
}
|
|
202
|
+
get(sessionId) {
|
|
203
|
+
return this.sessions.get(sessionId);
|
|
204
|
+
}
|
|
205
|
+
start(sessionId, ttlMs) {
|
|
206
|
+
const now = Date.now();
|
|
207
|
+
const existing = this.sessions.get(sessionId);
|
|
208
|
+
if (existing) {
|
|
209
|
+
return this.touch(existing, ttlMs, now);
|
|
210
|
+
}
|
|
211
|
+
const entry = {
|
|
212
|
+
sessionId,
|
|
213
|
+
createdAt: now,
|
|
214
|
+
lastUsedAt: now,
|
|
215
|
+
ttlMs,
|
|
216
|
+
expiresAt: now + ttlMs,
|
|
217
|
+
};
|
|
218
|
+
this.sessions.set(sessionId, entry);
|
|
219
|
+
this.schedule(entry);
|
|
220
|
+
return entry;
|
|
221
|
+
}
|
|
222
|
+
touch(entry, ttlMs, now = Date.now()) {
|
|
223
|
+
const effectiveTtl = ttlMs === undefined ? entry.ttlMs : ttlMs;
|
|
224
|
+
entry.lastUsedAt = now;
|
|
225
|
+
entry.ttlMs = effectiveTtl;
|
|
226
|
+
entry.expiresAt = effectiveTtl > 0 ? now + effectiveTtl : now;
|
|
227
|
+
this.schedule(entry);
|
|
228
|
+
return entry;
|
|
229
|
+
}
|
|
230
|
+
schedule(entry) {
|
|
231
|
+
if (entry.timer)
|
|
232
|
+
clearTimeout(entry.timer);
|
|
233
|
+
if (!Number.isFinite(entry.ttlMs) || entry.ttlMs <= 0) {
|
|
234
|
+
entry.timer = undefined;
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const ttl = Math.max(0, Math.floor(entry.ttlMs));
|
|
238
|
+
entry.timer = setTimeout(() => {
|
|
239
|
+
void this.autoClose(entry.sessionId);
|
|
240
|
+
}, ttl);
|
|
241
|
+
entry.timer.unref?.();
|
|
242
|
+
}
|
|
243
|
+
async autoClose(sessionId) {
|
|
244
|
+
const entry = this.sessions.get(sessionId);
|
|
245
|
+
if (!entry)
|
|
246
|
+
return;
|
|
247
|
+
if (entry.closing)
|
|
248
|
+
return;
|
|
249
|
+
entry.closing = true;
|
|
250
|
+
if (entry.timer)
|
|
251
|
+
clearTimeout(entry.timer);
|
|
252
|
+
entry.timer = undefined;
|
|
253
|
+
this.sessions.delete(sessionId);
|
|
254
|
+
try {
|
|
255
|
+
await this.closeFn(sessionId, { signal: AbortSignal.timeout?.(5_000) ?? new AbortController().signal, log: () => { } });
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
// ignore
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
async close(sessionId, context) {
|
|
262
|
+
const entry = this.sessions.get(sessionId);
|
|
263
|
+
if (entry) {
|
|
264
|
+
entry.closing = true;
|
|
265
|
+
if (entry.timer)
|
|
266
|
+
clearTimeout(entry.timer);
|
|
267
|
+
entry.timer = undefined;
|
|
268
|
+
this.sessions.delete(sessionId);
|
|
269
|
+
}
|
|
270
|
+
return await this.closeFn(sessionId, context);
|
|
271
|
+
}
|
|
272
|
+
dispose() {
|
|
273
|
+
for (const entry of this.sessions.values()) {
|
|
274
|
+
if (entry.timer)
|
|
275
|
+
clearTimeout(entry.timer);
|
|
276
|
+
}
|
|
277
|
+
this.sessions.clear();
|
|
278
|
+
}
|
|
279
|
+
ensure(sessionId, ttlMs) {
|
|
280
|
+
const ttl = ttlMs === undefined ? this.defaultTtlMs : ttlMs;
|
|
281
|
+
const existing = this.sessions.get(sessionId);
|
|
282
|
+
if (existing)
|
|
283
|
+
return this.touch(existing, ttl);
|
|
284
|
+
return this.start(sessionId, ttl);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
function redactActionForOutput(action) {
|
|
288
|
+
if (action.type === 'fill' || action.type === 'type') {
|
|
289
|
+
return { ...action, text: `<redacted:${String(action.text || '').length} chars>` };
|
|
290
|
+
}
|
|
291
|
+
return action;
|
|
292
|
+
}
|
|
293
|
+
function toAgentBrowserArgsForAction(params) {
|
|
294
|
+
const action = params.action;
|
|
295
|
+
switch (action.type) {
|
|
296
|
+
case 'open': {
|
|
297
|
+
const validated = validatePublicUrl(action.url, { allowHttp: params.allowHttp, allowPrivateHosts: params.allowPrivateHosts });
|
|
298
|
+
if (!validated.ok) {
|
|
299
|
+
return Promise.reject(new Error(`browser.run rejected url: ${validated.reason}`));
|
|
300
|
+
}
|
|
301
|
+
return Promise.resolve({ args: ['open', validated.url.toString()] });
|
|
302
|
+
}
|
|
303
|
+
case 'back':
|
|
304
|
+
return Promise.resolve({ args: ['back'] });
|
|
305
|
+
case 'forward':
|
|
306
|
+
return Promise.resolve({ args: ['forward'] });
|
|
307
|
+
case 'reload':
|
|
308
|
+
return Promise.resolve({ args: ['reload'] });
|
|
309
|
+
case 'click':
|
|
310
|
+
return Promise.resolve({ args: ['click', String(action.selector || '').trim()] });
|
|
311
|
+
case 'dblclick':
|
|
312
|
+
return Promise.resolve({ args: ['dblclick', String(action.selector || '').trim()] });
|
|
313
|
+
case 'focus':
|
|
314
|
+
return Promise.resolve({ args: ['focus', String(action.selector || '').trim()] });
|
|
315
|
+
case 'hover':
|
|
316
|
+
return Promise.resolve({ args: ['hover', String(action.selector || '').trim()] });
|
|
317
|
+
case 'fill':
|
|
318
|
+
return Promise.resolve({ args: ['fill', String(action.selector || '').trim(), String(action.text ?? '')] });
|
|
319
|
+
case 'type':
|
|
320
|
+
return Promise.resolve({ args: ['type', String(action.selector || '').trim(), String(action.text ?? '')] });
|
|
321
|
+
case 'press':
|
|
322
|
+
return Promise.resolve({ args: ['press', String(action.key || '').trim()] });
|
|
323
|
+
case 'select':
|
|
324
|
+
return Promise.resolve({ args: ['select', String(action.selector || '').trim(), String(action.value ?? '')] });
|
|
325
|
+
case 'check':
|
|
326
|
+
return Promise.resolve({ args: ['check', String(action.selector || '').trim()] });
|
|
327
|
+
case 'uncheck':
|
|
328
|
+
return Promise.resolve({ args: ['uncheck', String(action.selector || '').trim()] });
|
|
329
|
+
case 'scroll': {
|
|
330
|
+
const dir = String(action.direction || '').trim().toLowerCase();
|
|
331
|
+
if (!['up', 'down', 'left', 'right'].includes(dir)) {
|
|
332
|
+
return Promise.reject(new Error('scroll.direction must be up|down|left|right'));
|
|
333
|
+
}
|
|
334
|
+
const px = action.px === undefined ? undefined : clampInt(action.px, 1, 10_000);
|
|
335
|
+
return Promise.resolve({ args: px ? ['scroll', dir, String(px)] : ['scroll', dir] });
|
|
336
|
+
}
|
|
337
|
+
case 'scrollIntoView':
|
|
338
|
+
return Promise.resolve({ args: ['scrollintoview', String(action.selector || '').trim()] });
|
|
339
|
+
case 'wait': {
|
|
340
|
+
const ms = action.ms === undefined ? undefined : clampInt(action.ms, 1, 300_000);
|
|
341
|
+
const selector = action.selector ? String(action.selector).trim() : '';
|
|
342
|
+
const text = action.text ? String(action.text).trim() : '';
|
|
343
|
+
const url = action.url ? String(action.url).trim() : '';
|
|
344
|
+
const load = action.load ? String(action.load).trim() : '';
|
|
345
|
+
const fn = action.fn ? String(action.fn).trim() : '';
|
|
346
|
+
const configured = [ms !== undefined, !!selector, !!text, !!url, !!load, !!fn].filter(Boolean).length;
|
|
347
|
+
if (configured !== 1) {
|
|
348
|
+
return Promise.reject(new Error('wait action must specify exactly one of: ms, selector, text, url, load, fn'));
|
|
349
|
+
}
|
|
350
|
+
if (ms !== undefined)
|
|
351
|
+
return Promise.resolve({ args: ['wait', String(ms)] });
|
|
352
|
+
if (selector)
|
|
353
|
+
return Promise.resolve({ args: ['wait', selector] });
|
|
354
|
+
if (text)
|
|
355
|
+
return Promise.resolve({ args: ['wait', '--text', text] });
|
|
356
|
+
if (url)
|
|
357
|
+
return Promise.resolve({ args: ['wait', '--url', url] });
|
|
358
|
+
if (load)
|
|
359
|
+
return Promise.resolve({ args: ['wait', '--load', load] });
|
|
360
|
+
return Promise.resolve({ args: ['wait', '--fn', fn] });
|
|
361
|
+
}
|
|
362
|
+
case 'get': {
|
|
363
|
+
const kind = String(action.kind || '').trim().toLowerCase();
|
|
364
|
+
if (kind === 'title')
|
|
365
|
+
return Promise.resolve({ args: ['get', 'title'] });
|
|
366
|
+
if (kind === 'url')
|
|
367
|
+
return Promise.resolve({ args: ['get', 'url'] });
|
|
368
|
+
const selector = String(action.selector || '').trim();
|
|
369
|
+
if (!selector)
|
|
370
|
+
return Promise.reject(new Error('get action requires selector for kind text/html/value/attr'));
|
|
371
|
+
if (kind === 'text')
|
|
372
|
+
return Promise.resolve({ args: ['get', 'text', selector], artifact: undefined });
|
|
373
|
+
if (kind === 'html')
|
|
374
|
+
return Promise.resolve({ args: ['get', 'html', selector], artifact: undefined });
|
|
375
|
+
if (kind === 'value')
|
|
376
|
+
return Promise.resolve({ args: ['get', 'value', selector], artifact: undefined });
|
|
377
|
+
if (kind === 'attr') {
|
|
378
|
+
const attr = String(action.attr || '').trim();
|
|
379
|
+
if (!attr)
|
|
380
|
+
return Promise.reject(new Error('get.kind=attr requires attr'));
|
|
381
|
+
return Promise.resolve({ args: ['get', 'attr', selector, attr], artifact: undefined });
|
|
382
|
+
}
|
|
383
|
+
return Promise.reject(new Error(`Unknown get.kind: ${kind}`));
|
|
384
|
+
}
|
|
385
|
+
case 'screenshot': {
|
|
386
|
+
return resolveArtifactPath({
|
|
387
|
+
context: params.context,
|
|
388
|
+
artifactsDir: params.artifactsDir,
|
|
389
|
+
name: action.name,
|
|
390
|
+
defaultExt: '.png',
|
|
391
|
+
}).then(({ absPath, displayPath }) => ({
|
|
392
|
+
args: action.fullPage ? ['screenshot', absPath, '--full'] : ['screenshot', absPath],
|
|
393
|
+
artifact: { kind: 'screenshot', path: displayPath },
|
|
394
|
+
}));
|
|
395
|
+
}
|
|
396
|
+
case 'pdf': {
|
|
397
|
+
return resolveArtifactPath({
|
|
398
|
+
context: params.context,
|
|
399
|
+
artifactsDir: params.artifactsDir,
|
|
400
|
+
name: action.name,
|
|
401
|
+
defaultExt: '.pdf',
|
|
402
|
+
}).then(({ absPath, displayPath }) => ({
|
|
403
|
+
args: ['pdf', absPath],
|
|
404
|
+
artifact: { kind: 'pdf', path: displayPath },
|
|
405
|
+
}));
|
|
406
|
+
}
|
|
407
|
+
case 'traceStart': {
|
|
408
|
+
return resolveArtifactPath({
|
|
409
|
+
context: params.context,
|
|
410
|
+
artifactsDir: params.artifactsDir,
|
|
411
|
+
name: action.name,
|
|
412
|
+
defaultExt: '.zip',
|
|
413
|
+
}).then(({ absPath, displayPath }) => ({
|
|
414
|
+
args: ['trace', 'start', absPath],
|
|
415
|
+
artifact: { kind: 'trace', path: displayPath },
|
|
416
|
+
}));
|
|
417
|
+
}
|
|
418
|
+
case 'traceStop': {
|
|
419
|
+
return resolveArtifactPath({
|
|
420
|
+
context: params.context,
|
|
421
|
+
artifactsDir: params.artifactsDir,
|
|
422
|
+
name: action.name,
|
|
423
|
+
defaultExt: '.zip',
|
|
424
|
+
}).then(({ absPath, displayPath }) => ({
|
|
425
|
+
args: ['trace', 'stop', absPath],
|
|
426
|
+
artifact: { kind: 'trace', path: displayPath },
|
|
427
|
+
}));
|
|
428
|
+
}
|
|
429
|
+
default:
|
|
430
|
+
return Promise.reject(new Error(`Unsupported action type: ${action.type}`));
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
function parseActions(args) {
|
|
434
|
+
const raw = args.actions;
|
|
435
|
+
if (!Array.isArray(raw))
|
|
436
|
+
return { ok: false, error: 'actions is required and must be an array' };
|
|
437
|
+
const actions = [];
|
|
438
|
+
for (const item of raw) {
|
|
439
|
+
if (!item || typeof item !== 'object')
|
|
440
|
+
return { ok: false, error: 'actions items must be objects' };
|
|
441
|
+
const actionType = String(item.type || '').trim();
|
|
442
|
+
if (!actionType)
|
|
443
|
+
return { ok: false, error: 'actions[].type is required' };
|
|
444
|
+
actions.push(item);
|
|
445
|
+
}
|
|
446
|
+
return { ok: true, actions };
|
|
447
|
+
}
|
|
448
|
+
export class AgentBrowserToolProvider {
|
|
449
|
+
options;
|
|
450
|
+
id = 'agent-browser';
|
|
451
|
+
name = 'Agent Browser';
|
|
452
|
+
enabled;
|
|
453
|
+
timeoutMs;
|
|
454
|
+
maxSnapshotChars;
|
|
455
|
+
maxTextChars;
|
|
456
|
+
artifactsDir;
|
|
457
|
+
allowHttp;
|
|
458
|
+
allowPrivateHosts;
|
|
459
|
+
runner;
|
|
460
|
+
sessions;
|
|
461
|
+
startSessionTool;
|
|
462
|
+
closeSessionTool;
|
|
463
|
+
snapshotTool;
|
|
464
|
+
runTool;
|
|
465
|
+
constructor(options = {}) {
|
|
466
|
+
this.options = options;
|
|
467
|
+
this.enabled = options.enabled !== false;
|
|
468
|
+
this.timeoutMs = clampInt(options.timeoutMs ?? 30_000, 2_000, 180_000);
|
|
469
|
+
this.maxSnapshotChars = clampInt(options.maxSnapshotChars ?? 25_000, 1, 200_000);
|
|
470
|
+
this.maxTextChars = clampInt(options.maxTextChars ?? 20_000, 1, 200_000);
|
|
471
|
+
this.artifactsDir = String(options.artifactsDir || '.kooka/agent-browser').trim() || '.kooka/agent-browser';
|
|
472
|
+
this.allowHttp = !!options.allowHttp;
|
|
473
|
+
this.allowPrivateHosts = !!options.allowPrivateHosts;
|
|
474
|
+
this.runner =
|
|
475
|
+
options.runner ??
|
|
476
|
+
(async (args, execOptions) => {
|
|
477
|
+
return await runAgentBrowserProcess(args, { timeoutMs: execOptions.timeoutMs, cwd: execOptions.cwd, bin: execOptions.bin });
|
|
478
|
+
});
|
|
479
|
+
const defaultTtlMs = clampInt(options.defaultTtlMs ?? 10 * 60_000, 1_000, 24 * 60 * 60_000);
|
|
480
|
+
this.sessions = new BrowserSessionManager(async (sessionId, ctx) => {
|
|
481
|
+
const cwd = getCwd(ctx);
|
|
482
|
+
const bin = resolveAgentBrowserCommand(ctx, options);
|
|
483
|
+
const res = await this.runner(['--session', sessionId, 'close'], { timeoutMs: this.timeoutMs, cwd, bin });
|
|
484
|
+
return !!res.success;
|
|
485
|
+
}, defaultTtlMs);
|
|
486
|
+
this.startSessionTool = {
|
|
487
|
+
id: 'browser.startSession',
|
|
488
|
+
name: 'Browser: Start Session',
|
|
489
|
+
description: 'Start (or reuse) an isolated browser session for multi-step browser work. ' +
|
|
490
|
+
'Use browser.snapshot to inspect the page and get stable refs (@e1). Sessions auto-close after a TTL.',
|
|
491
|
+
parameters: {
|
|
492
|
+
type: 'object',
|
|
493
|
+
properties: {
|
|
494
|
+
sessionId: { type: 'string', description: 'Optional session id (letters/numbers/._-). Omit to auto-generate.' },
|
|
495
|
+
ttlMs: { type: 'number', description: 'Time-to-live in ms before auto-close (default from host config)' },
|
|
496
|
+
},
|
|
497
|
+
},
|
|
498
|
+
execution: { type: 'function', handler: 'browser.startSession' },
|
|
499
|
+
metadata: { requiresApproval: false, permission: 'read', readOnly: true },
|
|
500
|
+
};
|
|
501
|
+
this.closeSessionTool = {
|
|
502
|
+
id: 'browser.closeSession',
|
|
503
|
+
name: 'Browser: Close Session',
|
|
504
|
+
description: 'Close a browser session and release resources.',
|
|
505
|
+
parameters: {
|
|
506
|
+
type: 'object',
|
|
507
|
+
properties: {
|
|
508
|
+
sessionId: { type: 'string', description: 'Session id returned by browser.startSession' },
|
|
509
|
+
},
|
|
510
|
+
required: ['sessionId'],
|
|
511
|
+
},
|
|
512
|
+
execution: { type: 'function', handler: 'browser.closeSession' },
|
|
513
|
+
metadata: { requiresApproval: false, permission: 'read', readOnly: true },
|
|
514
|
+
};
|
|
515
|
+
this.snapshotTool = {
|
|
516
|
+
id: 'browser.snapshot',
|
|
517
|
+
name: 'Browser: Snapshot',
|
|
518
|
+
description: 'Open (optional) and snapshot the current page into an accessibility tree with stable refs (use @eN selectors). ' +
|
|
519
|
+
'Prefer this over raw HTML scraping.',
|
|
520
|
+
parameters: {
|
|
521
|
+
type: 'object',
|
|
522
|
+
properties: {
|
|
523
|
+
sessionId: { type: 'string', description: 'Session id (use browser.startSession first)' },
|
|
524
|
+
url: { type: 'string', description: 'Optional URL to open before snapshot (defaults to current page)' },
|
|
525
|
+
interactive: { type: 'boolean', description: 'Only interactive elements (default true)' },
|
|
526
|
+
compact: { type: 'boolean', description: 'Remove empty structural elements (default true)' },
|
|
527
|
+
depth: { type: 'number', description: 'Max snapshot depth (default 6; max 20)' },
|
|
528
|
+
selector: { type: 'string', description: 'Optional CSS selector to scope snapshot (e.g. #main)' },
|
|
529
|
+
maxChars: { type: 'number', description: 'Max characters to return (default from host config)' },
|
|
530
|
+
timeoutMs: { type: 'number', description: 'Command timeout in ms (default from host config)' },
|
|
531
|
+
},
|
|
532
|
+
required: ['sessionId'],
|
|
533
|
+
},
|
|
534
|
+
execution: { type: 'function', handler: 'browser.snapshot' },
|
|
535
|
+
metadata: { requiresApproval: false, permission: 'read', readOnly: true },
|
|
536
|
+
};
|
|
537
|
+
this.runTool = {
|
|
538
|
+
id: 'browser.run',
|
|
539
|
+
name: 'Browser: Run Actions',
|
|
540
|
+
description: 'Run a sequence of browser actions in a session (click/fill/type/press/wait/get/etc). ' +
|
|
541
|
+
'Use refs from browser.snapshot when possible (e.g. selector "@e2"). ' +
|
|
542
|
+
'This tool can take screenshots / PDFs / traces into a local artifacts directory.',
|
|
543
|
+
parameters: {
|
|
544
|
+
type: 'object',
|
|
545
|
+
properties: {
|
|
546
|
+
sessionId: { type: 'string', description: 'Session id (use browser.startSession first)' },
|
|
547
|
+
actions: {
|
|
548
|
+
type: 'array',
|
|
549
|
+
description: 'Action list to execute sequentially',
|
|
550
|
+
items: { type: 'object', description: 'Action object; see tool description for supported types' },
|
|
551
|
+
},
|
|
552
|
+
timeoutMs: { type: 'number', description: 'Per-action timeout in ms (default from host config)' },
|
|
553
|
+
failFast: { type: 'boolean', description: 'Stop at first failure (default true)' },
|
|
554
|
+
},
|
|
555
|
+
required: ['sessionId', 'actions'],
|
|
556
|
+
},
|
|
557
|
+
execution: { type: 'function', handler: 'browser.run' },
|
|
558
|
+
metadata: { requiresApproval: true, permission: 'write', readOnly: false },
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
getTools() {
|
|
562
|
+
return [this.startSessionTool, this.closeSessionTool, this.snapshotTool, this.runTool];
|
|
563
|
+
}
|
|
564
|
+
dispose() {
|
|
565
|
+
this.sessions.dispose();
|
|
566
|
+
}
|
|
567
|
+
async executeTool(toolId, args, context) {
|
|
568
|
+
if (!this.enabled)
|
|
569
|
+
return { success: false, error: 'Browser tools are disabled.' };
|
|
570
|
+
switch (toolId) {
|
|
571
|
+
case 'browser.startSession':
|
|
572
|
+
return await this.handleStartSession(args);
|
|
573
|
+
case 'browser.closeSession':
|
|
574
|
+
return await this.handleCloseSession(args, context);
|
|
575
|
+
case 'browser.snapshot':
|
|
576
|
+
return await this.handleSnapshot(args, context);
|
|
577
|
+
case 'browser.run':
|
|
578
|
+
return await this.handleRun(args, context);
|
|
579
|
+
default:
|
|
580
|
+
return { success: false, error: `Unknown browser tool: ${toolId}` };
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
async handleStartSession(args) {
|
|
584
|
+
const sessionInput = optionalString(args, 'sessionId');
|
|
585
|
+
const sessionIdRaw = sessionInput && sessionInput.trim() ? sessionInput.trim() : `browser_${crypto.randomUUID().slice(0, 12)}`;
|
|
586
|
+
const sessionRes = validateSessionId(sessionIdRaw);
|
|
587
|
+
if (!sessionRes.ok)
|
|
588
|
+
return { success: false, error: sessionRes.reason };
|
|
589
|
+
const ttlMsRaw = optionalNumber(args, 'ttlMs');
|
|
590
|
+
const ttlMs = ttlMsRaw === undefined ? undefined : clampInt(ttlMsRaw, 1_000, 24 * 60 * 60_000);
|
|
591
|
+
const entry = this.sessions.ensure(sessionRes.sessionId, ttlMs);
|
|
592
|
+
return {
|
|
593
|
+
success: true,
|
|
594
|
+
data: {
|
|
595
|
+
sessionId: entry.sessionId,
|
|
596
|
+
ttlMs: entry.ttlMs,
|
|
597
|
+
expiresAt: entry.expiresAt,
|
|
598
|
+
},
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
async handleCloseSession(args, context) {
|
|
602
|
+
const sessionResult = requireString(args, 'sessionId');
|
|
603
|
+
if ('error' in sessionResult)
|
|
604
|
+
return { success: false, error: sessionResult.error };
|
|
605
|
+
const sessionRes = validateSessionId(sessionResult.value);
|
|
606
|
+
if (!sessionRes.ok)
|
|
607
|
+
return { success: false, error: sessionRes.reason };
|
|
608
|
+
const closed = await this.sessions.close(sessionRes.sessionId, context);
|
|
609
|
+
return { success: true, data: { sessionId: sessionRes.sessionId, closed } };
|
|
610
|
+
}
|
|
611
|
+
async handleSnapshot(args, context) {
|
|
612
|
+
const sessionResult = requireString(args, 'sessionId');
|
|
613
|
+
if ('error' in sessionResult)
|
|
614
|
+
return { success: false, error: sessionResult.error };
|
|
615
|
+
const sessionRes = validateSessionId(sessionResult.value);
|
|
616
|
+
if (!sessionRes.ok)
|
|
617
|
+
return { success: false, error: sessionRes.reason };
|
|
618
|
+
const ttlMsRaw = optionalNumber(args, 'ttlMs');
|
|
619
|
+
const ttlMs = ttlMsRaw === undefined ? undefined : clampInt(ttlMsRaw, 1_000, 24 * 60 * 60_000);
|
|
620
|
+
this.sessions.ensure(sessionRes.sessionId, ttlMs);
|
|
621
|
+
const timeoutMs = clampInt(optionalNumber(args, 'timeoutMs') ?? this.timeoutMs, 2_000, 180_000);
|
|
622
|
+
const maxChars = clampInt(optionalNumber(args, 'maxChars') ?? this.maxSnapshotChars, 1, this.maxSnapshotChars);
|
|
623
|
+
const urlRaw = optionalString(args, 'url');
|
|
624
|
+
const cwd = getCwd(context);
|
|
625
|
+
const bin = resolveAgentBrowserCommand(context, this.options);
|
|
626
|
+
let openInfo;
|
|
627
|
+
if (urlRaw && urlRaw.trim()) {
|
|
628
|
+
const validated = validatePublicUrl(urlRaw, { allowHttp: this.allowHttp, allowPrivateHosts: this.allowPrivateHosts });
|
|
629
|
+
if (!validated.ok)
|
|
630
|
+
return { success: false, error: `browser.snapshot rejected url: ${validated.reason}` };
|
|
631
|
+
const opened = await this.runner(['--session', sessionRes.sessionId, 'open', validated.url.toString()], { timeoutMs, cwd, bin });
|
|
632
|
+
if (!opened.success)
|
|
633
|
+
return { success: false, error: opened.error || 'agent-browser open failed' };
|
|
634
|
+
if (opened.data && typeof opened.data === 'object') {
|
|
635
|
+
openInfo = opened.data;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
const interactive = optionalBoolean(args, 'interactive', true) ?? true;
|
|
639
|
+
const compact = optionalBoolean(args, 'compact', true) ?? true;
|
|
640
|
+
const depth = clampInt(optionalNumber(args, 'depth') ?? 6, 1, 20);
|
|
641
|
+
const selector = optionalString(args, 'selector');
|
|
642
|
+
const snapArgs = ['--session', sessionRes.sessionId, 'snapshot'];
|
|
643
|
+
if (interactive)
|
|
644
|
+
snapArgs.push('-i');
|
|
645
|
+
if (compact)
|
|
646
|
+
snapArgs.push('-c');
|
|
647
|
+
if (depth)
|
|
648
|
+
snapArgs.push('-d', String(depth));
|
|
649
|
+
if (selector && selector.trim())
|
|
650
|
+
snapArgs.push('-s', selector.trim());
|
|
651
|
+
const snap = await this.runner(snapArgs, { timeoutMs, cwd, bin });
|
|
652
|
+
if (!snap.success)
|
|
653
|
+
return { success: false, error: snap.error || 'agent-browser snapshot failed' };
|
|
654
|
+
const data = snap.data && typeof snap.data === 'object' ? snap.data : {};
|
|
655
|
+
const snapshot = safeTruncate(typeof data.snapshot === 'string' ? data.snapshot : '', maxChars);
|
|
656
|
+
const refCount = data.refs && typeof data.refs === 'object' ? Object.keys(data.refs).length : 0;
|
|
657
|
+
return {
|
|
658
|
+
success: true,
|
|
659
|
+
data: {
|
|
660
|
+
sessionId: sessionRes.sessionId,
|
|
661
|
+
url: typeof openInfo?.url === 'string' ? openInfo.url : undefined,
|
|
662
|
+
title: typeof openInfo?.title === 'string' ? openInfo.title : undefined,
|
|
663
|
+
snapshot,
|
|
664
|
+
refCount,
|
|
665
|
+
},
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
async handleRun(args, context) {
|
|
669
|
+
const sessionResult = requireString(args, 'sessionId');
|
|
670
|
+
if ('error' in sessionResult)
|
|
671
|
+
return { success: false, error: sessionResult.error };
|
|
672
|
+
const sessionRes = validateSessionId(sessionResult.value);
|
|
673
|
+
if (!sessionRes.ok)
|
|
674
|
+
return { success: false, error: sessionRes.reason };
|
|
675
|
+
const ttlMsRaw = optionalNumber(args, 'ttlMs');
|
|
676
|
+
const ttlMs = ttlMsRaw === undefined ? undefined : clampInt(ttlMsRaw, 1_000, 24 * 60 * 60_000);
|
|
677
|
+
this.sessions.ensure(sessionRes.sessionId, ttlMs);
|
|
678
|
+
const actionsRes = parseActions(args);
|
|
679
|
+
if (!actionsRes.ok)
|
|
680
|
+
return { success: false, error: actionsRes.error };
|
|
681
|
+
const timeoutMs = clampInt(optionalNumber(args, 'timeoutMs') ?? this.timeoutMs, 2_000, 180_000);
|
|
682
|
+
const failFast = optionalBoolean(args, 'failFast', true) ?? true;
|
|
683
|
+
const cwd = getCwd(context);
|
|
684
|
+
const bin = resolveAgentBrowserCommand(context, this.options);
|
|
685
|
+
const artifacts = [];
|
|
686
|
+
const results = [];
|
|
687
|
+
for (let i = 0; i < actionsRes.actions.length; i++) {
|
|
688
|
+
const action = actionsRes.actions[i];
|
|
689
|
+
let mapped;
|
|
690
|
+
try {
|
|
691
|
+
mapped = await toAgentBrowserArgsForAction({
|
|
692
|
+
action,
|
|
693
|
+
allowHttp: this.allowHttp,
|
|
694
|
+
allowPrivateHosts: this.allowPrivateHosts,
|
|
695
|
+
maxTextChars: this.maxTextChars,
|
|
696
|
+
context,
|
|
697
|
+
artifactsDir: this.artifactsDir,
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
catch (err) {
|
|
701
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
702
|
+
results.push({ index: i, action: redactActionForOutput(action), success: false, error: message });
|
|
703
|
+
if (failFast)
|
|
704
|
+
break;
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
if (mapped.artifact)
|
|
708
|
+
artifacts.push(mapped.artifact);
|
|
709
|
+
const runArgs = ['--session', sessionRes.sessionId, ...mapped.args];
|
|
710
|
+
const res = await this.runner(runArgs, { timeoutMs, cwd, bin });
|
|
711
|
+
const step = {
|
|
712
|
+
index: i,
|
|
713
|
+
action: redactActionForOutput(action),
|
|
714
|
+
success: !!res.success,
|
|
715
|
+
};
|
|
716
|
+
if (!res.success) {
|
|
717
|
+
step.error = res.error || 'agent-browser command failed';
|
|
718
|
+
results.push(step);
|
|
719
|
+
if (failFast)
|
|
720
|
+
break;
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
const maxChars = this.maxTextChars;
|
|
724
|
+
if (action.type === 'get' && action.kind === 'text') {
|
|
725
|
+
const text = typeof res.data?.text === 'string' ? res.data.text : typeof res.data?.result === 'string' ? res.data.result : '';
|
|
726
|
+
step.data = { ...(res.data || {}), text: safeTruncate(text, clampInt(action.maxChars ?? maxChars, 1, maxChars)) };
|
|
727
|
+
}
|
|
728
|
+
else if (action.type === 'get' && action.kind === 'html') {
|
|
729
|
+
const html = typeof res.data?.html === 'string' ? res.data.html : '';
|
|
730
|
+
step.data = { ...(res.data || {}), html: safeTruncate(html, clampInt(action.maxChars ?? maxChars, 1, maxChars)) };
|
|
731
|
+
}
|
|
732
|
+
else {
|
|
733
|
+
step.data = res.data ?? undefined;
|
|
734
|
+
}
|
|
735
|
+
results.push(step);
|
|
736
|
+
}
|
|
737
|
+
return { success: true, data: { sessionId: sessionRes.sessionId, artifacts, results } };
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
export function createAgentBrowserToolProvider(options = {}) {
|
|
741
|
+
return new AgentBrowserToolProvider(options);
|
|
742
|
+
}
|
|
743
|
+
export function registerAgentBrowserTools(registry, options = {}) {
|
|
744
|
+
const provider = createAgentBrowserToolProvider(options);
|
|
745
|
+
return registry.registerProvider(provider);
|
|
746
|
+
}
|
|
747
|
+
//# sourceMappingURL=agentBrowser.js.map
|