@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.
@@ -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 context = { user: { id: header(request, 'X-Harbour-Verified-User-ID'), email: header(request, 'X-Harbour-Verified-User-Email') }, tenant, app, requestId: requestId ?? '' };
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.4.3",
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
- "typescript": "5.9.2",
24
- "@types/node": "24.7.0"
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
  }