@kernel.chat/kbot 3.85.0 → 3.87.0

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.
@@ -0,0 +1,1049 @@
1
+ // kbot Built-In Browser — Zero-dependency web engine (no Chrome, no Playwright)
2
+ //
3
+ // A purpose-built browsing engine that kbot owns and controls completely.
4
+ // HTML-only — no JavaScript execution (this is a feature, not a bug).
5
+ // SSRF-protected, rate-limited, session-only cookies.
6
+ //
7
+ // Tools: browser_navigate, browser_search, browser_click,
8
+ // browser_scroll, browser_read, browser_tabs, browser_back
9
+ //
10
+ // Stream integration: drawBrowserPanel() renders a mini browser on the canvas.
11
+ // Chat commands: !browse, !search, !click, !scroll, !tabs
12
+ import { lookup } from 'node:dns/promises';
13
+ import { registerTool } from './index.js';
14
+ // ── Constants ──
15
+ const KBOT_VERSION = '3.86.0';
16
+ const USER_AGENT = `KBot/${KBOT_VERSION} (https://kernel.chat; terminal AI agent)`;
17
+ const MAX_PAGE_SIZE = 2 * 1024 * 1024; // 2MB
18
+ const MAX_CONTENT_LENGTH = 5000; // chars for readable content
19
+ const SCROLL_LINES = 20; // lines per scroll
20
+ const RATE_LIMIT_MS = 2000; // min ms between requests
21
+ const FETCH_TIMEOUT_MS = 15000;
22
+ /** Private/reserved IP patterns — block SSRF */
23
+ const BLOCKED_IP_PATTERNS = [
24
+ /^127\.\d+\.\d+\.\d+$/,
25
+ /^10\.\d+\.\d+\.\d+$/,
26
+ /^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+$/,
27
+ /^192\.168\.\d+\.\d+$/,
28
+ /^0\.0\.0\.0$/,
29
+ /^::1$/,
30
+ /^fd[0-9a-f]{2}:/i,
31
+ /^fe80:/i,
32
+ /^169\.254\.\d+\.\d+$/,
33
+ ];
34
+ const BLOCKED_HOSTNAMES = [
35
+ /^localhost$/i,
36
+ /\.local$/i,
37
+ /^metadata\.google\.internal$/i,
38
+ ];
39
+ const BLOCKED_PROTOCOLS = new Set(['file:', 'data:', 'javascript:', 'vbscript:', 'ftp:']);
40
+ // ── Singleton browser instance ──
41
+ let _browser = null;
42
+ let _lastRequestTime = 0;
43
+ export function getBrowser() {
44
+ if (!_browser) {
45
+ _browser = {
46
+ tabs: [],
47
+ activeTab: -1,
48
+ history: [],
49
+ bookmarks: [],
50
+ cookies: new Map(),
51
+ userAgent: USER_AGENT,
52
+ };
53
+ }
54
+ return _browser;
55
+ }
56
+ /** Reset browser state (for testing or cleanup) */
57
+ export function resetBrowser() {
58
+ _browser = null;
59
+ _lastRequestTime = 0;
60
+ }
61
+ // ── SSRF Protection ──
62
+ function isBlockedIP(ip) {
63
+ return BLOCKED_IP_PATTERNS.some(p => p.test(ip));
64
+ }
65
+ function isBlockedHost(hostname) {
66
+ return BLOCKED_HOSTNAMES.some(p => p.test(hostname));
67
+ }
68
+ async function checkSSRF(hostname) {
69
+ if (isBlockedHost(hostname))
70
+ return 'Blocked: private/reserved hostname';
71
+ // IP literal check
72
+ if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname) || hostname.includes(':')) {
73
+ if (isBlockedIP(hostname))
74
+ return 'Blocked: private/reserved IP range';
75
+ return null;
76
+ }
77
+ // DNS resolution check (catches rebinding attacks)
78
+ try {
79
+ const { address } = await lookup(hostname);
80
+ if (isBlockedIP(address))
81
+ return `Blocked: ${hostname} resolves to private IP ${address}`;
82
+ }
83
+ catch {
84
+ // DNS failure — let fetch handle it
85
+ }
86
+ return null;
87
+ }
88
+ function validateUrl(urlStr) {
89
+ let url;
90
+ try {
91
+ // Auto-prepend https:// if no protocol
92
+ if (!/^https?:\/\//i.test(urlStr) && !urlStr.includes('://')) {
93
+ urlStr = 'https://' + urlStr;
94
+ }
95
+ url = new URL(urlStr);
96
+ }
97
+ catch {
98
+ return { url: null, error: `Invalid URL: ${urlStr}` };
99
+ }
100
+ if (BLOCKED_PROTOCOLS.has(url.protocol)) {
101
+ return { url, error: `Blocked protocol: ${url.protocol} — only http/https allowed` };
102
+ }
103
+ if (!['http:', 'https:'].includes(url.protocol)) {
104
+ return { url, error: `Unsupported protocol: ${url.protocol}` };
105
+ }
106
+ return { url };
107
+ }
108
+ // ── Rate Limiting ──
109
+ async function enforceRateLimit() {
110
+ const now = Date.now();
111
+ const elapsed = now - _lastRequestTime;
112
+ if (elapsed < RATE_LIMIT_MS && _lastRequestTime > 0) {
113
+ await new Promise(resolve => setTimeout(resolve, RATE_LIMIT_MS - elapsed));
114
+ }
115
+ _lastRequestTime = Date.now();
116
+ }
117
+ // ── HTML Parsing ──
118
+ /** Decode common HTML entities */
119
+ function decodeEntities(text) {
120
+ return text
121
+ .replace(/&amp;/g, '&')
122
+ .replace(/&lt;/g, '<')
123
+ .replace(/&gt;/g, '>')
124
+ .replace(/&quot;/g, '"')
125
+ .replace(/&#39;/g, "'")
126
+ .replace(/&#x27;/g, "'")
127
+ .replace(/&apos;/g, "'")
128
+ .replace(/&nbsp;/g, ' ')
129
+ .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)))
130
+ .replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
131
+ }
132
+ /** Extract page title from HTML */
133
+ function extractTitle(html) {
134
+ const match = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
135
+ return match ? decodeEntities(match[1].trim()) : '(untitled)';
136
+ }
137
+ /** Extract all links from HTML */
138
+ function extractLinks(html, baseUrl) {
139
+ const links = [];
140
+ const seen = new Set();
141
+ const regex = /<a\s[^>]*href\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi;
142
+ let match;
143
+ let index = 0;
144
+ while ((match = regex.exec(html)) !== null && index < 200) {
145
+ const rawHref = match[1].trim();
146
+ const rawText = match[2]
147
+ .replace(/<[^>]+>/g, '')
148
+ .replace(/\s+/g, ' ')
149
+ .trim();
150
+ if (!rawHref || rawHref.startsWith('#') || rawHref.startsWith('javascript:'))
151
+ continue;
152
+ let resolved;
153
+ try {
154
+ resolved = new URL(rawHref, baseUrl).href;
155
+ }
156
+ catch {
157
+ continue;
158
+ }
159
+ if (seen.has(resolved))
160
+ continue;
161
+ seen.add(resolved);
162
+ const text = decodeEntities(rawText).slice(0, 100) || resolved;
163
+ links.push({ text, url: resolved, index });
164
+ index++;
165
+ }
166
+ return links;
167
+ }
168
+ /** Extract all forms from HTML */
169
+ function extractForms(html, baseUrl) {
170
+ const forms = [];
171
+ const formRegex = /<form\s[^>]*>([\s\S]*?)<\/form>/gi;
172
+ let formMatch;
173
+ let formIndex = 0;
174
+ while ((formMatch = formRegex.exec(html)) !== null && formIndex < 20) {
175
+ const formTag = formMatch[0];
176
+ const formBody = formMatch[1];
177
+ // Extract action and method
178
+ const actionMatch = formTag.match(/action\s*=\s*["']([^"']+)["']/i);
179
+ const methodMatch = formTag.match(/method\s*=\s*["']([^"']+)["']/i);
180
+ let action;
181
+ try {
182
+ action = actionMatch
183
+ ? new URL(actionMatch[1], baseUrl).href
184
+ : baseUrl;
185
+ }
186
+ catch {
187
+ action = baseUrl;
188
+ }
189
+ const method = (methodMatch?.[1] || 'GET').toUpperCase();
190
+ // Extract input fields
191
+ const fields = [];
192
+ const inputRegex = /<(?:input|textarea|select)\s[^>]*>/gi;
193
+ let inputMatch;
194
+ while ((inputMatch = inputRegex.exec(formBody)) !== null) {
195
+ const tag = inputMatch[0];
196
+ const name = tag.match(/name\s*=\s*["']([^"']+)["']/i)?.[1] || '';
197
+ const type = tag.match(/type\s*=\s*["']([^"']+)["']/i)?.[1] || 'text';
198
+ const value = tag.match(/value\s*=\s*["']([^"']*?)["']/i)?.[1] || '';
199
+ const placeholder = tag.match(/placeholder\s*=\s*["']([^"']*?)["']/i)?.[1] || '';
200
+ if (name && type !== 'hidden' && type !== 'submit') {
201
+ fields.push({ name, type, value: decodeEntities(value), placeholder: decodeEntities(placeholder) });
202
+ }
203
+ }
204
+ forms.push({ action, method, fields, index: formIndex });
205
+ formIndex++;
206
+ }
207
+ return forms;
208
+ }
209
+ /** Extract readable content from HTML (reader mode) */
210
+ export function extractReadableContent(html) {
211
+ let text = html;
212
+ // Remove elements that are not content
213
+ text = text.replace(/<script[\s\S]*?<\/script>/gi, '');
214
+ text = text.replace(/<style[\s\S]*?<\/style>/gi, '');
215
+ text = text.replace(/<nav[\s\S]*?<\/nav>/gi, '');
216
+ text = text.replace(/<footer[\s\S]*?<\/footer>/gi, '');
217
+ text = text.replace(/<header[\s\S]*?<\/header>/gi, '');
218
+ text = text.replace(/<aside[\s\S]*?<\/aside>/gi, '');
219
+ text = text.replace(/<iframe[\s\S]*?<\/iframe>/gi, '');
220
+ text = text.replace(/<noscript[\s\S]*?<\/noscript>/gi, '');
221
+ text = text.replace(/<svg[\s\S]*?<\/svg>/gi, '');
222
+ // Convert headings to markdown
223
+ text = text.replace(/<h1[^>]*>([\s\S]*?)<\/h1>/gi, (_, c) => `\n# ${stripTags(c).trim()}\n`);
224
+ text = text.replace(/<h2[^>]*>([\s\S]*?)<\/h2>/gi, (_, c) => `\n## ${stripTags(c).trim()}\n`);
225
+ text = text.replace(/<h3[^>]*>([\s\S]*?)<\/h3>/gi, (_, c) => `\n### ${stripTags(c).trim()}\n`);
226
+ text = text.replace(/<h4[^>]*>([\s\S]*?)<\/h4>/gi, (_, c) => `\n#### ${stripTags(c).trim()}\n`);
227
+ text = text.replace(/<h5[^>]*>([\s\S]*?)<\/h5>/gi, (_, c) => `\n##### ${stripTags(c).trim()}\n`);
228
+ text = text.replace(/<h6[^>]*>([\s\S]*?)<\/h6>/gi, (_, c) => `\n###### ${stripTags(c).trim()}\n`);
229
+ // Convert links to markdown
230
+ text = text.replace(/<a\s[^>]*href\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi, (_, href, content) => `[${stripTags(content).trim()}](${href})`);
231
+ // Convert images to descriptive text
232
+ text = text.replace(/<img\s[^>]*alt\s*=\s*["']([^"']*?)["'][^>]*>/gi, (_, alt) => alt ? `[image: ${alt}]` : '');
233
+ text = text.replace(/<img[^>]*>/gi, '');
234
+ // Convert list items
235
+ text = text.replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, (_, c) => `\n- ${stripTags(c).trim()}`);
236
+ // Convert blockquotes
237
+ text = text.replace(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/gi, (_, c) => `\n> ${stripTags(c).trim()}\n`);
238
+ // Preserve code blocks
239
+ text = text.replace(/<pre[^>]*>([\s\S]*?)<\/pre>/gi, (_, c) => `\n\`\`\`\n${stripTags(c).trim()}\n\`\`\`\n`);
240
+ text = text.replace(/<code[^>]*>([\s\S]*?)<\/code>/gi, (_, c) => `\`${stripTags(c).trim()}\``);
241
+ // Convert table rows
242
+ text = text.replace(/<tr[^>]*>([\s\S]*?)<\/tr>/gi, (_, row) => {
243
+ const cells = row.match(/<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/gi) || [];
244
+ const values = cells.map((cell) => stripTags(cell.replace(/<t[dh][^>]*>/i, '').replace(/<\/t[dh]>/i, '')).trim());
245
+ return values.length ? `\n| ${values.join(' | ')} |` : '';
246
+ });
247
+ // Convert block elements to newlines
248
+ text = text.replace(/<\/?(p|div|br|section|article|main|table|tbody|thead)[^>]*>/gi, '\n');
249
+ // Strip all remaining tags
250
+ text = stripTags(text);
251
+ // Decode entities
252
+ text = decodeEntities(text);
253
+ // Clean up whitespace
254
+ text = text
255
+ .replace(/[ \t]+/g, ' ')
256
+ .replace(/\n /g, '\n')
257
+ .replace(/ \n/g, '\n')
258
+ .replace(/\n{3,}/g, '\n\n')
259
+ .trim();
260
+ // Truncate
261
+ if (text.length > MAX_CONTENT_LENGTH) {
262
+ text = text.slice(0, MAX_CONTENT_LENGTH) + '\n\n... (truncated)';
263
+ }
264
+ return text;
265
+ }
266
+ /** Strip HTML tags */
267
+ function stripTags(html) {
268
+ return html.replace(/<[^>]+>/g, '');
269
+ }
270
+ // ── ASCII Page Rendering (for stream display) ──
271
+ export function renderPageToAscii(tab, width = 60, height = 30) {
272
+ const lines = [];
273
+ // Navigation bar
274
+ const urlDisplay = tab.url.length > width - 12
275
+ ? tab.url.slice(0, width - 15) + '...'
276
+ : tab.url;
277
+ lines.push(`+${'='.repeat(width - 2)}+`);
278
+ lines.push(`| [< > R] ${urlDisplay}${' '.repeat(Math.max(0, width - 12 - urlDisplay.length))} |`);
279
+ lines.push(`+${'-'.repeat(width - 2)}+`);
280
+ // Status line
281
+ if (tab.status === 'loading') {
282
+ const dots = '.'.repeat((Date.now() / 300 | 0) % 4);
283
+ lines.push(`| Loading${dots}${' '.repeat(Math.max(0, width - 12 - dots.length))} |`);
284
+ }
285
+ else if (tab.status === 'error') {
286
+ const errMsg = (tab.error || 'Error loading page').slice(0, width - 4);
287
+ lines.push(`| ${errMsg}${' '.repeat(Math.max(0, width - 2 - errMsg.length))} |`);
288
+ }
289
+ if (tab.status !== 'loaded') {
290
+ // Fill remaining with empty lines
291
+ while (lines.length < height - 1) {
292
+ lines.push(`|${' '.repeat(width - 2)}|`);
293
+ }
294
+ lines.push(`+${'-'.repeat(width - 2)}+`);
295
+ return lines;
296
+ }
297
+ // Content area: word-wrap the readable content
298
+ const contentLines = wordWrap(tab.content, width - 4);
299
+ const maxContentLines = height - 5; // leave room for chrome
300
+ // Apply scroll offset
301
+ const visibleLines = contentLines.slice(tab.scrollY, tab.scrollY + maxContentLines);
302
+ for (const line of visibleLines) {
303
+ const padded = line.slice(0, width - 4);
304
+ lines.push(`| ${padded}${' '.repeat(Math.max(0, width - 4 - padded.length))} |`);
305
+ }
306
+ // Fill remaining space
307
+ while (lines.length < height - 2) {
308
+ lines.push(`|${' '.repeat(width - 2)}|`);
309
+ }
310
+ // Scroll indicator
311
+ const totalPages = Math.ceil(contentLines.length / maxContentLines);
312
+ const currentPage = Math.floor(tab.scrollY / maxContentLines) + 1;
313
+ const scrollInfo = totalPages > 1 ? `[${currentPage}/${totalPages}]` : '';
314
+ lines.push(`| ${scrollInfo}${' '.repeat(Math.max(0, width - 4 - scrollInfo.length))} |`);
315
+ lines.push(`+${'-'.repeat(width - 2)}+`);
316
+ return lines;
317
+ }
318
+ /** Word-wrap text to a given width */
319
+ function wordWrap(text, maxWidth) {
320
+ const lines = [];
321
+ const inputLines = text.split('\n');
322
+ for (const line of inputLines) {
323
+ if (line.length <= maxWidth) {
324
+ lines.push(line);
325
+ continue;
326
+ }
327
+ // Wrap long lines at word boundaries
328
+ const words = line.split(' ');
329
+ let current = '';
330
+ for (const word of words) {
331
+ if (current.length + word.length + 1 > maxWidth) {
332
+ if (current)
333
+ lines.push(current);
334
+ // If single word is longer than max, break it
335
+ if (word.length > maxWidth) {
336
+ for (let i = 0; i < word.length; i += maxWidth) {
337
+ lines.push(word.slice(i, i + maxWidth));
338
+ }
339
+ current = '';
340
+ }
341
+ else {
342
+ current = word;
343
+ }
344
+ }
345
+ else {
346
+ current = current ? current + ' ' + word : word;
347
+ }
348
+ }
349
+ if (current)
350
+ lines.push(current);
351
+ }
352
+ return lines;
353
+ }
354
+ // ── Core Browser Functions ──
355
+ /** Fetch a URL and create a browser tab */
356
+ async function fetchPage(browser, url) {
357
+ const tab = {
358
+ url,
359
+ title: '(loading)',
360
+ content: '',
361
+ links: [],
362
+ forms: [],
363
+ status: 'loading',
364
+ html: '',
365
+ screenshot: [],
366
+ scrollY: 0,
367
+ loadedAt: Date.now(),
368
+ };
369
+ const { url: parsed, error: urlError } = validateUrl(url);
370
+ if (urlError) {
371
+ tab.status = 'error';
372
+ tab.error = urlError;
373
+ tab.content = urlError;
374
+ return tab;
375
+ }
376
+ tab.url = parsed.href;
377
+ // SSRF check
378
+ const ssrfBlock = await checkSSRF(parsed.hostname);
379
+ if (ssrfBlock) {
380
+ tab.status = 'error';
381
+ tab.error = ssrfBlock;
382
+ tab.content = ssrfBlock;
383
+ return tab;
384
+ }
385
+ // Rate limit
386
+ await enforceRateLimit();
387
+ try {
388
+ const controller = new AbortController();
389
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
390
+ const res = await fetch(parsed.href, {
391
+ headers: {
392
+ 'User-Agent': browser.userAgent,
393
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
394
+ 'Accept-Language': 'en-US,en;q=0.9',
395
+ // Forward cookies for this domain
396
+ ...(browser.cookies.size > 0 ? { 'Cookie': buildCookieHeader(browser, parsed) } : {}),
397
+ },
398
+ signal: controller.signal,
399
+ redirect: 'follow',
400
+ });
401
+ clearTimeout(timeout);
402
+ // Store cookies from response
403
+ const setCookies = res.headers.getSetCookie?.() || [];
404
+ for (const cookie of setCookies) {
405
+ const [nameValue] = cookie.split(';');
406
+ if (nameValue) {
407
+ const [name, ...valueParts] = nameValue.split('=');
408
+ if (name) {
409
+ browser.cookies.set(`${parsed.hostname}:${name.trim()}`, valueParts.join('=').trim());
410
+ }
411
+ }
412
+ }
413
+ if (!res.ok) {
414
+ tab.status = 'error';
415
+ tab.error = `HTTP ${res.status} ${res.statusText}`;
416
+ tab.content = `Error: HTTP ${res.status} ${res.statusText}`;
417
+ return tab;
418
+ }
419
+ // Check content length
420
+ const contentLength = Number(res.headers.get('content-length') || 0);
421
+ if (contentLength > MAX_PAGE_SIZE) {
422
+ tab.status = 'error';
423
+ tab.error = `Page too large: ${(contentLength / 1024 / 1024).toFixed(1)}MB (max: 2MB)`;
424
+ tab.content = tab.error;
425
+ return tab;
426
+ }
427
+ const html = await res.text();
428
+ if (html.length > MAX_PAGE_SIZE) {
429
+ tab.status = 'error';
430
+ tab.error = `Page too large: ${(html.length / 1024 / 1024).toFixed(1)}MB (max: 2MB)`;
431
+ tab.content = tab.error;
432
+ return tab;
433
+ }
434
+ tab.html = html;
435
+ tab.title = extractTitle(html);
436
+ tab.links = extractLinks(html, parsed.href);
437
+ tab.forms = extractForms(html, parsed.href);
438
+ tab.content = extractReadableContent(html);
439
+ tab.status = 'loaded';
440
+ tab.loadedAt = Date.now();
441
+ tab.screenshot = renderPageToAscii(tab);
442
+ }
443
+ catch (err) {
444
+ tab.status = 'error';
445
+ if (err instanceof Error && err.name === 'AbortError') {
446
+ tab.error = 'Request timed out (15s)';
447
+ }
448
+ else {
449
+ tab.error = err instanceof Error ? err.message : String(err);
450
+ }
451
+ tab.content = `Error: ${tab.error}`;
452
+ }
453
+ return tab;
454
+ }
455
+ /** Build Cookie header for a domain */
456
+ function buildCookieHeader(browser, url) {
457
+ const pairs = [];
458
+ for (const [key, value] of browser.cookies) {
459
+ if (key.startsWith(url.hostname + ':')) {
460
+ const name = key.slice(url.hostname.length + 1);
461
+ pairs.push(`${name}=${value}`);
462
+ }
463
+ }
464
+ return pairs.join('; ');
465
+ }
466
+ // ── Public API ──
467
+ /** Navigate the active tab (or create one) to a URL */
468
+ export async function navigateTo(browser, url) {
469
+ const tab = await fetchPage(browser, url);
470
+ if (browser.tabs.length === 0 || browser.activeTab < 0) {
471
+ browser.tabs.push(tab);
472
+ browser.activeTab = 0;
473
+ }
474
+ else {
475
+ browser.tabs[browser.activeTab] = tab;
476
+ }
477
+ if (tab.status === 'loaded') {
478
+ browser.history.push(tab.url);
479
+ }
480
+ return tab;
481
+ }
482
+ /** Click a link by index on the current page */
483
+ export async function clickLink(browser, linkIndex) {
484
+ if (browser.activeTab < 0 || browser.activeTab >= browser.tabs.length) {
485
+ throw new Error('No active tab — navigate to a page first');
486
+ }
487
+ const currentTab = browser.tabs[browser.activeTab];
488
+ const link = currentTab.links.find(l => l.index === linkIndex);
489
+ if (!link) {
490
+ throw new Error(`Link [${linkIndex}] not found. Available links: 0-${currentTab.links.length - 1}`);
491
+ }
492
+ return navigateTo(browser, link.url);
493
+ }
494
+ /** Fill and submit a form by index */
495
+ export async function fillForm(browser, formIndex, values) {
496
+ if (browser.activeTab < 0 || browser.activeTab >= browser.tabs.length) {
497
+ throw new Error('No active tab — navigate to a page first');
498
+ }
499
+ const currentTab = browser.tabs[browser.activeTab];
500
+ const form = currentTab.forms.find(f => f.index === formIndex);
501
+ if (!form) {
502
+ throw new Error(`Form [${formIndex}] not found. Available forms: 0-${currentTab.forms.length - 1}`);
503
+ }
504
+ // Set values
505
+ for (const field of form.fields) {
506
+ if (values[field.name] !== undefined) {
507
+ field.value = values[field.name];
508
+ }
509
+ }
510
+ // Build request
511
+ const params = new URLSearchParams();
512
+ for (const field of form.fields) {
513
+ if (field.value)
514
+ params.set(field.name, field.value);
515
+ }
516
+ if (form.method === 'GET') {
517
+ const url = new URL(form.action);
518
+ url.search = params.toString();
519
+ return navigateTo(browser, url.href);
520
+ }
521
+ // POST submission
522
+ await enforceRateLimit();
523
+ const { url: parsed, error: urlError } = validateUrl(form.action);
524
+ if (urlError) {
525
+ const tab = browser.tabs[browser.activeTab];
526
+ tab.status = 'error';
527
+ tab.error = urlError;
528
+ tab.content = urlError;
529
+ return tab;
530
+ }
531
+ const ssrfBlock = await checkSSRF(parsed.hostname);
532
+ if (ssrfBlock) {
533
+ const tab = browser.tabs[browser.activeTab];
534
+ tab.status = 'error';
535
+ tab.error = ssrfBlock;
536
+ tab.content = ssrfBlock;
537
+ return tab;
538
+ }
539
+ try {
540
+ const controller = new AbortController();
541
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
542
+ const res = await fetch(parsed.href, {
543
+ method: 'POST',
544
+ headers: {
545
+ 'User-Agent': browser.userAgent,
546
+ 'Content-Type': 'application/x-www-form-urlencoded',
547
+ 'Accept': 'text/html,application/xhtml+xml,*/*',
548
+ ...(browser.cookies.size > 0 ? { 'Cookie': buildCookieHeader(browser, parsed) } : {}),
549
+ },
550
+ body: params.toString(),
551
+ signal: controller.signal,
552
+ redirect: 'follow',
553
+ });
554
+ clearTimeout(timeout);
555
+ const html = await res.text();
556
+ const tab = {
557
+ url: res.url || parsed.href,
558
+ title: extractTitle(html),
559
+ content: extractReadableContent(html),
560
+ links: extractLinks(html, res.url || parsed.href),
561
+ forms: extractForms(html, res.url || parsed.href),
562
+ status: res.ok ? 'loaded' : 'error',
563
+ html,
564
+ screenshot: [],
565
+ scrollY: 0,
566
+ loadedAt: Date.now(),
567
+ error: res.ok ? undefined : `HTTP ${res.status} ${res.statusText}`,
568
+ };
569
+ tab.screenshot = renderPageToAscii(tab);
570
+ browser.tabs[browser.activeTab] = tab;
571
+ if (tab.status === 'loaded') {
572
+ browser.history.push(tab.url);
573
+ }
574
+ return tab;
575
+ }
576
+ catch (err) {
577
+ const tab = browser.tabs[browser.activeTab];
578
+ tab.status = 'error';
579
+ tab.error = err instanceof Error ? err.message : String(err);
580
+ tab.content = `Error: ${tab.error}`;
581
+ return tab;
582
+ }
583
+ }
584
+ /** Search the web via DuckDuckGo HTML (no JS needed) */
585
+ export async function search(browser, query) {
586
+ const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
587
+ return navigateTo(browser, url);
588
+ }
589
+ /** Scroll the current page */
590
+ export function scroll(browser, direction) {
591
+ if (browser.activeTab < 0 || browser.activeTab >= browser.tabs.length)
592
+ return null;
593
+ const tab = browser.tabs[browser.activeTab];
594
+ if (direction === 'down') {
595
+ tab.scrollY = Math.min(tab.scrollY + SCROLL_LINES, Math.max(0, wordWrap(tab.content, 56).length - SCROLL_LINES));
596
+ }
597
+ else {
598
+ tab.scrollY = Math.max(0, tab.scrollY - SCROLL_LINES);
599
+ }
600
+ tab.screenshot = renderPageToAscii(tab);
601
+ return tab;
602
+ }
603
+ /** Go back in history */
604
+ export async function goBack(browser) {
605
+ if (browser.history.length < 2)
606
+ return null;
607
+ // Remove current page from history
608
+ browser.history.pop();
609
+ const previousUrl = browser.history[browser.history.length - 1];
610
+ // Navigate (will re-add to history)
611
+ browser.history.pop(); // navigateTo will re-push
612
+ return navigateTo(browser, previousUrl);
613
+ }
614
+ /** Open a new tab */
615
+ export function newTab(browser, url) {
616
+ const tab = {
617
+ url: url || 'about:blank',
618
+ title: 'New Tab',
619
+ content: '',
620
+ links: [],
621
+ forms: [],
622
+ status: 'loaded',
623
+ html: '',
624
+ screenshot: [],
625
+ scrollY: 0,
626
+ loadedAt: Date.now(),
627
+ };
628
+ browser.tabs.push(tab);
629
+ browser.activeTab = browser.tabs.length - 1;
630
+ }
631
+ /** Close a tab */
632
+ export function closeTab(browser, tabIndex) {
633
+ if (tabIndex < 0 || tabIndex >= browser.tabs.length)
634
+ return;
635
+ browser.tabs.splice(tabIndex, 1);
636
+ if (browser.tabs.length === 0) {
637
+ browser.activeTab = -1;
638
+ }
639
+ else if (browser.activeTab >= browser.tabs.length) {
640
+ browser.activeTab = browser.tabs.length - 1;
641
+ }
642
+ else if (tabIndex < browser.activeTab) {
643
+ browser.activeTab--;
644
+ }
645
+ }
646
+ /** Switch to a tab */
647
+ export function switchTab(browser, tabIndex) {
648
+ if (tabIndex >= 0 && tabIndex < browser.tabs.length) {
649
+ browser.activeTab = tabIndex;
650
+ }
651
+ }
652
+ // ── Format output for CLI ──
653
+ function formatTabOutput(tab) {
654
+ if (tab.status === 'error') {
655
+ return `Error: ${tab.error}\nURL: ${tab.url}`;
656
+ }
657
+ const parts = [];
658
+ parts.push(`# ${tab.title}`);
659
+ parts.push(`URL: ${tab.url}`);
660
+ parts.push('');
661
+ // Content
662
+ parts.push(tab.content);
663
+ // Links summary (first 30)
664
+ if (tab.links.length > 0) {
665
+ parts.push('');
666
+ parts.push('---');
667
+ parts.push(`Links (${tab.links.length} found):`);
668
+ const maxLinks = Math.min(tab.links.length, 30);
669
+ for (let i = 0; i < maxLinks; i++) {
670
+ const link = tab.links[i];
671
+ parts.push(` [${link.index}] ${link.text}`);
672
+ }
673
+ if (tab.links.length > 30) {
674
+ parts.push(` ... and ${tab.links.length - 30} more`);
675
+ }
676
+ }
677
+ // Forms summary
678
+ if (tab.forms.length > 0) {
679
+ parts.push('');
680
+ parts.push(`Forms (${tab.forms.length} found):`);
681
+ for (const form of tab.forms) {
682
+ parts.push(` [Form ${form.index}] ${form.method} ${form.action}`);
683
+ for (const field of form.fields) {
684
+ parts.push(` - ${field.name} (${field.type})${field.placeholder ? ` placeholder: "${field.placeholder}"` : ''}`);
685
+ }
686
+ }
687
+ }
688
+ return parts.join('\n');
689
+ }
690
+ // ── Stream Integration ──
691
+ /** Draw a browser panel on a canvas (for stream overlay) */
692
+ export function drawBrowserPanel(ctx, browser, x, y, width, height, frame) {
693
+ const tab = browser.activeTab >= 0 ? browser.tabs[browser.activeTab] : null;
694
+ // Background
695
+ ctx.fillStyle = '#1a1b26';
696
+ ctx.fillRect(x, y, width, height);
697
+ // Border
698
+ ctx.strokeStyle = '#3fb950';
699
+ ctx.lineWidth = 2;
700
+ ctx.strokeRect(x, y, width, height);
701
+ const lineHeight = 14;
702
+ const padding = 8;
703
+ let cy = y + padding;
704
+ // Title bar with tabs
705
+ ctx.fillStyle = '#21222c';
706
+ ctx.fillRect(x + 1, y + 1, width - 2, 24);
707
+ ctx.font = '11px monospace';
708
+ let tabX = x + padding;
709
+ for (let i = 0; i < browser.tabs.length && i < 5; i++) {
710
+ const t = browser.tabs[i];
711
+ const label = (t.title || 'Tab').slice(0, 15);
712
+ const isActive = i === browser.activeTab;
713
+ ctx.fillStyle = isActive ? '#3fb950' : '#565f89';
714
+ ctx.fillText(label, tabX, cy + 15);
715
+ tabX += label.length * 7 + 12;
716
+ }
717
+ cy += 26;
718
+ // URL bar
719
+ ctx.fillStyle = '#15161e';
720
+ ctx.fillRect(x + padding, cy, width - padding * 2, 20);
721
+ ctx.strokeStyle = '#414868';
722
+ ctx.strokeRect(x + padding, cy, width - padding * 2, 20);
723
+ ctx.fillStyle = '#c0caf5';
724
+ ctx.font = '10px monospace';
725
+ const urlText = tab
726
+ ? tab.url.slice(0, Math.floor((width - padding * 4) / 6))
727
+ : 'about:blank';
728
+ ctx.fillText(urlText, x + padding + 4, cy + 14);
729
+ cy += 26;
730
+ if (!tab) {
731
+ ctx.fillStyle = '#565f89';
732
+ ctx.fillText('Navigate to a URL to begin', x + padding, cy + lineHeight);
733
+ return;
734
+ }
735
+ // Loading spinner
736
+ if (tab.status === 'loading') {
737
+ const spinChars = ['|', '/', '-', '\\'];
738
+ const spin = spinChars[frame % spinChars.length];
739
+ ctx.fillStyle = '#7aa2f7';
740
+ ctx.font = '12px monospace';
741
+ ctx.fillText(`${spin} Loading...`, x + padding, cy + lineHeight);
742
+ return;
743
+ }
744
+ // Error display
745
+ if (tab.status === 'error') {
746
+ ctx.fillStyle = '#f7768e';
747
+ ctx.font = '11px monospace';
748
+ ctx.fillText(tab.error || 'Error', x + padding, cy + lineHeight);
749
+ return;
750
+ }
751
+ // Content area — render the ASCII screenshot
752
+ ctx.fillStyle = '#a9b1d6';
753
+ ctx.font = '10px monospace';
754
+ const charWidth = 6;
755
+ const maxChars = Math.floor((width - padding * 2) / charWidth);
756
+ const maxLines = Math.floor((height - (cy - y) - padding) / lineHeight);
757
+ const contentLines = wordWrap(tab.content, maxChars);
758
+ const visible = contentLines.slice(tab.scrollY, tab.scrollY + maxLines);
759
+ for (let i = 0; i < visible.length; i++) {
760
+ const line = visible[i];
761
+ // Color headings
762
+ if (line.startsWith('#')) {
763
+ ctx.fillStyle = '#bb9af7';
764
+ }
765
+ else if (line.startsWith('[') && line.includes(']')) {
766
+ ctx.fillStyle = '#7aa2f7';
767
+ }
768
+ else if (line.startsWith('-')) {
769
+ ctx.fillStyle = '#73daca';
770
+ }
771
+ else {
772
+ ctx.fillStyle = '#a9b1d6';
773
+ }
774
+ ctx.fillText(line.slice(0, maxChars), x + padding, cy + lineHeight * (i + 1));
775
+ }
776
+ // Scroll bar
777
+ if (contentLines.length > maxLines) {
778
+ const barHeight = height - (cy - y) - padding * 2;
779
+ const thumbHeight = Math.max(20, (maxLines / contentLines.length) * barHeight);
780
+ const thumbPos = (tab.scrollY / Math.max(1, contentLines.length - maxLines)) * (barHeight - thumbHeight);
781
+ ctx.fillStyle = '#414868';
782
+ ctx.fillRect(x + width - 6, cy, 4, barHeight);
783
+ ctx.fillStyle = '#7aa2f7';
784
+ ctx.fillRect(x + width - 6, cy + thumbPos, 4, thumbHeight);
785
+ }
786
+ }
787
+ // ── Stream Chat Commands ──
788
+ /** Parse stream chat commands for browser interaction. Returns action string or null. */
789
+ export function parseStreamBrowserCommand(message) {
790
+ const trimmed = message.trim().toLowerCase();
791
+ if (trimmed.startsWith('!browse ')) {
792
+ return { command: 'browse', args: message.trim().slice(8).trim() };
793
+ }
794
+ if (trimmed.startsWith('!search ')) {
795
+ return { command: 'search', args: message.trim().slice(8).trim() };
796
+ }
797
+ if (trimmed.startsWith('!click ')) {
798
+ return { command: 'click', args: message.trim().slice(7).trim() };
799
+ }
800
+ if (trimmed === '!scroll' || trimmed === '!scroll down') {
801
+ return { command: 'scroll', args: 'down' };
802
+ }
803
+ if (trimmed === '!scroll up') {
804
+ return { command: 'scroll', args: 'up' };
805
+ }
806
+ if (trimmed === '!tabs') {
807
+ return { command: 'tabs', args: '' };
808
+ }
809
+ return null;
810
+ }
811
+ /** Handle a stream chat browser command. Returns a response string for the chat. */
812
+ export async function handleStreamBrowserCommand(command, args) {
813
+ const browser = getBrowser();
814
+ switch (command) {
815
+ case 'browse': {
816
+ const tab = await navigateTo(browser, args);
817
+ if (tab.status === 'error') {
818
+ return { response: `Could not load ${args}: ${tab.error}`, mood: 'sad' };
819
+ }
820
+ return {
821
+ response: `Loaded: ${tab.title} — ${tab.links.length} links found. ${tab.content.slice(0, 120)}...`,
822
+ mood: 'thinking',
823
+ };
824
+ }
825
+ case 'search': {
826
+ const tab = await search(browser, args);
827
+ if (tab.status === 'error') {
828
+ return { response: `Search failed: ${tab.error}`, mood: 'sad' };
829
+ }
830
+ const topLinks = tab.links.slice(0, 5).map((l, i) => `[${i}] ${l.text}`).join(', ');
831
+ return {
832
+ response: `Searched for "${args}" — top results: ${topLinks}`,
833
+ mood: 'thinking',
834
+ };
835
+ }
836
+ case 'click': {
837
+ const idx = parseInt(args, 10);
838
+ if (isNaN(idx)) {
839
+ return { response: 'Specify a link number, e.g. !click 3', mood: 'confused' };
840
+ }
841
+ try {
842
+ const tab = await clickLink(browser, idx);
843
+ if (tab.status === 'error') {
844
+ return { response: `Link error: ${tab.error}`, mood: 'sad' };
845
+ }
846
+ return {
847
+ response: `Navigated to: ${tab.title}`,
848
+ mood: 'thinking',
849
+ };
850
+ }
851
+ catch (err) {
852
+ return { response: err instanceof Error ? err.message : String(err), mood: 'confused' };
853
+ }
854
+ }
855
+ case 'scroll': {
856
+ const tab = scroll(browser, args === 'up' ? 'up' : 'down');
857
+ if (!tab) {
858
+ return { response: 'No page loaded to scroll', mood: 'confused' };
859
+ }
860
+ return { response: `Scrolled ${args}`, mood: 'idle' };
861
+ }
862
+ case 'tabs': {
863
+ if (browser.tabs.length === 0) {
864
+ return { response: 'No tabs open', mood: 'idle' };
865
+ }
866
+ const tabList = browser.tabs.map((t, i) => `${i === browser.activeTab ? '>' : ' '} [${i}] ${t.title || t.url}`).join('\n');
867
+ return { response: tabList, mood: 'idle' };
868
+ }
869
+ default:
870
+ return { response: 'Unknown browser command', mood: 'confused' };
871
+ }
872
+ }
873
+ // ── Tool Registration ──
874
+ export function registerKBotBrowserTools() {
875
+ registerTool({
876
+ name: 'kbot_browse',
877
+ description: "Navigate kbot's built-in browser to a URL. No Chrome or Playwright needed. Returns page content, links, and forms. SSRF-protected.",
878
+ parameters: {
879
+ url: { type: 'string', description: 'URL to navigate to', required: true },
880
+ },
881
+ tier: 'free',
882
+ async execute(args) {
883
+ const url = String(args.url);
884
+ const browser = getBrowser();
885
+ const tab = await navigateTo(browser, url);
886
+ return formatTabOutput(tab);
887
+ },
888
+ });
889
+ registerTool({
890
+ name: 'kbot_search',
891
+ description: "Search the web using kbot's built-in browser via DuckDuckGo. No external dependencies.",
892
+ parameters: {
893
+ query: { type: 'string', description: 'Search query', required: true },
894
+ },
895
+ tier: 'free',
896
+ async execute(args) {
897
+ const query = String(args.query);
898
+ const browser = getBrowser();
899
+ const tab = await search(browser, query);
900
+ return formatTabOutput(tab);
901
+ },
902
+ });
903
+ registerTool({
904
+ name: 'kbot_click',
905
+ description: 'Click a link on the current page by its index number (shown as [N] in page content).',
906
+ parameters: {
907
+ link_index: { type: 'string', description: 'Link index number', required: true },
908
+ },
909
+ tier: 'free',
910
+ async execute(args) {
911
+ const idx = parseInt(String(args.link_index), 10);
912
+ if (isNaN(idx))
913
+ return 'Error: link_index must be a number';
914
+ const browser = getBrowser();
915
+ try {
916
+ const tab = await clickLink(browser, idx);
917
+ return formatTabOutput(tab);
918
+ }
919
+ catch (err) {
920
+ return `Error: ${err instanceof Error ? err.message : String(err)}`;
921
+ }
922
+ },
923
+ });
924
+ registerTool({
925
+ name: 'kbot_scroll',
926
+ description: 'Scroll the current page up or down in the built-in browser.',
927
+ parameters: {
928
+ direction: { type: 'string', description: '"up" or "down"', required: true },
929
+ },
930
+ tier: 'free',
931
+ async execute(args) {
932
+ const dir = String(args.direction).toLowerCase() === 'up' ? 'up' : 'down';
933
+ const browser = getBrowser();
934
+ const tab = scroll(browser, dir);
935
+ if (!tab)
936
+ return 'No page loaded. Navigate to a URL first.';
937
+ return formatTabOutput(tab);
938
+ },
939
+ });
940
+ registerTool({
941
+ name: 'kbot_read',
942
+ description: "Get the current page content in reader mode (clean text, no clutter). Uses kbot's built-in browser.",
943
+ parameters: {},
944
+ tier: 'free',
945
+ async execute() {
946
+ const browser = getBrowser();
947
+ if (browser.activeTab < 0 || browser.activeTab >= browser.tabs.length) {
948
+ return 'No page loaded. Navigate to a URL first.';
949
+ }
950
+ const tab = browser.tabs[browser.activeTab];
951
+ return `# ${tab.title}\nURL: ${tab.url}\n\n${tab.content}`;
952
+ },
953
+ });
954
+ registerTool({
955
+ name: 'kbot_tabs',
956
+ description: 'Manage browser tabs: list, new, close, or switch.',
957
+ parameters: {
958
+ action: { type: 'string', description: '"list", "new", "close", or "switch"', required: true },
959
+ index: { type: 'string', description: 'Tab index (for close/switch)' },
960
+ url: { type: 'string', description: 'URL (for new tab)' },
961
+ },
962
+ tier: 'free',
963
+ async execute(args) {
964
+ const action = String(args.action).toLowerCase();
965
+ const browser = getBrowser();
966
+ switch (action) {
967
+ case 'list': {
968
+ if (browser.tabs.length === 0)
969
+ return 'No tabs open.';
970
+ return browser.tabs.map((t, i) => `${i === browser.activeTab ? '* ' : ' '}[${i}] ${t.title} — ${t.url}`).join('\n');
971
+ }
972
+ case 'new': {
973
+ const url = args.url ? String(args.url) : undefined;
974
+ newTab(browser, url);
975
+ if (url) {
976
+ const tab = await navigateTo(browser, url);
977
+ return formatTabOutput(tab);
978
+ }
979
+ return `Opened new tab [${browser.activeTab}]`;
980
+ }
981
+ case 'close': {
982
+ const idx = parseInt(String(args.index || browser.activeTab), 10);
983
+ if (isNaN(idx))
984
+ return 'Error: specify tab index';
985
+ if (idx < 0 || idx >= browser.tabs.length)
986
+ return `Error: tab ${idx} does not exist`;
987
+ const title = browser.tabs[idx].title;
988
+ closeTab(browser, idx);
989
+ return `Closed tab: ${title}. ${browser.tabs.length} tab(s) remaining.`;
990
+ }
991
+ case 'switch': {
992
+ const idx = parseInt(String(args.index), 10);
993
+ if (isNaN(idx))
994
+ return 'Error: specify tab index';
995
+ if (idx < 0 || idx >= browser.tabs.length)
996
+ return `Error: tab ${idx} does not exist`;
997
+ switchTab(browser, idx);
998
+ const tab = browser.tabs[idx];
999
+ return `Switched to tab [${idx}]: ${tab.title}\nURL: ${tab.url}`;
1000
+ }
1001
+ default:
1002
+ return 'Unknown action. Use: list, new, close, or switch.';
1003
+ }
1004
+ },
1005
+ });
1006
+ registerTool({
1007
+ name: 'kbot_back',
1008
+ description: 'Go back to the previous page in browser history.',
1009
+ parameters: {},
1010
+ tier: 'free',
1011
+ async execute() {
1012
+ const browser = getBrowser();
1013
+ const tab = await goBack(browser);
1014
+ if (!tab)
1015
+ return 'No history to go back to.';
1016
+ return formatTabOutput(tab);
1017
+ },
1018
+ });
1019
+ registerTool({
1020
+ name: 'kbot_form',
1021
+ description: 'Fill and submit a form on the current page.',
1022
+ parameters: {
1023
+ form_index: { type: 'string', description: 'Form index number', required: true },
1024
+ values: { type: 'string', description: 'JSON object of field name → value pairs, e.g. {"q": "search term"}', required: true },
1025
+ },
1026
+ tier: 'free',
1027
+ async execute(args) {
1028
+ const formIdx = parseInt(String(args.form_index), 10);
1029
+ if (isNaN(formIdx))
1030
+ return 'Error: form_index must be a number';
1031
+ let values;
1032
+ try {
1033
+ values = JSON.parse(String(args.values));
1034
+ }
1035
+ catch {
1036
+ return 'Error: values must be valid JSON, e.g. {"q": "search term"}';
1037
+ }
1038
+ const browser = getBrowser();
1039
+ try {
1040
+ const tab = await fillForm(browser, formIdx, values);
1041
+ return formatTabOutput(tab);
1042
+ }
1043
+ catch (err) {
1044
+ return `Error: ${err instanceof Error ? err.message : String(err)}`;
1045
+ }
1046
+ },
1047
+ });
1048
+ }
1049
+ //# sourceMappingURL=kbot-browser.js.map