@skaile/forge-agent-runtime 1.1.0 → 1.1.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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.1
4
+
5
+ - `app-runner-host`: a run whose `skaile serve` child exits, or whose connection is found dead on a command (`410`), is torn down immediately: credentials erased, scratch directory removed, listener closed, later requests `404`. The final error event is still delivered to `onEvent`. Before, the run stayed registered until its lease expired.
6
+ - `POST /runs` answers `400` when `model` does not name an enabled `llm` provider of the snapshot (`<providerId>/<modelId>`), before any directory or process is created. Before it surfaced as an opaque `503` after startup.
7
+ - Native tools stay disabled through `tools.allowed: ["WebFetch"]`, because the Workspaces schema has no explicit empty form; a test now asserts that `buildOmpToolArgs` turns the written agent definition into `--no-tools`.
8
+ - Internal: the registry shape is validated once (`requireRegistryShape`) and `StartRunRequest.registry` is typed; routing uses one method table; the provider snapshot throws a domain error that the host maps to `400` instead of depending on the HTTP request module.
9
+
3
10
  ## 1.1.0
4
11
 
5
12
  - New entry `@skaile/forge-agent-runtime/app-runner-host`: `createAppRunnerHost({ rootDir, authenticate, onEvent, appTools })`, an authenticated HTTP host that runs one isolated `omp` agent per request for an app that deploys it privately next to itself. Node-only; it imports no Nuxt, database or settings UI code, and the package root is unchanged.
package/README.md CHANGED
@@ -71,14 +71,14 @@ destination. `host.close()` terminates every run.
71
71
 
72
72
  | Request | Behaviour |
73
73
  |---|---|
74
- | `POST /runs` | Body `{ id, model, registry, credentialEnv, systemPrompt, mcpAuthorization, expiresAt }`. `id` is a UUID, `expiresAt` an ISO-8601 time or epoch milliseconds in the future, `mcpAuthorization` the full `Authorization` header value for the app MCP endpoint. `400` invalid, `409` id already active, `503` the runtime did not start, `201` started. |
75
- | `POST /runs/:id/commands` | One Workspaces command: `prompt`, `cancel`, `reply`, `capability_register`, `capability_deregister` or `capability_result`. `400` any other type or a malformed field, `404` no such run, `410` lease expired (except `cancel`), `202` sent. `capability_register` accepts only the declared `browserActionNames`. |
74
+ | `POST /runs` | Body `{ id, model, registry, credentialEnv, systemPrompt, mcpAuthorization, expiresAt }`. `id` is a UUID, `expiresAt` an ISO-8601 time or epoch milliseconds in the future, `mcpAuthorization` the full `Authorization` header value for the app MCP endpoint. `400` invalid, including a `model` whose `<providerId>` is not an enabled `llm` entry of the `registry` (refused before any directory or process exists), `409` id already active, `503` the runtime did not start, `201` started. |
75
+ | `POST /runs/:id/commands` | One Workspaces command: `prompt`, `cancel`, `reply`, `capability_register`, `capability_deregister` or `capability_result`. `400` any other type or a malformed field, `404` no such run, `410` lease expired (except `cancel`) or the run's process has ended, `202` sent. `capability_register` accepts only the declared `browserActionNames`. |
76
76
  | `PATCH /runs/:id/lease` | Body `{ expiresAt }`. `400` not in the future, `404` no such run, `410` already expired. |
77
77
  | `DELETE /runs/:id` | Idempotent `204`. Stops the process and its listener, erases the run's credentials and removes its scratch directory. |
78
78
 
79
79
  `authenticate` is called first. `false` answers `401` and a throw `500`; the body
80
80
  is never read in either case. When a lease expires the turn is cancelled, further
81
- commands answer `410`, and the run is torn down a few seconds later.
81
+ commands answer `410`, and the run is torn down a few seconds later. A run whose child process exits (or whose connection is found dead) is torn down at once: credentials erased, scratch removed, later requests answer `404`.
82
82
 
83
83
  What a run is:
84
84
 
@@ -2,8 +2,8 @@ import { randomBytes } from 'node:crypto';
2
2
  import { join, resolve } from 'node:path';
3
3
  import { consola } from 'consola';
4
4
  import { P as ProcessManagerAdapter } from '../shared/forge-agent-runtime.B1LZr0Sl.mjs';
5
- import { rm, mkdir, writeFile } from 'node:fs/promises';
6
5
  import { registryEntries, isEntryEnabled, entryPolicyRefusal, providerCredentialEnvName, parseCredentialRef, writeProviderLayer } from '@skaile/forge-settings/server';
6
+ import { rm, mkdir, writeFile } from 'node:fs/promises';
7
7
  import 'node:events';
8
8
  import 'node:child_process';
9
9
  import 'node:fs';
@@ -42,6 +42,10 @@ function hostAllowedFor(tools) {
42
42
  return [`mcp__${tools.mcpName}__*`, ...tools.browserActionNames];
43
43
  }
44
44
 
45
+ function isRecord(value) {
46
+ return typeof value === "object" && value !== null && !Array.isArray(value);
47
+ }
48
+
45
49
  class RequestError extends Error {
46
50
  constructor(status, message) {
47
51
  super(message);
@@ -53,8 +57,21 @@ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
53
57
  function isUuid(value) {
54
58
  return typeof value === "string" && UUID.test(value);
55
59
  }
56
- function isRecord(value) {
57
- return typeof value === "object" && value !== null && !Array.isArray(value);
60
+ function requireRegistryShape(registry) {
61
+ if (!isRecord(registry)) throw new RequestError(400, "'registry' must be an object");
62
+ if (Array.isArray(registry.order) && !isRecord(registry.providers)) {
63
+ throw new RequestError(400, "'registry.providers' must be an object");
64
+ }
65
+ const view = registry;
66
+ for (const [id, entry] of registryEntries(view)) {
67
+ if (!isRecord(entry) || typeof entry.kind !== "string") {
68
+ throw new RequestError(400, `registry entry '${id}' needs a 'kind'`);
69
+ }
70
+ if (entry.credential !== void 0 && !isRecord(entry.credential)) {
71
+ throw new RequestError(400, `registry entry '${id}': 'credential' must be an object`);
72
+ }
73
+ }
74
+ return view;
58
75
  }
59
76
  function requireRecord(body) {
60
77
  if (!isRecord(body)) throw new RequestError(400, "request body must be a JSON object");
@@ -85,7 +102,7 @@ function parseStartRequest(body, now) {
85
102
  if (/\s/.test(model) || model.startsWith("-")) {
86
103
  throw new RequestError(400, "'model' must be '<providerId>/<modelId>' without whitespace");
87
104
  }
88
- if (!isRecord(record.registry)) throw new RequestError(400, "'registry' must be an object");
105
+ const registry = requireRegistryShape(record.registry);
89
106
  const credentialEnv = record.credentialEnv;
90
107
  if (!isRecord(credentialEnv)) throw new RequestError(400, "'credentialEnv' must be an object");
91
108
  for (const [name, value] of Object.entries(credentialEnv)) {
@@ -103,7 +120,7 @@ function parseStartRequest(body, now) {
103
120
  return {
104
121
  id,
105
122
  model,
106
- registry: record.registry,
123
+ registry,
107
124
  credentialEnv: { ...credentialEnv },
108
125
  systemPrompt: record.systemPrompt,
109
126
  mcpAuthorization,
@@ -210,37 +227,26 @@ async function createRunDirs(paths) {
210
227
  async function removeRunDirs(paths) {
211
228
  await rm(paths.root, { recursive: true, force: true, maxRetries: 3 });
212
229
  }
213
- function requireRegistryShape(registry) {
214
- if (!isRecord(registry)) throw new RequestError(400, "'registry' must be an object");
215
- if (Array.isArray(registry.order) && !isRecord(registry.providers)) {
216
- throw new RequestError(400, "'registry.providers' must be an object");
217
- }
218
- const view = registry;
219
- for (const [id, entry] of registryEntries(view)) {
220
- if (!isRecord(entry) || typeof entry.kind !== "string") {
221
- throw new RequestError(400, `registry entry '${id}' needs a 'kind'`);
222
- }
223
- if (entry.credential !== void 0 && !isRecord(entry.credential)) {
224
- throw new RequestError(400, `registry entry '${id}': 'credential' must be an object`);
225
- }
230
+ class SnapshotError extends Error {
231
+ constructor(message) {
232
+ super(message);
233
+ this.name = "SnapshotError";
226
234
  }
227
- return view;
228
235
  }
229
236
  function prepareProviderSnapshot(registry, credentialEnv) {
230
237
  const providers = {};
231
238
  const env = {};
232
239
  const names = /* @__PURE__ */ new Set();
233
- for (const [id, entry] of registryEntries(requireRegistryShape(registry))) {
240
+ for (const [id, entry] of registryEntries(registry)) {
234
241
  if (entry.kind !== "llm" || !isEntryEnabled(entry)) continue;
235
242
  const interpolation = entryPolicyRefusal(
236
243
  { ...entry, credential: void 0 },
237
244
  { envNames: [] }
238
245
  );
239
- if (interpolation) throw new RequestError(400, `registry entry '${id}': ${interpolation}`);
246
+ if (interpolation) throw new SnapshotError(`registry entry '${id}': ${interpolation}`);
240
247
  const canonical = providerCredentialEnvName(id);
241
248
  if (names.has(canonical)) {
242
- throw new RequestError(
243
- 400,
249
+ throw new SnapshotError(
244
250
  `registry entry '${id}' collides with another entry's credential name`
245
251
  );
246
252
  }
@@ -252,8 +258,7 @@ function prepareProviderSnapshot(registry, credentialEnv) {
252
258
  }
253
259
  const parsed = parseCredentialRef(ref);
254
260
  if (parsed?.form !== "env") {
255
- throw new RequestError(
256
- 400,
261
+ throw new SnapshotError(
257
262
  `registry entry '${id}': only env: credential references are allowed`
258
263
  );
259
264
  }
@@ -266,10 +271,17 @@ function prepareProviderSnapshot(registry, credentialEnv) {
266
271
  }
267
272
  const providerIds = Object.keys(providers);
268
273
  if (providerIds.length === 0) {
269
- throw new RequestError(400, "'registry' has no enabled llm provider");
274
+ throw new SnapshotError("'registry' has no enabled llm provider");
270
275
  }
271
276
  return { providers, providerIds, credentialEnv: env };
272
277
  }
278
+ function requireModelProvider(model, snapshot) {
279
+ if (!snapshot.providerIds.some((id) => model.startsWith(`${id}/`) && model.length > id.length + 1)) {
280
+ throw new SnapshotError(
281
+ "'model' must name an enabled llm provider of the registry: '<providerId>/<modelId>'"
282
+ );
283
+ }
284
+ }
273
285
  async function writeRunWorkspace(paths, input) {
274
286
  const { appTools } = input;
275
287
  const skaileYaml = {
@@ -382,7 +394,7 @@ function createAppRunnerHost(options) {
382
394
  async function pump(run) {
383
395
  try {
384
396
  for await (const event of run.adapter.listen()) {
385
- if (run.teardown) return;
397
+ if (run.teardown && !run.exited) return;
386
398
  await onEvent(run.id, event);
387
399
  }
388
400
  } catch (err) {
@@ -413,8 +425,7 @@ function createAppRunnerHost(options) {
413
425
  log.warn(`[${run.id}] cancel on expiry failed: ${err.message}`);
414
426
  }
415
427
  }
416
- async function start(request) {
417
- const snapshot = prepareProviderSnapshot(request.registry, request.credentialEnv);
428
+ async function start(request, snapshot) {
418
429
  const paths = runPaths(rootDir, request.id);
419
430
  const wsAuthToken = randomBytes(32).toString("base64url");
420
431
  const mcpEnv = { [APP_MCP_AUTHORIZATION_ENV]: request.mcpAuthorization };
@@ -448,6 +459,12 @@ function createAppRunnerHost(options) {
448
459
  wsAuthToken
449
460
  });
450
461
  if (run.teardown) throw new Error("run was terminated while starting");
462
+ if (!run.adapter.isConnected()) throw new Error("runtime exited while starting");
463
+ run.adapter.getProcessManager().on(`error:${paths.workspace}`, () => {
464
+ log.warn(`[${run.id}] runtime process exited; tearing the run down`);
465
+ run.exited = true;
466
+ void tearDown(run);
467
+ });
451
468
  run.state = "running";
452
469
  armLease(run);
453
470
  })();
@@ -463,11 +480,18 @@ function createAppRunnerHost(options) {
463
480
  async function startRun(req, res) {
464
481
  const request = parseStartRequest(await readJson(req), Date.now());
465
482
  if (runs.has(request.id)) throw new RequestError(409, "a run with this id is already active");
483
+ let snapshot;
466
484
  try {
467
- const run = await start(request);
468
- json(res, 201, { id: run.id, expiresAt: new Date(run.expiresAt).toISOString() });
485
+ snapshot = prepareProviderSnapshot(request.registry, request.credentialEnv);
486
+ requireModelProvider(request.model, snapshot);
469
487
  } catch (err) {
470
- if (err instanceof RequestError) throw err;
488
+ if (err instanceof SnapshotError) throw new RequestError(400, err.message);
489
+ throw err;
490
+ }
491
+ try {
492
+ const run = await start(request, snapshot);
493
+ json(res, 201, { id: run.id, expiresAt: new Date(run.expiresAt).toISOString() });
494
+ } catch {
471
495
  json(res, 503, { error: "runner could not be started" });
472
496
  }
473
497
  }
@@ -486,7 +510,8 @@ function createAppRunnerHost(options) {
486
510
  try {
487
511
  await run.adapter.sendCommand(command);
488
512
  } catch {
489
- throw new RequestError(409, "run process is not connected");
513
+ void tearDown(run);
514
+ throw new RequestError(410, "run process has ended");
490
515
  }
491
516
  json(res, 202, { accepted: true });
492
517
  }
@@ -499,35 +524,31 @@ function createAppRunnerHost(options) {
499
524
  armLease(run);
500
525
  json(res, 200, { expiresAt: new Date(expiresAt).toISOString() });
501
526
  }
502
- async function deleteRun(res, id) {
527
+ async function deleteRun(_req, res, id) {
503
528
  if (!isUuid(id)) throw new RequestError(404, "no such run");
504
529
  const run = runs.get(id);
505
530
  if (run) await terminate(run);
506
531
  json(res, 204);
507
532
  }
533
+ const collectionRoutes = /* @__PURE__ */ new Map([["POST", startRun]]);
534
+ const runRoutes = /* @__PURE__ */ new Map([["DELETE", deleteRun]]);
535
+ const leafRoutes = /* @__PURE__ */ new Map([
536
+ ["commands", /* @__PURE__ */ new Map([["POST", sendCommand]])],
537
+ // /runs/:id/commands
538
+ ["lease", /* @__PURE__ */ new Map([["PATCH", renewLease]])]
539
+ // /runs/:id/lease
540
+ ]);
508
541
  async function route(req, res) {
509
542
  const segments = new URL(req.url ?? "/", "http://runner.invalid").pathname.split("/").filter((segment) => segment !== "");
510
- const method = req.method ?? "GET";
511
543
  const [root, id, leaf] = segments;
512
544
  if (root !== "runs" || segments.length > 3) throw new RequestError(404, "not found");
513
- if (id === void 0) {
514
- if (method !== "POST") throw new RequestError(405, "method not allowed");
515
- return startRun(req, res);
516
- }
517
- const runId = decodeURIComponent(id);
518
- if (leaf === void 0) {
519
- if (method !== "DELETE") throw new RequestError(405, "method not allowed");
520
- return deleteRun(res, runId);
521
- }
522
- if (leaf === "commands") {
523
- if (method !== "POST") throw new RequestError(405, "method not allowed");
524
- return sendCommand(req, res, runId);
525
- }
526
- if (leaf === "lease") {
527
- if (method !== "PATCH") throw new RequestError(405, "method not allowed");
528
- return renewLease(req, res, runId);
529
- }
530
- throw new RequestError(404, "not found");
545
+ let methods;
546
+ if (id === void 0) methods = collectionRoutes;
547
+ else methods = leaf === void 0 ? runRoutes : leafRoutes.get(leaf);
548
+ if (!methods) throw new RequestError(404, "not found");
549
+ const handler = methods.get(req.method ?? "GET");
550
+ if (!handler) throw new RequestError(405, "method not allowed");
551
+ return handler(req, res, id === void 0 ? "" : decodeURIComponent(id));
531
552
  }
532
553
  return {
533
554
  async handler(req, res) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skaile/forge-agent-runtime",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "Agent process lifecycle for Skaile forge apps: spawns `skaile serve`, connects over WebSocket, and exposes the AgentAdapter, harness registry and session manager. Node-only.",
5
5
  "type": "module",
6
6
  "packageManager": "bun@1.3.9",