@isomorph.ai/app-sdk 1.4.2 → 1.5.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,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;
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;
@@ -72,6 +73,8 @@ export interface RuntimeOptions {
72
73
  gatewayUrl: string;
73
74
  mode: RuntimeMode;
74
75
  tokenFile?: string;
76
+ /** False when the app has no runs table (ISOMORPH_JOB_RUNS=none): nothing reads or works it. */
77
+ jobRuns?: boolean;
75
78
  log?: (line: string) => void;
76
79
  /** The pause between queue passes while work is found; the tests shorten it. */
77
80
  queuePollMs?: number;
@@ -82,6 +85,7 @@ export declare class Runtime {
82
85
  private readonly running;
83
86
  private readonly timers;
84
87
  private stopped;
88
+ private aiBinding;
85
89
  private queueLive;
86
90
  private queueWake;
87
91
  private readonly runs;
@@ -89,7 +93,7 @@ export declare class Runtime {
89
93
  readonly server: import("http").Server<typeof IncomingMessage, typeof ServerResponse>;
90
94
  constructor(options: RuntimeOptions);
91
95
  start(): Promise<number>;
92
- /** Whether this process works the queue at all: not the gate, an app with jobs, a gateway to reach. */
96
+ /** Whether this process works the queue at all: not the gate, a runs table, an app with jobs, a gateway to reach. */
93
97
  private worksQueue;
94
98
  /**
95
99
  * Starts the queue loop unless it is already live. The first pass runs at
@@ -110,6 +114,7 @@ export declare class Runtime {
110
114
  private awaitDataPath;
111
115
  stop(): Promise<void>;
112
116
  private every;
117
+ private prepareAI;
113
118
  private refreshToken;
114
119
  private scheduleTick;
115
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
@@ -44,13 +44,21 @@
44
44
  // in-process scheduler. Set by Provision on the
45
45
  // hosted kit-runtime; `isomorph dev` leaves it
46
46
  // unset and keeps cron in-process
47
+ // ISOMORPH_JOB_RUNS=none the app has no runs table (it never calls
48
+ // isomorph.jobs): no queue loop, and --run-job
49
+ // runs without waiting on the data path. Set by
50
+ // Provision; the table exists otherwise
47
51
  // ISOMORPH_APP_ROOT the app root (default: the working directory)
52
+ import { AsyncLocalStorage } from 'node:async_hooks';
48
53
  import { createServer } from 'node:http';
49
54
  import { readFile, readdir, stat } from 'node:fs/promises';
50
55
  import { register } from 'node:module';
51
56
  import { join, resolve } from 'node:path';
52
57
  import { pathToFileURL } from 'node:url';
53
58
  import { createClient, IsomorphError } from './index.js';
59
+ const actionIdentity = new AsyncLocalStorage();
60
+ const identityKey = Symbol.for('isomorph.actionIdentity');
61
+ globalThis[identityKey] = () => actionIdentity.getStore();
54
62
  export const ACTIONS_DIR = 'actions';
55
63
  export const JOBS_DIR = 'jobs';
56
64
  export const RUNS_TABLE = 'isomorph_job_runs';
@@ -227,6 +235,7 @@ export class Runtime {
227
235
  running = new Set();
228
236
  timers = [];
229
237
  stopped = false;
238
+ aiBinding;
230
239
  // The queue loop is live while a pass runs or its next pass is timed; a
231
240
  // wake that arrives mid-pass is remembered so the pass after it looks again.
232
241
  queueLive = false;
@@ -256,9 +265,9 @@ export class Runtime {
256
265
  this.wakeQueue();
257
266
  return port;
258
267
  }
259
- /** Whether this process works the queue at all: not the gate, an app with jobs, a gateway to reach. */
268
+ /** Whether this process works the queue at all: not the gate, a runs table, an app with jobs, a gateway to reach. */
260
269
  worksQueue() {
261
- return this.options.mode !== 'gate' && this.inventory.jobs.length > 0 && Boolean(this.options.gatewayUrl);
270
+ return this.options.mode !== 'gate' && this.options.jobRuns !== false && this.inventory.jobs.length > 0 && Boolean(this.options.gatewayUrl);
262
271
  }
263
272
  /**
264
273
  * Starts the queue loop unless it is already live. The first pass runs at
@@ -313,7 +322,7 @@ export class Runtime {
313
322
  this.log(`this app has no ${JOBS_DIR}/${name}.ts`);
314
323
  return false;
315
324
  }
316
- if (this.options.gatewayUrl && !(await this.awaitDataPath()))
325
+ if (this.options.gatewayUrl && this.options.jobRuns !== false && !(await this.awaitDataPath()))
317
326
  return false;
318
327
  return this.runScheduled(name, new Date(Math.floor(now.getTime() / 60_000) * 60_000));
319
328
  }
@@ -356,6 +365,30 @@ export class Runtime {
356
365
  };
357
366
  this.timers.push(setTimeout(loop, ms));
358
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
+ }
359
392
  async refreshToken() {
360
393
  if (!this.options.tokenFile)
361
394
  return;
@@ -459,7 +492,10 @@ export class Runtime {
459
492
  const app = header(request, 'X-Harbour-Verified-App');
460
493
  if (!tenant || !app)
461
494
  return refuse(response, 401, 'AUTH_REQUIRED', 'the request did not come through the Isomorph gateway', requestId);
462
- 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 ?? '' };
463
499
  const raw = await readBody(request);
464
500
  if (raw === undefined)
465
501
  return refuse(response, 413, 'VALIDATION_FAILED', 'the request body is larger than 1 MiB', requestId);
@@ -477,8 +513,9 @@ export class Runtime {
477
513
  return answer(response, 200, { data: await this.reserved(name, input, context) }, requestId);
478
514
  if (!this.inventory.actions.includes(name))
479
515
  return refuse(response, 404, 'NOT_FOUND', `this app has no ${ACTIONS_DIR}/${name}.ts`, requestId);
516
+ await this.prepareAI();
480
517
  const handler = await loadHandler(this.options.root, ACTIONS_DIR, name);
481
- const result = await handler(input, context);
518
+ const result = await actionIdentity.run(header(request, 'X-Harbour-Identity-Context'), () => handler(input, context));
482
519
  return answer(response, 200, { data: result === undefined ? null : result }, requestId);
483
520
  }
484
521
  catch (error) {
@@ -487,6 +524,10 @@ export class Runtime {
487
524
  this.log(`${name}: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
488
525
  return refuse(response, 500, 'INTERNAL', `the action ${name} failed; its log names the cause`, requestId);
489
526
  }
527
+ finally {
528
+ response.removeListener('close', abort);
529
+ controller.abort();
530
+ }
490
531
  }
491
532
  async reserved(name, input, context) {
492
533
  const body = (input && typeof input === 'object' ? input : {});
@@ -529,6 +570,7 @@ export function optionsFromEnvironment(env = process.env) {
529
570
  gatewayUrl: (env.ISOMORPH_GATEWAY_URL || env.HARBOUR_GATEWAY_URL || '').replace(/\/$/, ''),
530
571
  mode: env.ISOMORPH_RUNTIME_MODE === 'gate' ? 'gate' : env.ISOMORPH_JOBS_SCHEDULER === 'platform' ? 'actions' : 'full',
531
572
  tokenFile: env.HARBOUR_WORKLOAD_TOKEN_FILE || undefined,
573
+ jobRuns: env.ISOMORPH_JOB_RUNS !== 'none',
532
574
  };
533
575
  }
534
576
  /** The job named by `--run-job <name>` or `--run-job=<name>`; undefined when the runtime is a server. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isomorph.ai/app-sdk",
3
- "version": "1.4.2",
3
+ "version": "1.5.0",
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
  }