@modelprofile.com/browser-runtime 1.0.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.
Files changed (51) hide show
  1. package/.smartconfig.json +34 -0
  2. package/changelog.md +11 -0
  3. package/dist_ts/00_commitinfo_data.d.ts +8 -0
  4. package/dist_ts/00_commitinfo_data.js +9 -0
  5. package/dist_ts/actions.d.ts +3 -0
  6. package/dist_ts/actions.js +215 -0
  7. package/dist_ts/classes.artifactstore.d.ts +38 -0
  8. package/dist_ts/classes.artifactstore.js +344 -0
  9. package/dist_ts/classes.egressproxy.d.ts +67 -0
  10. package/dist_ts/classes.egressproxy.js +830 -0
  11. package/dist_ts/classes.flexprovider.d.ts +9 -0
  12. package/dist_ts/classes.flexprovider.js +117 -0
  13. package/dist_ts/classes.framed.d.ts +52 -0
  14. package/dist_ts/classes.framed.js +557 -0
  15. package/dist_ts/classes.runtime.d.ts +202 -0
  16. package/dist_ts/classes.runtime.js +1667 -0
  17. package/dist_ts/confinement.d.ts +2 -0
  18. package/dist_ts/confinement.js +63 -0
  19. package/dist_ts/errors.d.ts +7 -0
  20. package/dist_ts/errors.js +40 -0
  21. package/dist_ts/index.d.ts +11 -0
  22. package/dist_ts/index.js +9 -0
  23. package/dist_ts/interfaces.d.ts +287 -0
  24. package/dist_ts/interfaces.js +2 -0
  25. package/dist_ts/internal.testing.d.ts +9 -0
  26. package/dist_ts/internal.testing.js +2 -0
  27. package/dist_ts/mcp.d.ts +4 -0
  28. package/dist_ts/mcp.js +196 -0
  29. package/dist_ts/plugins.d.ts +20 -0
  30. package/dist_ts/plugins.js +24 -0
  31. package/dist_ts/utils.d.ts +25 -0
  32. package/dist_ts/utils.js +143 -0
  33. package/license.md +21 -0
  34. package/package.json +59 -0
  35. package/readme.hints.md +35 -0
  36. package/readme.md +181 -0
  37. package/ts/00_commitinfo_data.ts +8 -0
  38. package/ts/actions.ts +241 -0
  39. package/ts/classes.artifactstore.ts +432 -0
  40. package/ts/classes.egressproxy.ts +1005 -0
  41. package/ts/classes.flexprovider.ts +134 -0
  42. package/ts/classes.framed.ts +649 -0
  43. package/ts/classes.runtime.ts +2135 -0
  44. package/ts/confinement.ts +90 -0
  45. package/ts/errors.ts +63 -0
  46. package/ts/index.ts +52 -0
  47. package/ts/interfaces.ts +375 -0
  48. package/ts/internal.testing.ts +18 -0
  49. package/ts/mcp.ts +230 -0
  50. package/ts/plugins.ts +28 -0
  51. package/ts/utils.ts +188 -0
package/ts/mcp.ts ADDED
@@ -0,0 +1,230 @@
1
+ import * as plugins from './plugins.js';
2
+ import type { BrowserRuntime, BrowserRuntimeLease } from './classes.runtime.js';
3
+ import { BrowserRuntimeError } from './errors.js';
4
+ import { commitinfo } from './00_commitinfo_data.js';
5
+ import type { IBrowserRuntimeMcpOptions } from './interfaces.js';
6
+ import { validateAgentActionResult } from './actions.js';
7
+ import {
8
+ validateBoundedString,
9
+ validateInteger,
10
+ } from './utils.js';
11
+
12
+ const parseBearerToken = (request: Request): string => {
13
+ const authorization = request.headers.get('authorization');
14
+ if (!authorization?.startsWith('Bearer ') || authorization.includes(',')) {
15
+ throw new BrowserRuntimeError('CAPABILITY_INVALID');
16
+ }
17
+ return validateBoundedString(
18
+ authorization.slice('Bearer '.length),
19
+ 'bearer capability',
20
+ 16,
21
+ 512,
22
+ );
23
+ };
24
+
25
+ const validateStringList = (value: unknown, name: string): string[] | undefined => {
26
+ if (value === undefined) return undefined;
27
+ if (!Array.isArray(value) || value.length > 64) throw new BrowserRuntimeError('INVALID_INPUT');
28
+ return value.map((entry) => validateBoundedString(entry, name, 1, 2048));
29
+ };
30
+
31
+ export const createBrowserRuntimeMcpHttpHandler = (
32
+ runtime: BrowserRuntime,
33
+ options: IBrowserRuntimeMcpOptions,
34
+ ): plugins.smartmcp.ISmartMcpHttpHandler => {
35
+ if (!runtime || !options || typeof options.authenticateMcpRequest !== 'function') {
36
+ throw new BrowserRuntimeError('INVALID_INPUT');
37
+ }
38
+ const trustedOrigins = validateStringList(options.trustedOrigins, 'trustedOrigin');
39
+ const allowedHosts = validateStringList(options.allowedHosts, 'allowedHost');
40
+ if (options.allowMissingOrigin !== undefined && typeof options.allowMissingOrigin !== 'boolean') {
41
+ throw new BrowserRuntimeError('INVALID_INPUT');
42
+ }
43
+ const maxRequestBytes = options.maxRequestBytes === undefined
44
+ ? 256 * 1024
45
+ : validateInteger(options.maxRequestBytes, 'maxRequestBytes', 1024, 1024 * 1024);
46
+ const maxToolResultBytes = options.maxToolResultBytes === undefined
47
+ ? 256 * 1024
48
+ : validateInteger(options.maxToolResultBytes, 'maxToolResultBytes', 1024, 256 * 1024);
49
+
50
+ const withLease = async <T>(
51
+ context: plugins.smartmcp.ISmartMcpToolContext,
52
+ action: (lease: BrowserRuntimeLease, signal: AbortSignal) => Promise<T>,
53
+ ): Promise<T> => {
54
+ const peerId = validateBoundedString(context.authInfo?.clientId, 'peerId', 1, 128);
55
+ const capabilityToken = parseBearerToken(context.request);
56
+ const signal = AbortSignal.any([context.extra.signal, context.request.signal]);
57
+ signal.throwIfAborted();
58
+ const descriptor = runtime.authorizeCapability(capabilityToken, peerId, 'agent', 'mcp');
59
+ if (descriptor.capabilityId !== context.authInfo?.token) {
60
+ throw new BrowserRuntimeError('CAPABILITY_INVALID');
61
+ }
62
+ const lease = await runtime.acquireLease({
63
+ capabilityToken,
64
+ peerId,
65
+ expectedRole: 'agent',
66
+ expectedSource: 'mcp',
67
+ signal,
68
+ });
69
+ try {
70
+ signal.throwIfAborted();
71
+ return await action(lease, signal);
72
+ } finally {
73
+ await lease.release();
74
+ }
75
+ };
76
+
77
+ const tabIdSchema = plugins.smartmcp.z.string().min(1).max(128).optional();
78
+ const timeoutSchema = plugins.smartmcp.z.number().int().min(100).max(120_000).optional();
79
+ const tools: plugins.smartmcp.ISmartMcpToolDefinition[] = [
80
+ {
81
+ name: 'browser_snapshot',
82
+ description: 'Read bounded accessibility text from a browser tab.',
83
+ inputSchema: plugins.smartmcp.z.object({
84
+ tabId: tabIdSchema,
85
+ maxCharacters: plugins.smartmcp.z.number().int().min(256).max(50_000).optional(),
86
+ }).strict(),
87
+ annotations: { readOnlyHint: true, openWorldHint: false },
88
+ handler: async (args, context) => withLease(context, async (lease, signal) => (
89
+ validateAgentActionResult(await lease.executeAgentAction({
90
+ action: 'snapshot',
91
+ ...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}),
92
+ ...(typeof args.maxCharacters === 'number'
93
+ ? { maxCharacters: args.maxCharacters }
94
+ : {}),
95
+ }, { signal }))
96
+ )),
97
+ },
98
+ {
99
+ name: 'browser_navigate',
100
+ description: 'Navigate a browser tab to an HTTP or HTTPS URL.',
101
+ inputSchema: plugins.smartmcp.z.object({
102
+ url: plugins.smartmcp.z.string().min(1).max(8192),
103
+ tabId: tabIdSchema,
104
+ timeoutMs: timeoutSchema,
105
+ }).strict(),
106
+ annotations: { readOnlyHint: false, openWorldHint: true },
107
+ handler: async (args, context) => withLease(context, async (lease, signal) => (
108
+ validateAgentActionResult(await lease.executeAgentAction({
109
+ action: 'navigate',
110
+ url: String(args.url),
111
+ ...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}),
112
+ ...(typeof args.timeoutMs === 'number' ? { timeoutMs: args.timeoutMs } : {}),
113
+ }, { signal }))
114
+ )),
115
+ },
116
+ {
117
+ name: 'browser_click',
118
+ description: 'Click an element selected from the observed page.',
119
+ inputSchema: plugins.smartmcp.z.object({
120
+ selector: plugins.smartmcp.z.string().min(1).max(4096),
121
+ tabId: tabIdSchema,
122
+ timeoutMs: timeoutSchema,
123
+ }).strict(),
124
+ annotations: { readOnlyHint: false, openWorldHint: true },
125
+ handler: async (args, context) => withLease(context, async (lease, signal) => (
126
+ validateAgentActionResult(await lease.executeAgentAction({
127
+ action: 'click',
128
+ selector: String(args.selector),
129
+ ...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}),
130
+ ...(typeof args.timeoutMs === 'number' ? { timeoutMs: args.timeoutMs } : {}),
131
+ }, { signal }))
132
+ )),
133
+ },
134
+ {
135
+ name: 'browser_fill',
136
+ description: 'Fill an element selected from the observed page.',
137
+ inputSchema: plugins.smartmcp.z.object({
138
+ selector: plugins.smartmcp.z.string().min(1).max(4096),
139
+ text: plugins.smartmcp.z.string().max(100_000),
140
+ tabId: tabIdSchema,
141
+ timeoutMs: timeoutSchema,
142
+ }).strict(),
143
+ annotations: { readOnlyHint: false, openWorldHint: true },
144
+ handler: async (args, context) => withLease(context, async (lease, signal) => (
145
+ validateAgentActionResult(await lease.executeAgentAction({
146
+ action: 'fill',
147
+ selector: String(args.selector),
148
+ text: String(args.text),
149
+ ...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}),
150
+ ...(typeof args.timeoutMs === 'number' ? { timeoutMs: args.timeoutMs } : {}),
151
+ }, { signal }))
152
+ )),
153
+ },
154
+ {
155
+ name: 'browser_press',
156
+ description: 'Press a key on an element selected from the observed page.',
157
+ inputSchema: plugins.smartmcp.z.object({
158
+ selector: plugins.smartmcp.z.string().min(1).max(4096),
159
+ key: plugins.smartmcp.z.string().min(1).max(64),
160
+ tabId: tabIdSchema,
161
+ timeoutMs: timeoutSchema,
162
+ }).strict(),
163
+ annotations: { readOnlyHint: false, openWorldHint: true },
164
+ handler: async (args, context) => withLease(context, async (lease, signal) => (
165
+ validateAgentActionResult(await lease.executeAgentAction({
166
+ action: 'press',
167
+ selector: String(args.selector),
168
+ key: String(args.key),
169
+ ...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}),
170
+ ...(typeof args.timeoutMs === 'number' ? { timeoutMs: args.timeoutMs } : {}),
171
+ }, { signal }))
172
+ )),
173
+ },
174
+ {
175
+ name: 'browser_screenshot',
176
+ description: 'Capture a screenshot as bounded artifact metadata.',
177
+ inputSchema: plugins.smartmcp.z.object({
178
+ tabId: tabIdSchema,
179
+ format: plugins.smartmcp.z.enum(['jpeg', 'png']).optional(),
180
+ quality: plugins.smartmcp.z.number().int().min(0).max(100).optional(),
181
+ }).strict(),
182
+ annotations: { readOnlyHint: true, openWorldHint: false },
183
+ handler: async (args, context) => withLease(context, async (lease, signal) => (
184
+ validateAgentActionResult(await lease.executeAgentAction({
185
+ action: 'screenshot',
186
+ ...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}),
187
+ ...(args.format === 'jpeg' || args.format === 'png' ? { format: args.format } : {}),
188
+ ...(typeof args.quality === 'number' ? { quality: args.quality } : {}),
189
+ }, { signal }))
190
+ )),
191
+ },
192
+ ];
193
+
194
+ return plugins.smartmcp.createSmartMcpHttpHandler({
195
+ name: '@modelprofile.com/browser-runtime',
196
+ version: commitinfo.version,
197
+ ...(trustedOrigins ? { trustedOrigins } : {}),
198
+ ...(allowedHosts ? { allowedHosts } : {}),
199
+ ...(options.allowMissingOrigin !== undefined
200
+ ? { allowMissingOrigin: options.allowMissingOrigin }
201
+ : {}),
202
+ maxRequestBytes,
203
+ maxToolResultBytes,
204
+ redactError: () => 'Browser tool execution failed.',
205
+ authorize: async ({ request }) => {
206
+ let peerId: string;
207
+ let capabilityToken: string;
208
+ try {
209
+ const authentication = await options.authenticateMcpRequest(request);
210
+ peerId = validateBoundedString(authentication?.peerId, 'peerId', 1, 128);
211
+ capabilityToken = parseBearerToken(request);
212
+ const descriptor = runtime.authorizeCapability(
213
+ capabilityToken,
214
+ peerId,
215
+ 'agent',
216
+ 'mcp',
217
+ );
218
+ return {
219
+ token: descriptor.capabilityId,
220
+ clientId: peerId,
221
+ scopes: ['browser'],
222
+ expiresAt: descriptor.expiresAt,
223
+ };
224
+ } catch {
225
+ return new Response('Unauthorized', { status: 401 });
226
+ }
227
+ },
228
+ tools,
229
+ });
230
+ };
package/ts/plugins.ts ADDED
@@ -0,0 +1,28 @@
1
+ // node native scope
2
+ import { Buffer } from 'node:buffer';
3
+ import * as crypto from 'node:crypto';
4
+ import * as dns from 'node:dns';
5
+ import * as fs from 'node:fs';
6
+ import * as fsPromises from 'node:fs/promises';
7
+ import * as http from 'node:http';
8
+ import * as net from 'node:net';
9
+ import * as os from 'node:os';
10
+ import * as path from 'node:path';
11
+ import * as stream from 'node:stream';
12
+ import * as tls from 'node:tls';
13
+ import * as url from 'node:url';
14
+
15
+ export { Buffer, crypto, dns, fs, fsPromises, http, net, os, path, stream, tls, url };
16
+
17
+ // foss.global scopes
18
+ import * as flexharness from '@modelprofile.com/flexharness';
19
+ import * as smartagent from '@push.rocks/smartagent';
20
+ import * as smartmcp from '@push.rocks/smartmcp';
21
+ import * as smartpuppeteer from '@push.rocks/smartpuppeteer';
22
+
23
+ export { flexharness, smartagent, smartmcp, smartpuppeteer };
24
+
25
+ // third-party scope
26
+ import ipaddr from 'ipaddr.js';
27
+
28
+ export { ipaddr };
package/ts/utils.ts ADDED
@@ -0,0 +1,188 @@
1
+ import * as plugins from './plugins.js';
2
+ import { BrowserRuntimeError } from './errors.js';
3
+
4
+ export const validateBoundedString = (
5
+ value: unknown,
6
+ name: string,
7
+ minimum: number,
8
+ maximum: number,
9
+ ): string => {
10
+ if (typeof value !== 'string') {
11
+ throw new BrowserRuntimeError('INVALID_INPUT', `${name} must be a string`);
12
+ }
13
+ const normalized = value.trim();
14
+ if (normalized.length < minimum || normalized.length > maximum) {
15
+ throw new BrowserRuntimeError(
16
+ 'INVALID_INPUT',
17
+ `${name} must contain between ${minimum} and ${maximum} characters`,
18
+ );
19
+ }
20
+ return normalized;
21
+ };
22
+
23
+ export const validateInteger = (
24
+ value: unknown,
25
+ name: string,
26
+ minimum: number,
27
+ maximum: number,
28
+ ): number => {
29
+ if (!Number.isInteger(value) || (value as number) < minimum || (value as number) > maximum) {
30
+ throw new BrowserRuntimeError(
31
+ 'INVALID_INPUT',
32
+ `${name} must be an integer between ${minimum} and ${maximum}`,
33
+ );
34
+ }
35
+ return value as number;
36
+ };
37
+
38
+ export const validateOptionalInteger = (
39
+ value: unknown,
40
+ name: string,
41
+ minimum: number,
42
+ maximum: number,
43
+ defaultValue: number,
44
+ ): number => value === undefined
45
+ ? defaultValue
46
+ : validateInteger(value, name, minimum, maximum);
47
+
48
+ export const validateExactKeys = (
49
+ value: unknown,
50
+ allowedKeys: readonly string[],
51
+ name: string,
52
+ ): Record<string, unknown> => {
53
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
54
+ throw new BrowserRuntimeError('INVALID_INPUT', `${name} must be an object`);
55
+ }
56
+ const record = value as Record<string, unknown>;
57
+ const allowed = new Set(allowedKeys);
58
+ for (const key of Object.keys(record)) {
59
+ if (!allowed.has(key)) {
60
+ throw new BrowserRuntimeError('INVALID_INPUT', `${name} contains an unsupported field`);
61
+ }
62
+ }
63
+ return record;
64
+ };
65
+
66
+ export const randomId = (bytes = 24): string => plugins.crypto.randomBytes(bytes).toString('base64url');
67
+
68
+ export const digestToken = (token: string): plugins.Buffer => plugins.crypto
69
+ .createHash('sha256')
70
+ .update(token, 'utf8')
71
+ .digest();
72
+
73
+ export const digestsEqual = (left: plugins.Buffer, right: plugins.Buffer): boolean => (
74
+ left.byteLength === right.byteLength && plugins.crypto.timingSafeEqual(left, right)
75
+ );
76
+
77
+ export const delay = (milliseconds: number): Promise<void> => new Promise((resolve) => {
78
+ const timer = setTimeout(resolve, milliseconds);
79
+ timer.unref();
80
+ });
81
+
82
+ export const waitBounded = async <T>(
83
+ promise: Promise<T>,
84
+ timeoutMs: number,
85
+ ): Promise<{ settled: true; value: T } | { settled: false }> => {
86
+ const timeout = Symbol('timeout');
87
+ let timer: ReturnType<typeof setTimeout> | undefined;
88
+ const timeoutPromise = new Promise<typeof timeout>((resolve) => {
89
+ timer = setTimeout(() => resolve(timeout), timeoutMs);
90
+ timer.unref();
91
+ });
92
+ try {
93
+ const result = await Promise.race([promise, timeoutPromise]);
94
+ return result === timeout
95
+ ? { settled: false }
96
+ : { settled: true, value: result as T };
97
+ } finally {
98
+ if (timer) clearTimeout(timer);
99
+ }
100
+ };
101
+
102
+ export const truncateString = (value: string, maximum = 2048): string => (
103
+ value.length <= maximum ? value : value.slice(0, maximum)
104
+ );
105
+
106
+ export const assertJsonWithoutBytes = (value: unknown, maxBytes = 256 * 1024): void => {
107
+ const seen = new WeakSet<object>();
108
+ const visit = (current: unknown, depth: number): void => {
109
+ if (depth > 24) {
110
+ throw new BrowserRuntimeError('PROTOCOL_ERROR');
111
+ }
112
+ if (
113
+ current instanceof Uint8Array
114
+ || current instanceof ArrayBuffer
115
+ || ArrayBuffer.isView(current)
116
+ ) {
117
+ throw new BrowserRuntimeError('PROTOCOL_ERROR');
118
+ }
119
+ if (!current || typeof current !== 'object') {
120
+ if (
121
+ typeof current === 'bigint'
122
+ || typeof current === 'function'
123
+ || typeof current === 'symbol'
124
+ || typeof current === 'undefined'
125
+ || (typeof current === 'number' && !Number.isFinite(current))
126
+ ) {
127
+ throw new BrowserRuntimeError('PROTOCOL_ERROR');
128
+ }
129
+ return;
130
+ }
131
+ if (seen.has(current)) {
132
+ throw new BrowserRuntimeError('PROTOCOL_ERROR');
133
+ }
134
+ seen.add(current);
135
+ if (Array.isArray(current)) {
136
+ for (const entry of current) visit(entry, depth + 1);
137
+ return;
138
+ }
139
+ const prototype = Object.getPrototypeOf(current);
140
+ if (prototype !== Object.prototype && prototype !== null) {
141
+ throw new BrowserRuntimeError('PROTOCOL_ERROR');
142
+ }
143
+ for (const entry of Object.values(current as Record<string, unknown>)) {
144
+ visit(entry, depth + 1);
145
+ }
146
+ };
147
+ visit(value, 0);
148
+ const serialized = JSON.stringify(value);
149
+ if (plugins.Buffer.byteLength(serialized, 'utf8') > maxBytes) {
150
+ throw new BrowserRuntimeError('PROTOCOL_ERROR');
151
+ }
152
+ };
153
+
154
+ export class TransitionMutex {
155
+ private locked = false;
156
+ private waiters: Array<() => void> = [];
157
+
158
+ public tryAcquire(): (() => void) | undefined {
159
+ if (this.locked) return undefined;
160
+ this.locked = true;
161
+ return this.createRelease();
162
+ }
163
+
164
+ public async acquire(): Promise<() => void> {
165
+ const immediate = this.tryAcquire();
166
+ if (immediate) return immediate;
167
+ await new Promise<void>((resolve) => this.waiters.push(resolve));
168
+ return this.createRelease();
169
+ }
170
+
171
+ public get busy(): boolean {
172
+ return this.locked;
173
+ }
174
+
175
+ private createRelease(): () => void {
176
+ let released = false;
177
+ return () => {
178
+ if (released) return;
179
+ released = true;
180
+ const next = this.waiters.shift();
181
+ if (next) {
182
+ next();
183
+ } else {
184
+ this.locked = false;
185
+ }
186
+ };
187
+ }
188
+ }