@ontologic/pillowfort-agent 1.0.1 → 1.1.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.
@@ -1,472 +1,20 @@
1
1
  import { chromium } from 'playwright';
2
- import { setTimeout as delay } from 'node:timers/promises';
3
-
4
- export const MAX_INPUT_BYTES = 1024 * 1024;
5
- export const MAX_OUTPUT_BYTES = 2 * 1024 * 1024;
6
- export const MAX_WAIT_MS = 30_000;
7
- const SESSION_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
8
-
9
- export class AgentError extends Error {
10
- constructor(code, message, retryable = false) {
11
- super(message);
12
- this.name = 'AgentError';
13
- this.code = code;
14
- this.retryable = retryable;
15
- }
16
- }
17
-
18
- export function errorResult(error) {
19
- return { ok: false, error: error instanceof AgentError
20
- ? { code: error.code, message: error.message, retryable: error.retryable }
21
- : { code: 'INTERNAL_ERROR', message: 'The operation failed. Check the app connection and session state.', retryable: false } };
22
- }
23
-
24
- export function boundedJSON(value, maximum = MAX_INPUT_BYTES) {
25
- let text;
26
- try { text = JSON.stringify(value); } catch {
27
- throw new AgentError('INVALID_INPUT', 'Input must be JSON serializable.');
28
- }
29
- if (typeof text !== 'string' || Buffer.byteLength(text) > maximum) {
30
- throw new AgentError('SIZE_LIMIT', `JSON payload exceeds the ${maximum}-byte limit or is not a JSON value.`);
31
- }
32
- return text;
33
- }
34
-
35
- export function validateBaseURL(value) {
36
- let url;
37
- try { url = new URL(value); } catch {
38
- throw new AgentError('INVALID_ORIGIN', 'Set --url or baseURL to an explicit HTTPS or loopback HTTP app URL.');
39
- }
40
- const loopback = url.hostname === 'localhost' || url.hostname === '[::1]' || /^127(?:\.\d{1,3}){3}$/.test(url.hostname);
41
- if ((url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) || url.username || url.password || url.hash) {
42
- throw new AgentError('INVALID_ORIGIN', 'Use HTTPS or loopback HTTP without URL credentials or a fragment.');
43
- }
44
- if ([...url.searchParams.keys()].some(key => key !== 'agent')) {
45
- throw new AgentError('INVALID_ORIGIN', 'Use the base app URL without invitation, credential, or other query parameters. Pass invitations only to explicit bridge actions.');
46
- }
47
- url.searchParams.set('agent', '1');
48
- return url;
49
- }
50
-
51
- /** Real ephemeral Chromium contexts retain IndexedDB, Web Locks and MLS in the browser. */
52
- export class PillowfortAgent {
53
- #browser;
54
- #starting;
55
- #closed = false;
56
- #closing;
57
- #sessions = new Map();
58
- #discovery;
59
- #discovering;
60
- #pending = new Set();
61
- #url;
62
- #headed;
63
- #maxSessions;
64
- #timeoutMs;
65
- #cmsURL;
66
- #cmsStorageState;
67
- #cmsSession;
68
- #cmsOpening;
69
- #cmsQueue = Promise.resolve();
70
- #cmsPending = 0;
71
- #roomTrafficActions;
72
-
73
- constructor({ baseURL, headed = false, maxSessions = 8, timeoutMs = 30_000, cmsURL, cmsStorageState } = {}) {
74
- this.#url = validateBaseURL(baseURL);
75
- if (cmsStorageState && !cmsURL) throw new AgentError('INVALID_INPUT', 'cmsStorageState requires an explicit cmsURL.');
76
- if (cmsURL) {
77
- this.#cmsURL = validateBaseURL(cmsURL);
78
- this.#cmsURL.search = '';
79
- this.#cmsURL.pathname = '/admin';
80
- }
81
- if (cmsStorageState !== undefined && (typeof cmsStorageState !== 'string' || !cmsStorageState)) {
82
- throw new AgentError('INVALID_INPUT', 'cmsStorageState must be a user-selected Playwright storage-state file path. This file is sensitive.');
83
- }
84
- this.#cmsStorageState = cmsStorageState;
85
- if (!Number.isInteger(maxSessions) || maxSessions < 1 || maxSessions > 16) {
86
- throw new AgentError('INVALID_INPUT', 'maxSessions must be an integer from 1 to 16.');
87
- }
88
- if (!Number.isInteger(timeoutMs) || timeoutMs < 1_000 || timeoutMs > 60_000) {
89
- throw new AgentError('INVALID_INPUT', 'timeoutMs must be an integer from 1000 to 60000.');
90
- }
91
- this.#headed = Boolean(headed);
92
- this.#maxSessions = maxSessions;
93
- this.#timeoutMs = timeoutMs;
94
- }
95
-
96
- async start() {
97
- if (this.#closed) throw new AgentError('CLOSED', 'This SDK has closed. Create a new PillowfortAgent instance.');
98
- if (!this.#starting) {
99
- this.#starting = (async () => {
100
- try {
101
- this.#browser = await chromium.launch({ headless: !this.#headed, timeout: this.#timeoutMs });
102
- } catch {
2
+ import { AgentError, PillowfortBrowserAgent } from './agent-browser.mjs';
3
+
4
+ export { AgentError, errorResult, boundedJSON, validateBaseURL, MAX_INPUT_BYTES, MAX_OUTPUT_BYTES, MAX_WAIT_MS } from './agent-browser.mjs';
5
+
6
+ /** Public local SDK: installs and launches the matching Node Playwright browser. */
7
+ export class PillowfortAgent extends PillowfortBrowserAgent {
8
+ /** @param {Omit<import('./agent-browser.mjs').BrowserAgentOptions, 'launchBrowser'> & {headed?: boolean}} options */
9
+ constructor({ headed = false, ...options } = {}) {
10
+ super({
11
+ ...options,
12
+ launchBrowser: async ({ timeout }) => {
13
+ try { return await chromium.launch({ headless: !headed, timeout }); }
14
+ catch {
103
15
  throw new AgentError('BROWSER_UNAVAILABLE', 'Chromium could not start. Run pillowfort-agent install-browser (source: node scripts/agent.mjs install-browser) to install the matching browser, then check OS browser dependencies and headed display availability.', true);
104
16
  }
105
- if (this.#closed) {
106
- await this.#browser.close();
107
- throw new AgentError('CLOSED', 'The SDK closed during browser startup.');
108
- }
109
- })().catch(error => { this.#starting = undefined; throw error; });
110
- }
111
- await this.#starting;
112
- if (!this.#browser?.isConnected()) throw new AgentError('BROWSER_DISCONNECTED', 'The browser disconnected. Close this SDK and create a new instance.', true);
113
- return this;
114
- }
115
-
116
- async #open() {
117
- await this.start();
118
- let context;
119
- try {
120
- context = await this.#browser.newContext({ acceptDownloads: false, serviceWorkers: 'block' });
121
- if (this.#closed) throw new AgentError('CLOSED', 'The SDK is closed.');
122
- // Subresources may use the app's configured relay/CDN. Top-level navigation
123
- // cannot leave the explicitly selected app, including external checkout.
124
- await context.route('**/*', async route => {
125
- const request = route.request();
126
- if (request.isNavigationRequest() && request.frame().parentFrame() === null && request.url() !== this.#url.href) {
127
- await route.abort('blockedbyclient');
128
- } else {
129
- await route.continue();
130
- }
131
- });
132
- const page = await context.newPage();
133
- context.on('page', extra => { if (extra !== page) void extra.close().catch(() => {}); });
134
- page.on('dialog', dialog => { void dialog.dismiss().catch(() => {}); });
135
- const response = await page.goto(this.#url.href, { waitUntil: 'domcontentloaded', timeout: this.#timeoutMs });
136
- if (response && !response.ok()) throw new AgentError('APP_UNAVAILABLE', 'The selected app returned an HTTP error. Check the app URL and server.', true);
137
- try {
138
- await page.waitForFunction(() => {
139
- const bridge = window.pillowfortAgent;
140
- return bridge?.version === 1 && ['capabilities', 'execute', 'observe', 'waitForChange'].every(name => typeof bridge[name] === 'function');
141
- }, null, { timeout: this.#timeoutMs });
142
- } catch {
143
- throw new AgentError('BRIDGE_UNAVAILABLE', 'Agent bridge v1 is absent. Serve the updated Pillowfort client at the selected URL; the SDK adds ?agent=1.', true);
144
- }
145
- const actionAbort = new AbortController();
146
- page.on('close', () => actionAbort.abort());
147
- return { context, page, pendingCalls: 0, pendingActions: 0, nextActionAt: 0, actionQueue: Promise.resolve(), actionAbort };
148
- } catch (error) {
149
- await context?.close().catch(() => {});
150
- if (error instanceof AgentError) throw error;
151
- throw new AgentError('CONNECTION_FAILED', 'Could not load the selected app. Check its URL, TLS certificate, and running server. Redirects to other pages are blocked.', true);
152
- }
153
- }
154
-
155
- async createSession(name) {
156
- if (typeof name !== 'string' || !SESSION_NAME.test(name)) throw new AgentError('INVALID_SESSION', 'Session names must be 1–64 ASCII letters, digits, underscores or hyphens, starting with a letter or digit.');
157
- if (this.#closed) throw new AgentError('CLOSED', 'The SDK is closed.');
158
- if (this.#sessions.has(name)) throw new AgentError('SESSION_EXISTS', 'That session already exists. Reuse it or close it first.');
159
- if (this.#sessions.size >= this.#maxSessions || this.#pending.size >= this.#maxSessions) throw new AgentError('SESSION_LIMIT', `At most ${this.#maxSessions} named sessions may be open or opening. Close one or wait for creation to finish.`);
160
- const slot = { name, state: 'opening' };
161
- this.#sessions.set(name, slot);
162
- const opening = this.#open();
163
- this.#pending.add(opening);
164
- try {
165
- const session = await opening;
166
- if (this.#closed || this.#sessions.get(name) !== slot) {
167
- await session.context.close();
168
- throw new AgentError('SESSION_CLOSED', 'The session was closed while opening.');
169
- }
170
- Object.assign(slot, session, { state: 'ready' });
171
- return { name, state: 'ready' };
172
- } catch (error) {
173
- if (this.#sessions.get(name) === slot) this.#sessions.delete(name);
174
- throw error;
175
- } finally {
176
- this.#pending.delete(opening);
177
- }
178
- }
179
-
180
- listSessions() {
181
- return [...this.#sessions.values()].map(session => ({ name: session.name, state: session.page?.isClosed() ? 'stale' : session.state }));
182
- }
183
-
184
- #session(name) {
185
- if (this.#closed) throw new AgentError('CLOSED', 'The SDK is closed.');
186
- const session = this.#sessions.get(name);
187
- if (!session) throw new AgentError('SESSION_NOT_FOUND', 'No such session. Use session_list or create a named session first.');
188
- if (session.state !== 'ready') throw new AgentError('SESSION_NOT_READY', 'The session is still opening. Wait for session_create to finish.', true);
189
- if (session.page.isClosed() || !this.#browser?.isConnected()) throw new AgentError('STALE_SESSION', 'The session browser page is gone. Close and recreate this session.', true);
190
- return session;
191
- }
192
-
193
- async #call(session, method, args, timeoutMs = this.#timeoutMs) {
194
- boundedJSON(args);
195
- if (session.pendingCalls >= 32) throw new AgentError('BUSY', 'Too many outstanding calls for this session. Wait for pending calls to finish.', true);
196
- session.pendingCalls++;
197
- let timer;
198
- try {
199
- const serialized = await Promise.race([
200
- session.page.evaluate(async ({ method, args, maxBytes }) => {
201
- const bridge = window.pillowfortAgent;
202
- if (bridge?.version !== 1) return JSON.stringify({ transportError: 'STALE_SESSION' });
203
- try {
204
- const value = await bridge[method](...args);
205
- const text = JSON.stringify({ value });
206
- if (new TextEncoder().encode(text).byteLength > maxBytes) return JSON.stringify({ transportError: 'OUTPUT_LIMIT' });
207
- return text;
208
- } catch {
209
- return JSON.stringify({ transportError: 'BRIDGE_ERROR' });
210
- }
211
- }, { method, args, maxBytes: MAX_OUTPUT_BYTES }),
212
- new Promise((_, reject) => { timer = setTimeout(() => reject(new AgentError('TIMEOUT', 'The browser operation timed out; this session was closed to prevent late actions. An action may already have committed: inspect state before retrying in a new session.', false)), timeoutMs); }),
213
- ]);
214
- const payload = JSON.parse(serialized);
215
- if (payload.transportError) {
216
- const messages = {
217
- STALE_SESSION: 'The page no longer exposes agent bridge v1. Close and recreate the session.',
218
- OUTPUT_LIMIT: 'The result exceeds the output limit. Request a smaller result through the action parameters.',
219
- BRIDGE_ERROR: 'The app could not complete this operation. Inspect the session and check action parameters.',
220
- };
221
- throw new AgentError(payload.transportError, messages[payload.transportError]);
222
- }
223
- if (!Object.hasOwn(payload, 'value')) throw new AgentError('INVALID_RESULT', 'The bridge did not return a JSON value.');
224
- return payload.value;
225
- } catch (error) {
226
- if (error instanceof AgentError) {
227
- if (error.code === 'TIMEOUT') {
228
- await session.context.close().catch(() => {});
229
- if (session.name && this.#sessions.get(session.name) === session) this.#sessions.delete(session.name);
230
- if (session === this.#discovery) this.#discovery = undefined;
231
- }
232
- throw error;
233
- }
234
- throw new AgentError('STALE_SESSION', 'The browser connection or page was lost. Close and recreate this session; inspect state before retrying mutations.', false);
235
- } finally {
236
- clearTimeout(timer);
237
- session.pendingCalls--;
238
- }
239
- }
240
-
241
- async capabilities(name) {
242
- let session;
243
- if (name !== undefined) session = this.#session(name);
244
- else {
245
- if (!this.#discovery) {
246
- if (!this.#discovering) {
247
- this.#discovering = this.#open().then(async result => {
248
- if (this.#closed) {
249
- await result.context.close();
250
- throw new AgentError('CLOSED', 'The SDK is closed.');
251
- }
252
- this.#discovery = result;
253
- return result;
254
- }).finally(() => { this.#discovering = undefined; });
255
- }
256
- await this.#discovering;
257
- }
258
- session = this.#discovery;
259
- }
260
- const capabilities = await this.#call(session, 'capabilities', []);
261
- if (!Array.isArray(capabilities) || capabilities.length > 256 || capabilities.some(capability =>
262
- !capability || typeof capability.name !== 'string' || !/^[A-Za-z0-9_.-]{1,100}$/.test(capability.name) ||
263
- typeof capability.description !== 'string' || capability.inputSchema?.type !== 'object' || typeof capability.destructive !== 'boolean' || typeof capability.roomTraffic !== 'boolean') ||
264
- new Set(capabilities.map(capability => capability.name)).size !== capabilities.length) {
265
- throw new AgentError('INVALID_CAPABILITIES', 'The app returned an unsupported capability catalog. Update the app and SDK together.');
266
- }
267
- this.#roomTrafficActions = new Set(capabilities.filter(capability => capability.roomTraffic).map(capability => capability.name));
268
- return capabilities;
269
- }
270
-
271
- async execute(sessionName, name, input = {}) {
272
- if (typeof name !== 'string' || !name || !input || typeof input !== 'object' || Array.isArray(input)) throw new AgentError('INVALID_INPUT', 'Provide an action name and a JSON object input.');
273
- const session = this.#session(sessionName);
274
- if (!this.#roomTrafficActions) await this.capabilities(sessionName);
275
- input = JSON.parse(boundedJSON(input));
276
- const deadline = Date.now() + this.#timeoutMs;
277
- const paced = typeof input.roomId === 'string' && this.#roomTrafficActions.has(name);
278
- const run = async () => {
279
- if (paced) {
280
- const snapshot = await this.#call(session, 'observe', []);
281
- const members = Math.max(1, snapshot.room?.members?.length ?? 1);
282
- // The relay allows 256 aggregate frames per five seconds. Reserve half
283
- // for membership/crypto automation and account for recipient ACK fanout.
284
- const interval = Math.max(500, Math.ceil(5_000 * members * (members + 4) / 128));
285
- const waitMs = Math.max(0, session.nextActionAt - Date.now());
286
- if (Date.now() + waitMs + 1_000 > deadline) throw new AgentError('BUSY', 'The room action budget is busy. Observe state and wait before submitting another action.', true);
287
- if (waitMs) {
288
- try { await delay(waitMs, undefined, { signal: session.actionAbort.signal }); }
289
- catch { throw new AgentError('SESSION_CLOSED', 'The session closed before this action was submitted.'); }
290
- }
291
- this.#session(sessionName);
292
- session.nextActionAt = Date.now() + interval;
293
- }
294
- if (Date.now() >= deadline) throw new AgentError('BUSY', 'This action expired in the local queue and was not submitted.', true);
295
- const result = await this.#call(session, 'execute', [name, input], deadline - Date.now());
296
- if (!result || typeof result.ok !== 'boolean' || (!result.ok && (!result.error || typeof result.error.code !== 'string' || typeof result.error.message !== 'string' || typeof result.error.retryable !== 'boolean'))) {
297
- throw new AgentError('INVALID_RESULT', 'The app returned an unsupported action result. Update the app and SDK together.');
298
- }
299
- return result;
300
- };
301
- // Cancel/recovery/local reads are not held behind paced room mutations.
302
- if (!paced) return run();
303
- if (session.pendingActions >= 8) throw new AgentError('BUSY', 'At most eight room actions may be queued per session. Wait for pending actions.', true);
304
- session.pendingActions++;
305
- const result = session.actionQueue.then(run).finally(() => { session.pendingActions--; });
306
- session.actionQueue = result.catch(() => {});
307
- return result;
308
- }
309
-
310
- observe(name) { return this.#call(this.#session(name), 'observe', []); }
311
-
312
- wait(name, afterRevision, timeoutMs = MAX_WAIT_MS) {
313
- if (!Number.isSafeInteger(afterRevision) || afterRevision < 0 || !Number.isInteger(timeoutMs) || timeoutMs < 0 || timeoutMs > MAX_WAIT_MS) {
314
- throw new AgentError('INVALID_INPUT', 'afterRevision must be a nonnegative safe integer; timeoutMs must be an integer from 0 to 30000.');
315
- }
316
- return this.#call(this.#session(name), 'waitForChange', [afterRevision, timeoutMs], timeoutMs + 5_000);
317
- }
318
-
319
- async #openCMS() {
320
- if (!this.#cmsURL) throw new AgentError('CMS_NOT_CONFIGURED', 'Set cmsURL or --cms-url to enable optional authenticated CMS tools.');
321
- await this.start();
322
- if (this.#cmsSession && !this.#cmsSession.page.isClosed()) return this.#cmsSession;
323
- if (!this.#cmsOpening) {
324
- this.#cmsOpening = (async () => {
325
- let context;
326
- try {
327
- context = await this.#browser.newContext({
328
- acceptDownloads: false,
329
- serviceWorkers: 'block',
330
- ...(this.#cmsStorageState ? { storageState: this.#cmsStorageState } : {}),
331
- });
332
- if (this.#closed) throw new AgentError('CLOSED', 'The SDK is closed.');
333
- await context.route('**/*', async route => {
334
- const request = route.request();
335
- if (request.isNavigationRequest() && request.frame().parentFrame() === null &&
336
- new URL(request.url()).origin !== this.#cmsURL.origin) await route.abort('blockedbyclient');
337
- else await route.continue();
338
- });
339
- const page = await context.newPage();
340
- context.on('page', extra => { if (extra !== page) void extra.close().catch(() => {}); });
341
- page.on('dialog', dialog => { void dialog.dismiss().catch(() => {}); });
342
- await page.goto(this.#cmsURL.href, { waitUntil: 'domcontentloaded', timeout: this.#timeoutMs });
343
- if (new URL(page.url()).origin !== this.#cmsURL.origin) throw new AgentError('INVALID_ORIGIN', 'CMS redirected outside the selected origin.');
344
- if (this.#closed) throw new AgentError('CLOSED', 'The SDK is closed.');
345
- this.#cmsSession = { context, page };
346
- return this.#cmsSession;
347
- } catch (error) {
348
- await context?.close().catch(() => {});
349
- if (error instanceof AgentError) throw error;
350
- throw new AgentError('CMS_CONNECTION_FAILED', 'Could not open the CMS. Check --cms-url, TLS, and the selected storage-state file. External login and checkout navigation is not automated.', true);
351
- }
352
- })().finally(() => { this.#cmsOpening = undefined; });
353
- }
354
- return this.#cmsOpening;
355
- }
356
-
357
- async #fetchCMS(body) {
358
- const session = await this.#openCMS();
359
- const serializedBody = body === undefined ? undefined : boundedJSON(body);
360
- let timer;
361
- try {
362
- const result = await Promise.race([
363
- session.page.evaluate(async ({ body, origin, timeoutMs, maxBytes }) => {
364
- if (location.origin !== origin) return { failure: 'ORIGIN_CHANGED' };
365
- try {
366
- const response = await fetch('/api/agent', {
367
- method: body === undefined ? 'GET' : 'POST',
368
- credentials: 'same-origin',
369
- redirect: 'error',
370
- headers: body === undefined ? {} : { 'Content-Type': 'application/json' },
371
- body,
372
- signal: AbortSignal.timeout(timeoutMs),
373
- });
374
- if (response.status === 401 || response.status === 403) return { failure: 'AUTH_REQUIRED' };
375
- if (!response.body) return { failure: 'INVALID_RESULT' };
376
- const reader = response.body.getReader();
377
- const decoder = new TextDecoder();
378
- let length = 0;
379
- let text = '';
380
- while (true) {
381
- const chunk = await reader.read();
382
- if (chunk.done) break;
383
- length += chunk.value.byteLength;
384
- if (length > maxBytes) {
385
- await reader.cancel();
386
- return { failure: 'OUTPUT_LIMIT' };
387
- }
388
- text += decoder.decode(chunk.value, { stream: true });
389
- }
390
- text += decoder.decode();
391
- return { value: JSON.parse(text), status: response.status };
392
- } catch (error) {
393
- return { failure: error?.name === 'TimeoutError' ? 'TIMEOUT' : 'CMS_REQUEST_FAILED' };
394
- }
395
- }, { body: serializedBody, origin: this.#cmsURL.origin, timeoutMs: this.#timeoutMs, maxBytes: MAX_OUTPUT_BYTES }),
396
- new Promise((_, reject) => { timer = setTimeout(() => reject(new AgentError('TIMEOUT', 'CMS request timed out. Inspect article state before retrying a write.', false)), this.#timeoutMs + 2_000); }),
397
- ]);
398
- if (result.failure === 'AUTH_REQUIRED') throw new AgentError('AUTH_REQUIRED', `Sign in with an authorized CMS account at ${this.#cmsURL.href}. Use --headed or an explicitly selected authenticated --cms-storage-state file; no login or payment is automated.`);
399
- if (result.failure) throw new AgentError(result.failure, result.failure === 'TIMEOUT'
400
- ? 'CMS request timed out. Inspect article state before retrying a write.'
401
- : 'The CMS request failed or returned an invalid/oversized response. Check the CMS connection and endpoint.', false);
402
- if (result.status >= 400 && result.value?.ok !== false) throw new AgentError('CMS_REQUEST_FAILED', 'The CMS returned an HTTP error. Check authentication, permissions, and input.', false);
403
- return result.value;
404
- } catch (error) {
405
- if (error instanceof AgentError) {
406
- if (error.code === 'TIMEOUT') {
407
- await session.context.close().catch(() => {});
408
- this.#cmsSession = undefined;
409
- }
410
- throw error;
411
- }
412
- throw new AgentError('CMS_CONNECTION_FAILED', 'The CMS browser connection was lost. Inspect article state before retrying a write.', false);
413
- } finally {
414
- clearTimeout(timer);
415
- }
416
- }
417
-
418
- async cmsCapabilities() {
419
- if (!this.#cmsURL) return [];
420
- const catalog = await this.#fetchCMS();
421
- if (catalog?.version !== 1 || !Array.isArray(catalog.capabilities) || catalog.capabilities.length > 128 ||
422
- catalog.capabilities.some(capability => !capability || typeof capability.name !== 'string' ||
423
- !/^cms_[A-Za-z0-9_]{1,96}$/.test(capability.name) || typeof capability.description !== 'string' ||
424
- capability.inputSchema?.type !== 'object' || typeof capability.destructive !== 'boolean') ||
425
- new Set(catalog.capabilities.map(capability => capability.name)).size !== catalog.capabilities.length) {
426
- throw new AgentError('INVALID_CAPABILITIES', 'The CMS returned an unsupported capability catalog. Update the CMS and SDK together.');
427
- }
428
- return catalog.capabilities;
429
- }
430
-
431
- executeCMS(name, input = {}) {
432
- if (typeof name !== 'string' || !/^cms_[A-Za-z0-9_]+$/.test(name) || !input || typeof input !== 'object' || Array.isArray(input)) {
433
- throw new AgentError('INVALID_INPUT', 'Provide a cms_* action name and a JSON object input.');
434
- }
435
- boundedJSON({ name, input });
436
- if (this.#cmsPending >= 32) throw new AgentError('BUSY', 'At most 32 CMS calls may be queued. Wait for pending calls to finish.', true);
437
- this.#cmsPending++;
438
- const result = this.#cmsQueue.then(async () => {
439
- const value = await this.#fetchCMS({ name, input });
440
- if (!value || typeof value.ok !== 'boolean' || (!value.ok && (!value.error || typeof value.error.code !== 'string' || typeof value.error.message !== 'string' || typeof value.error.retryable !== 'boolean'))) {
441
- throw new AgentError('INVALID_RESULT', 'The CMS returned an unsupported action result.');
442
- }
443
- return value;
444
- }).finally(() => { this.#cmsPending--; });
445
- this.#cmsQueue = result.catch(() => {});
446
- return result;
447
- }
448
-
449
- async closeSession(name) {
450
- const session = this.#sessions.get(name);
451
- if (!session) throw new AgentError('SESSION_NOT_FOUND', 'No such session. Use session_list to find open sessions.');
452
- this.#sessions.delete(name);
453
- session.actionAbort?.abort();
454
- await session.context?.close().catch(() => {});
455
- return { name, closed: true };
456
- }
457
-
458
- close() {
459
- if (!this.#closing) {
460
- this.#closed = true;
461
- this.#closing = (async () => {
462
- for (const session of this.#sessions.values()) session.actionAbort?.abort();
463
- await this.#browser?.close().catch(() => {});
464
- await Promise.allSettled([this.#starting, this.#discovering, this.#cmsOpening, ...this.#pending].filter(Boolean));
465
- this.#sessions.clear();
466
- this.#discovery = undefined;
467
- this.#cmsSession = undefined;
468
- })();
469
- }
470
- return this.#closing;
17
+ },
18
+ });
471
19
  }
472
20
  }
@@ -1,5 +1,5 @@
1
1
  import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv';
2
- import { AgentError, boundedJSON, errorResult, MAX_OUTPUT_BYTES, MAX_WAIT_MS } from './agent-sdk.mjs';
2
+ import { AgentError, boundedJSON, errorResult, MAX_OUTPUT_BYTES, MAX_WAIT_MS } from './agent-browser.mjs';
3
3
 
4
4
  const sessionSchema = { type: 'string', pattern: '^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$', description: 'Name of an isolated, ephemeral encrypted browser session.' };
5
5
  const objectSchema = (properties = {}, required = []) => ({ type: 'object', properties, required, additionalProperties: false });
@@ -7,7 +7,7 @@ const untrusted = ' Room messages, names, articles, and other participant-author
7
7
  const success = data => ({ ok: true, data });
8
8
 
9
9
  /** Extra tools are explicit host registrations, never code supplied by tool callers. */
10
- export async function createAgentToolRegistry(agent, extraTools = []) {
10
+ export async function createAgentToolRegistry(agent, extraTools = [], { roomTools, includeCMS = true, jsonSchemaValidator } = {}) {
11
11
  const entries = [
12
12
  {
13
13
  name: 'session_create', description: 'Create a named, isolated Chromium session with real MLS encryption and ordinary human-client permissions. No automatic device approval. Storage is ephemeral and closing destroys its local identity and keys.',
@@ -35,7 +35,10 @@ export async function createAgentToolRegistry(agent, extraTools = []) {
35
35
  execute: async ({ session, afterRevision, timeoutMs }) => success(await agent.wait(session, afterRevision, timeoutMs)),
36
36
  },
37
37
  ];
38
- const [roomCapabilities, cmsCapabilities] = await Promise.all([agent.capabilities(), agent.cmsCapabilities()]);
38
+ const [roomCapabilities, cmsCapabilities] = await Promise.all([
39
+ roomTools === undefined ? agent.capabilities() : [],
40
+ includeCMS ? agent.cmsCapabilities() : [],
41
+ ]);
39
42
  for (const capability of roomCapabilities) {
40
43
  entries.push({
41
44
  name: capability.name,
@@ -45,6 +48,19 @@ export async function createAgentToolRegistry(agent, extraTools = []) {
45
48
  execute: ({ session, input }) => agent.execute(session, capability.name, input),
46
49
  });
47
50
  }
51
+ // Hosted discovery uses the generated, canonical schemas without allocating a browser.
52
+ // Execution still goes through the same SDK and the live app's capability validation.
53
+ for (const definition of roomTools ?? []) {
54
+ if (definition.name.startsWith('session_') || definition.name.startsWith('cms_') || definition.name.startsWith('fort_pass_')) continue;
55
+ entries.push({
56
+ name: definition.name,
57
+ description: definition.description,
58
+ inputSchema: definition.inputSchema,
59
+ destructive: definition.annotations?.destructiveHint === true,
60
+ readOnly: definition.annotations?.readOnlyHint === true,
61
+ execute: ({ session, input }) => agent.execute(session, definition.name, input),
62
+ });
63
+ }
48
64
  for (const capability of cmsCapabilities) {
49
65
  entries.push({
50
66
  ...capability,
@@ -55,7 +71,7 @@ export async function createAgentToolRegistry(agent, extraTools = []) {
55
71
  for (const tool of extraTools) {
56
72
  entries.push({ ...tool, description: tool.description + untrusted, execute: async input => success(await tool.execute(input)) });
57
73
  }
58
- const validator = new AjvJsonSchemaValidator();
74
+ const validator = jsonSchemaValidator ?? new AjvJsonSchemaValidator();
59
75
  const registry = new Map();
60
76
  for (const entry of entries) {
61
77
  if (!/^[A-Za-z0-9_.-]{1,128}$/.test(entry.name) || registry.has(entry.name) || typeof entry.description !== 'string' || entry.inputSchema?.type !== 'object' || typeof entry.execute !== 'function') {
package/scripts/agent.mjs CHANGED
@@ -10,9 +10,9 @@ import { runAgentMcp } from './agent-mcp.mjs';
10
10
 
11
11
  const HELP = `Pillowfort agent CLI — real ephemeral encrypted browser sessions
12
12
 
13
- Requires Node >=22.13.0 and local Chromium. No hosted /mcp endpoint.
13
+ This local CLI requires Node >=22.13.0 and Chromium. Hosted MCP and native WebMCP are separate connection choices: https://about.pillowfort.xyz/agents
14
14
  Version-pinned npm package:
15
- npm exec --yes --package=@ontologic/pillowfort-agent@1.0.1 -- pillowfort-agent --help
15
+ npm exec --yes --package=@ontologic/pillowfort-agent@1.1.0 -- pillowfort-agent --help
16
16
 
17
17
  Commands with the installed executable:
18
18
  pillowfort-agent install-browser