@isomorph.ai/app-sdk 1.4.3 → 1.5.1
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 +81 -0
- package/dist/browser.d.ts +1309 -0
- package/dist/browser.js +135 -0
- package/dist/index.js +30 -0
- package/dist/runtime.d.ts +3 -0
- package/dist/runtime.js +39 -2
- package/package.json +17 -3
package/dist/browser.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { lookup } from 'node:dns/promises';
|
|
5
|
+
import { isIP } from 'node:net';
|
|
6
|
+
import { chromium } from 'playwright-core';
|
|
7
|
+
import { jsonSchema, tool } from 'ai';
|
|
8
|
+
let active = false;
|
|
9
|
+
export function publicIPv4(address) {
|
|
10
|
+
if (isIP(address) !== 4)
|
|
11
|
+
return false;
|
|
12
|
+
const [a, b, c] = address.split('.').map(Number);
|
|
13
|
+
return !(a === 0 || a === 10 || a === 127 || a >= 224 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && [0, 168].includes(b) || a === 100 && b >= 64 && b <= 127 || a === 198 && ([18, 19].includes(b) || b === 51 && c === 100) || a === 203 && b === 0 && c === 113);
|
|
14
|
+
}
|
|
15
|
+
/** App-reviewed read workflows only; control names and selectors never come from the model. */
|
|
16
|
+
export async function createBrowserTools(workflow) {
|
|
17
|
+
if (active)
|
|
18
|
+
throw new Error('A browser run is already active.');
|
|
19
|
+
if (!workflow.pages.length)
|
|
20
|
+
throw new Error('Declare a read-only page.');
|
|
21
|
+
const pages = workflow.pages.map(value => {
|
|
22
|
+
const url = new URL(value);
|
|
23
|
+
if (url.protocol !== 'https:' || url.username || url.password || url.port && url.port !== '443')
|
|
24
|
+
throw new Error('Browser pages require HTTPS without credentials or custom ports.');
|
|
25
|
+
return url;
|
|
26
|
+
});
|
|
27
|
+
const allowed = new Set(pages.map(url => url.origin + url.pathname));
|
|
28
|
+
const hosts = [...new Set(pages.map(url => url.hostname))];
|
|
29
|
+
active = true;
|
|
30
|
+
let browser;
|
|
31
|
+
let home;
|
|
32
|
+
let timer;
|
|
33
|
+
let closed = false;
|
|
34
|
+
const close = async () => {
|
|
35
|
+
if (closed)
|
|
36
|
+
return;
|
|
37
|
+
closed = true;
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
workflow.signal?.removeEventListener('abort', abort);
|
|
40
|
+
try {
|
|
41
|
+
await browser?.close();
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
if (home)
|
|
45
|
+
await rm(home, { recursive: true, force: true });
|
|
46
|
+
active = false;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const abort = () => { void close(); };
|
|
50
|
+
try {
|
|
51
|
+
if (workflow.signal?.aborted)
|
|
52
|
+
throw new Error('Browser run cancelled.');
|
|
53
|
+
const rules = [];
|
|
54
|
+
for (const host of hosts) {
|
|
55
|
+
const addresses = await lookup(host, { all: true, family: 4 });
|
|
56
|
+
if (!addresses.length || addresses.some(entry => !publicIPv4(entry.address)))
|
|
57
|
+
throw new Error('Browser destination is not public.');
|
|
58
|
+
rules.push(`MAP ${host} ${addresses[0].address}`);
|
|
59
|
+
}
|
|
60
|
+
home = await mkdtemp(join(tmpdir(), 'isomorph-browser-'));
|
|
61
|
+
const executablePath = workflow.executablePath ?? process.env.HARBOUR_PUPPETEER_EXECUTABLE_PATH;
|
|
62
|
+
browser = await chromium.launch({ headless: true, chromiumSandbox: true, ...(executablePath ? { executablePath } : {}), args: [`--host-resolver-rules=${rules.join(',')},MAP * ~NOTFOUND`, '--no-proxy-server', '--disable-quic', '--force-webrtc-ip-handling-policy=disable_non_proxied_udp'], env: { PATH: process.env.PATH ?? '', HOME: home, TMPDIR: process.env.TMPDIR ?? '/tmp' } });
|
|
63
|
+
const context = await browser.newContext({ acceptDownloads: false, serviceWorkers: 'block' });
|
|
64
|
+
await context.route('**/*', async (route) => {
|
|
65
|
+
const request = route.request();
|
|
66
|
+
const url = new URL(request.url());
|
|
67
|
+
if (!['GET', 'HEAD'].includes(request.method()) || url.protocol !== 'https:' || url.port && url.port !== '443' || !hosts.includes(url.hostname) || request.isNavigationRequest() && !allowed.has(url.origin + url.pathname))
|
|
68
|
+
return route.abort('blockedbyclient');
|
|
69
|
+
return route.continue();
|
|
70
|
+
});
|
|
71
|
+
await context.routeWebSocket('**/*', socket => socket.close());
|
|
72
|
+
const page = await context.newPage();
|
|
73
|
+
context.on('page', popup => { if (popup !== page)
|
|
74
|
+
void popup.close(); });
|
|
75
|
+
page.setDefaultTimeout(10_000);
|
|
76
|
+
timer = setTimeout(abort, 5 * 60_000);
|
|
77
|
+
timer.unref();
|
|
78
|
+
workflow.signal?.addEventListener('abort', abort, { once: true });
|
|
79
|
+
if (workflow.signal?.aborted) {
|
|
80
|
+
await close();
|
|
81
|
+
throw new Error('Browser run cancelled.');
|
|
82
|
+
}
|
|
83
|
+
let actions = 0;
|
|
84
|
+
const run = async (operation) => {
|
|
85
|
+
if (closed || workflow.signal?.aborted)
|
|
86
|
+
throw new Error('Browser session is closed.');
|
|
87
|
+
if (++actions > 50) {
|
|
88
|
+
await close();
|
|
89
|
+
throw new Error('Browser action limit reached.');
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
return await operation();
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
await close();
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
const empty = jsonSchema({ type: 'object', properties: {}, additionalProperties: false });
|
|
100
|
+
return { close, tools: {
|
|
101
|
+
browserNavigate: tool({ description: 'Open an approved read-only page.', inputSchema: jsonSchema({ type: 'object', properties: { url: { type: 'string' } }, required: ['url'], additionalProperties: false }), execute: ({ url }) => run(async () => {
|
|
102
|
+
const target = new URL(url);
|
|
103
|
+
if (target.username || target.password || !allowed.has(target.origin + target.pathname))
|
|
104
|
+
throw new Error('Page is outside this workflow.');
|
|
105
|
+
await page.goto(target.href, { waitUntil: 'domcontentloaded' });
|
|
106
|
+
return { url: page.url() };
|
|
107
|
+
}) }),
|
|
108
|
+
browserObserve: tool({ description: 'Read page text and permitted controls.', inputSchema: empty, execute: () => run(async () => {
|
|
109
|
+
const text = await page.locator('body').innerText();
|
|
110
|
+
return { url: page.url(), text: text.slice(0, 16_000), truncated: text.length > 16_000, controls: Object.keys(workflow.controls ?? {}) };
|
|
111
|
+
}) }),
|
|
112
|
+
browserControl: tool({ description: 'Use an app-reviewed read-only control.', inputSchema: jsonSchema({ type: 'object', properties: { name: { type: 'string' }, value: { type: 'string', maxLength: 2000 } }, required: ['name'], additionalProperties: false }), execute: ({ name, value }) => run(async () => {
|
|
113
|
+
const control = workflow.controls?.[name];
|
|
114
|
+
if (!control || control.page !== new URL(page.url()).origin + new URL(page.url()).pathname)
|
|
115
|
+
throw new Error('Control is not approved on this page.');
|
|
116
|
+
const element = page.locator(control.selector);
|
|
117
|
+
if (await element.count() !== 1)
|
|
118
|
+
throw new Error('Control is missing or ambiguous.');
|
|
119
|
+
if (control.action === 'fill')
|
|
120
|
+
await element.fill(value ?? '');
|
|
121
|
+
else
|
|
122
|
+
await element.click();
|
|
123
|
+
return { url: page.url() };
|
|
124
|
+
}) }),
|
|
125
|
+
browserScroll: tool({ description: 'Scroll down.', inputSchema: empty, execute: () => run(async () => { await page.mouse.wheel(0, 600); return { scrolled: true }; }) }),
|
|
126
|
+
browserScreenshot: tool({ description: 'Capture the viewport.', inputSchema: empty, execute: () => run(async () => { const bytes = await page.screenshot({ type: 'png' }); if (bytes.length > 2 * 1024 * 1024)
|
|
127
|
+
throw new Error('Screenshot exceeds 2 MiB.'); return { mediaType: 'image/png', base64: bytes.toString('base64') }; }) }),
|
|
128
|
+
browserClose: tool({ description: 'Close the browser.', inputSchema: empty, execute: async () => { await close(); return { closed: true }; } }),
|
|
129
|
+
} };
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
await close();
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -64,6 +64,13 @@ function mergeHeaders(...layers) {
|
|
|
64
64
|
function runnerIdentityHeaders(identity) {
|
|
65
65
|
if (typeof location !== 'undefined')
|
|
66
66
|
return {};
|
|
67
|
+
const resolver = globalThis[Symbol.for('isomorph.actionIdentity')];
|
|
68
|
+
const actionToken = resolver?.();
|
|
69
|
+
if (actionToken !== undefined) {
|
|
70
|
+
if (!actionToken)
|
|
71
|
+
throw new IsomorphError('AUTH_REQUIRED', 'The action has no invoking-user identity.');
|
|
72
|
+
return { 'x-harbour-identity-context': actionToken };
|
|
73
|
+
}
|
|
67
74
|
const environment = globalThis.process?.env;
|
|
68
75
|
const name = environment?.ISOMORPH_IDENTITY_CONTEXT_HEADER;
|
|
69
76
|
const token = identity === 'second-user' ? environment?.ISOMORPH_IDENTITY_CONTEXT_SECOND_USER : environment?.ISOMORPH_IDENTITY_CONTEXT;
|
|
@@ -138,6 +145,11 @@ class Transport {
|
|
|
138
145
|
constructor(options, onUnauthorized) {
|
|
139
146
|
this.onUnauthorized = onUnauthorized;
|
|
140
147
|
this.baseUrl = (options.baseUrl ?? applicationBaseUrl()).replace(/\/$/, '');
|
|
148
|
+
// Captured here, at createClient() time, not when this module is
|
|
149
|
+
// evaluated: a kit page's credential shim (a plain <script> in <head>)
|
|
150
|
+
// wraps window.fetch before any module script runs, so the fetch bound
|
|
151
|
+
// below is the wrapped one, and a call to the API origin leaves with
|
|
152
|
+
// credentials: 'include' although the transport asks for 'same-origin'.
|
|
141
153
|
this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
142
154
|
this.identity = options.identity;
|
|
143
155
|
}
|
|
@@ -185,9 +197,27 @@ function applicationBaseUrl() {
|
|
|
185
197
|
const runtime = globalThis.process;
|
|
186
198
|
return (runtime?.env?.ISOMORPH_GATEWAY_URL ?? '').replace(/\/$/, '');
|
|
187
199
|
}
|
|
200
|
+
// A kit page served from CloudFront talks to its own API host, not to the
|
|
201
|
+
// page origin: the transform injects the credential shim into the page and
|
|
202
|
+
// the shim publishes that host as window.__isomorphApiOrigin (an origin,
|
|
203
|
+
// https://<org>-<app>.api.<zone>, no path). Anything that is not an http(s)
|
|
204
|
+
// origin is ignored, so a page without the shim (isomorph run, a git app on
|
|
205
|
+
// the shared edge) keeps the page-relative base URL below.
|
|
206
|
+
const apiOrigin = globalThis.__isomorphApiOrigin;
|
|
207
|
+
if (typeof apiOrigin === 'string' && isHttpOrigin(apiOrigin))
|
|
208
|
+
return apiOrigin;
|
|
188
209
|
const match = /^\/p\/([a-z0-9-]+\.[a-z0-9-]+)(?:\/|$)/.exec(location.pathname);
|
|
189
210
|
return match ? `/p/${match[1]}` : '';
|
|
190
211
|
}
|
|
212
|
+
function isHttpOrigin(value) {
|
|
213
|
+
try {
|
|
214
|
+
const url = new URL(value);
|
|
215
|
+
return (url.protocol === 'https:' || url.protocol === 'http:') && url.origin === value;
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
191
221
|
// Result is what awaiting the builder resolves to: T[] for a list query, T
|
|
192
222
|
// after .single(), T | null after .maybeSingle(). The gateway applies the
|
|
193
223
|
// cardinality; the type follows it so a reader of these declarations sees the
|
package/dist/runtime.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ export declare const ENQUEUE_ACTION = "isomorph-jobs-enqueue";
|
|
|
9
9
|
export declare const GET_RUN_ACTION = "isomorph-jobs-get";
|
|
10
10
|
export declare const CANCEL_RUN_ACTION = "isomorph-jobs-cancel";
|
|
11
11
|
export interface ActionContext {
|
|
12
|
+
signal: AbortSignal;
|
|
12
13
|
user: {
|
|
13
14
|
id: string;
|
|
14
15
|
email: string;
|
|
@@ -84,6 +85,7 @@ export declare class Runtime {
|
|
|
84
85
|
private readonly running;
|
|
85
86
|
private readonly timers;
|
|
86
87
|
private stopped;
|
|
88
|
+
private aiBinding;
|
|
87
89
|
private queueLive;
|
|
88
90
|
private queueWake;
|
|
89
91
|
private readonly runs;
|
|
@@ -112,6 +114,7 @@ export declare class Runtime {
|
|
|
112
114
|
private awaitDataPath;
|
|
113
115
|
stop(): Promise<void>;
|
|
114
116
|
private every;
|
|
117
|
+
private prepareAI;
|
|
115
118
|
private refreshToken;
|
|
116
119
|
private scheduleTick;
|
|
117
120
|
/** One scheduled run; true when the handler returned, false when it threw or the last tick's run is still going. */
|
package/dist/runtime.js
CHANGED
|
@@ -49,12 +49,16 @@
|
|
|
49
49
|
// runs without waiting on the data path. Set by
|
|
50
50
|
// Provision; the table exists otherwise
|
|
51
51
|
// ISOMORPH_APP_ROOT the app root (default: the working directory)
|
|
52
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
52
53
|
import { createServer } from 'node:http';
|
|
53
54
|
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
54
55
|
import { register } from 'node:module';
|
|
55
56
|
import { join, resolve } from 'node:path';
|
|
56
57
|
import { pathToFileURL } from 'node:url';
|
|
57
58
|
import { createClient, IsomorphError } from './index.js';
|
|
59
|
+
const actionIdentity = new AsyncLocalStorage();
|
|
60
|
+
const identityKey = Symbol.for('isomorph.actionIdentity');
|
|
61
|
+
globalThis[identityKey] = () => actionIdentity.getStore();
|
|
58
62
|
export const ACTIONS_DIR = 'actions';
|
|
59
63
|
export const JOBS_DIR = 'jobs';
|
|
60
64
|
export const RUNS_TABLE = 'isomorph_job_runs';
|
|
@@ -231,6 +235,7 @@ export class Runtime {
|
|
|
231
235
|
running = new Set();
|
|
232
236
|
timers = [];
|
|
233
237
|
stopped = false;
|
|
238
|
+
aiBinding;
|
|
234
239
|
// The queue loop is live while a pass runs or its next pass is timed; a
|
|
235
240
|
// wake that arrives mid-pass is remembered so the pass after it looks again.
|
|
236
241
|
queueLive = false;
|
|
@@ -360,6 +365,30 @@ export class Runtime {
|
|
|
360
365
|
};
|
|
361
366
|
this.timers.push(setTimeout(loop, ms));
|
|
362
367
|
}
|
|
368
|
+
async prepareAI() {
|
|
369
|
+
const versionId = process.env.ISOMORPH_AI_VERSION_ID;
|
|
370
|
+
if (!versionId)
|
|
371
|
+
return;
|
|
372
|
+
this.aiBinding ??= (async () => {
|
|
373
|
+
const token = process.env.ISOMORPH_WORKLOAD_TOKEN;
|
|
374
|
+
if (!token)
|
|
375
|
+
throw new Error('AI workload identity is unavailable.');
|
|
376
|
+
const response = await fetch(`${this.options.gatewayUrl}/_harbour/internal/ai/connection`, { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, body: JSON.stringify({ versionId }), signal: AbortSignal.timeout(15_000) });
|
|
377
|
+
if (!response.ok)
|
|
378
|
+
throw new Error('This app version has no active AI connection yet.');
|
|
379
|
+
const result = await response.json();
|
|
380
|
+
const binding = result.data;
|
|
381
|
+
if (!binding?.key || !binding.gatewayBaseUrl || new URL(binding.gatewayBaseUrl).protocol !== 'https:')
|
|
382
|
+
throw new Error('Invalid AI runtime binding.');
|
|
383
|
+
process.env.OPENAI_BASE_URL = binding.gatewayBaseUrl;
|
|
384
|
+
process.env.OPENAI_API_KEY = binding.key;
|
|
385
|
+
if (binding.modelAliases?.generation)
|
|
386
|
+
process.env.ISOMORPH_AI_GENERATION_MODEL_ALIAS = binding.modelAliases.generation;
|
|
387
|
+
if (binding.modelAliases?.image)
|
|
388
|
+
process.env.ISOMORPH_AI_IMAGE_MODEL_ALIAS = binding.modelAliases.image;
|
|
389
|
+
})().catch(error => { this.aiBinding = undefined; throw error; });
|
|
390
|
+
await this.aiBinding;
|
|
391
|
+
}
|
|
363
392
|
async refreshToken() {
|
|
364
393
|
if (!this.options.tokenFile)
|
|
365
394
|
return;
|
|
@@ -463,7 +492,10 @@ export class Runtime {
|
|
|
463
492
|
const app = header(request, 'X-Harbour-Verified-App');
|
|
464
493
|
if (!tenant || !app)
|
|
465
494
|
return refuse(response, 401, 'AUTH_REQUIRED', 'the request did not come through the Isomorph gateway', requestId);
|
|
466
|
-
const
|
|
495
|
+
const controller = new AbortController();
|
|
496
|
+
const abort = () => controller.abort();
|
|
497
|
+
response.once('close', abort);
|
|
498
|
+
const context = { signal: controller.signal, user: { id: header(request, 'X-Harbour-Verified-User-ID'), email: header(request, 'X-Harbour-Verified-User-Email') }, tenant, app, requestId: requestId ?? '' };
|
|
467
499
|
const raw = await readBody(request);
|
|
468
500
|
if (raw === undefined)
|
|
469
501
|
return refuse(response, 413, 'VALIDATION_FAILED', 'the request body is larger than 1 MiB', requestId);
|
|
@@ -481,8 +513,9 @@ export class Runtime {
|
|
|
481
513
|
return answer(response, 200, { data: await this.reserved(name, input, context) }, requestId);
|
|
482
514
|
if (!this.inventory.actions.includes(name))
|
|
483
515
|
return refuse(response, 404, 'NOT_FOUND', `this app has no ${ACTIONS_DIR}/${name}.ts`, requestId);
|
|
516
|
+
await this.prepareAI();
|
|
484
517
|
const handler = await loadHandler(this.options.root, ACTIONS_DIR, name);
|
|
485
|
-
const result = await handler(input, context);
|
|
518
|
+
const result = await actionIdentity.run(header(request, 'X-Harbour-Identity-Context'), () => handler(input, context));
|
|
486
519
|
return answer(response, 200, { data: result === undefined ? null : result }, requestId);
|
|
487
520
|
}
|
|
488
521
|
catch (error) {
|
|
@@ -491,6 +524,10 @@ export class Runtime {
|
|
|
491
524
|
this.log(`${name}: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
|
|
492
525
|
return refuse(response, 500, 'INTERNAL', `the action ${name} failed; its log names the cause`, requestId);
|
|
493
526
|
}
|
|
527
|
+
finally {
|
|
528
|
+
response.removeListener('close', abort);
|
|
529
|
+
controller.abort();
|
|
530
|
+
}
|
|
494
531
|
}
|
|
495
532
|
async reserved(name, input, context) {
|
|
496
533
|
const body = (input && typeof input === 'object' ? input : {});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@isomorph.ai/app-sdk",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -20,7 +20,21 @@
|
|
|
20
20
|
"test": "npm run build && node --test test/*.test.mjs"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
|
-
"
|
|
24
|
-
"
|
|
23
|
+
"@types/node": "24.7.0",
|
|
24
|
+
"ai": "7.0.118",
|
|
25
|
+
"playwright-core": "1.58.2",
|
|
26
|
+
"typescript": "5.9.2"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"ai": "^7.0.118",
|
|
30
|
+
"playwright-core": "^1.58.2"
|
|
31
|
+
},
|
|
32
|
+
"peerDependenciesMeta": {
|
|
33
|
+
"ai": {
|
|
34
|
+
"optional": true
|
|
35
|
+
},
|
|
36
|
+
"playwright-core": {
|
|
37
|
+
"optional": true
|
|
38
|
+
}
|
|
25
39
|
}
|
|
26
40
|
}
|