@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 +7 -0
- package/README.md +3 -3
- package/dist/app-runner-host/index.mjs +74 -53
- package/package.json +1 -1
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
|
|
57
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
468
|
-
|
|
485
|
+
snapshot = prepareProviderSnapshot(request.registry, request.credentialEnv);
|
|
486
|
+
requireModelProvider(request.model, snapshot);
|
|
469
487
|
} catch (err) {
|
|
470
|
-
if (err instanceof
|
|
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
|
-
|
|
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
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
const
|
|
518
|
-
if (
|
|
519
|
-
|
|
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.
|
|
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",
|