@skaile/forge-agent-runtime 1.0.0 → 1.1.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.
- package/CHANGELOG.md +8 -0
- package/README.md +58 -0
- package/dist/app-runner-host/index.d.mts +61 -0
- package/dist/app-runner-host/index.d.ts +61 -0
- package/dist/app-runner-host/index.mjs +565 -0
- package/dist/index.d.mts +6 -124
- package/dist/index.d.ts +6 -124
- package/dist/index.mjs +11 -535
- package/dist/shared/forge-agent-runtime.B1LZr0Sl.mjs +538 -0
- package/dist/shared/forge-agent-runtime.DlNAoPjs.d.mts +122 -0
- package/dist/shared/forge-agent-runtime.DlNAoPjs.d.ts +122 -0
- package/package.json +6 -2
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { consola } from 'consola';
|
|
4
|
+
import { P as ProcessManagerAdapter } from '../shared/forge-agent-runtime.B1LZr0Sl.mjs';
|
|
5
|
+
import { rm, mkdir, writeFile } from 'node:fs/promises';
|
|
6
|
+
import { registryEntries, isEntryEnabled, entryPolicyRefusal, providerCredentialEnvName, parseCredentialRef, writeProviderLayer } from '@skaile/forge-settings/server';
|
|
7
|
+
import 'node:events';
|
|
8
|
+
import 'node:child_process';
|
|
9
|
+
import 'node:fs';
|
|
10
|
+
import 'node:net';
|
|
11
|
+
import 'node:url';
|
|
12
|
+
import 'picocolors';
|
|
13
|
+
import '@skaile/workspaces/sdk/client';
|
|
14
|
+
import '@skaile/workspaces/sdk/transport/ws/client';
|
|
15
|
+
|
|
16
|
+
const APP_MCP_AUTHORIZATION_ENV = "APP_MCP_AUTHORIZATION";
|
|
17
|
+
const MCP_NAME = /^[A-Za-z0-9_-]+$/;
|
|
18
|
+
function assertAppTools(tools) {
|
|
19
|
+
if (!MCP_NAME.test(tools.mcpName) || tools.mcpName.includes("__")) {
|
|
20
|
+
throw new TypeError(
|
|
21
|
+
"appTools.mcpName must contain only letters, digits, '_' and '-', and no '__'"
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
let url;
|
|
25
|
+
try {
|
|
26
|
+
url = new URL(tools.mcpUrl);
|
|
27
|
+
} catch {
|
|
28
|
+
throw new TypeError("appTools.mcpUrl must be an absolute URL");
|
|
29
|
+
}
|
|
30
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
31
|
+
throw new TypeError("appTools.mcpUrl must be an http(s) URL");
|
|
32
|
+
}
|
|
33
|
+
for (const name of tools.browserActionNames) {
|
|
34
|
+
if (typeof name !== "string" || name === "" || name.includes("*") || name.startsWith("mcp__")) {
|
|
35
|
+
throw new TypeError(
|
|
36
|
+
"appTools.browserActionNames must be exact, non-empty names that do not start with 'mcp__'"
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function hostAllowedFor(tools) {
|
|
42
|
+
return [`mcp__${tools.mcpName}__*`, ...tools.browserActionNames];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
class RequestError extends Error {
|
|
46
|
+
constructor(status, message) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.status = status;
|
|
49
|
+
this.name = "RequestError";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
53
|
+
function isUuid(value) {
|
|
54
|
+
return typeof value === "string" && UUID.test(value);
|
|
55
|
+
}
|
|
56
|
+
function isRecord(value) {
|
|
57
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
58
|
+
}
|
|
59
|
+
function requireRecord(body) {
|
|
60
|
+
if (!isRecord(body)) throw new RequestError(400, "request body must be a JSON object");
|
|
61
|
+
return body;
|
|
62
|
+
}
|
|
63
|
+
function requireString(body, key) {
|
|
64
|
+
const value = body[key];
|
|
65
|
+
if (typeof value !== "string" || value === "") {
|
|
66
|
+
throw new RequestError(400, `'${key}' must be a non-empty string`);
|
|
67
|
+
}
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
function parseExpiry(value, now) {
|
|
71
|
+
let at = Number.NaN;
|
|
72
|
+
if (typeof value === "number") at = value;
|
|
73
|
+
else if (typeof value === "string" && /\d{4}-\d{2}-\d{2}T/.test(value)) at = Date.parse(value);
|
|
74
|
+
if (!Number.isFinite(at)) {
|
|
75
|
+
throw new RequestError(400, "'expiresAt' must be an ISO-8601 timestamp or epoch milliseconds");
|
|
76
|
+
}
|
|
77
|
+
if (at <= now) throw new RequestError(400, "'expiresAt' must be in the future");
|
|
78
|
+
return at;
|
|
79
|
+
}
|
|
80
|
+
function parseStartRequest(body, now) {
|
|
81
|
+
const record = requireRecord(body);
|
|
82
|
+
const id = record.id;
|
|
83
|
+
if (!isUuid(id)) throw new RequestError(400, "'id' must be a UUID");
|
|
84
|
+
const model = requireString(record, "model");
|
|
85
|
+
if (/\s/.test(model) || model.startsWith("-")) {
|
|
86
|
+
throw new RequestError(400, "'model' must be '<providerId>/<modelId>' without whitespace");
|
|
87
|
+
}
|
|
88
|
+
if (!isRecord(record.registry)) throw new RequestError(400, "'registry' must be an object");
|
|
89
|
+
const credentialEnv = record.credentialEnv;
|
|
90
|
+
if (!isRecord(credentialEnv)) throw new RequestError(400, "'credentialEnv' must be an object");
|
|
91
|
+
for (const [name, value] of Object.entries(credentialEnv)) {
|
|
92
|
+
if (typeof value !== "string") {
|
|
93
|
+
throw new RequestError(400, `'credentialEnv.${name}' must be a string`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (typeof record.systemPrompt !== "string") {
|
|
97
|
+
throw new RequestError(400, "'systemPrompt' must be a string");
|
|
98
|
+
}
|
|
99
|
+
const mcpAuthorization = requireString(record, "mcpAuthorization");
|
|
100
|
+
if (/[\u0000-\u001f\u007f]/.test(mcpAuthorization)) {
|
|
101
|
+
throw new RequestError(400, "'mcpAuthorization' must be a single header value");
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
id,
|
|
105
|
+
model,
|
|
106
|
+
registry: record.registry,
|
|
107
|
+
credentialEnv: { ...credentialEnv },
|
|
108
|
+
systemPrompt: record.systemPrompt,
|
|
109
|
+
mcpAuthorization,
|
|
110
|
+
expiresAt: parseExpiry(record.expiresAt, now)
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function parseLeaseRequest(body, now) {
|
|
114
|
+
return parseExpiry(requireRecord(body).expiresAt, now);
|
|
115
|
+
}
|
|
116
|
+
const RUN_COMMAND_TYPES = [
|
|
117
|
+
"prompt",
|
|
118
|
+
"cancel",
|
|
119
|
+
"reply",
|
|
120
|
+
"capability_register",
|
|
121
|
+
"capability_deregister",
|
|
122
|
+
"capability_result"
|
|
123
|
+
];
|
|
124
|
+
function optionalStrings(body, keys) {
|
|
125
|
+
const out = {};
|
|
126
|
+
for (const key of keys) {
|
|
127
|
+
const value = body[key];
|
|
128
|
+
if (value === void 0) continue;
|
|
129
|
+
if (typeof value !== "string") throw new RequestError(400, `'${key}' must be a string`);
|
|
130
|
+
out[key] = value;
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
function parseCommand(body, browserActionNames) {
|
|
135
|
+
const record = requireRecord(body);
|
|
136
|
+
const type = record.type;
|
|
137
|
+
if (typeof type !== "string" || !RUN_COMMAND_TYPES.includes(type)) {
|
|
138
|
+
throw new RequestError(400, `unknown command type; allowed: ${RUN_COMMAND_TYPES.join(", ")}`);
|
|
139
|
+
}
|
|
140
|
+
switch (type) {
|
|
141
|
+
case "prompt":
|
|
142
|
+
return {
|
|
143
|
+
type: "prompt",
|
|
144
|
+
prompt: requireString(record, "prompt"),
|
|
145
|
+
...optionalStrings(record, ["messageId", "senderId", "turnId"])
|
|
146
|
+
};
|
|
147
|
+
case "cancel":
|
|
148
|
+
return { type: "cancel", ...optionalStrings(record, ["senderId"]) };
|
|
149
|
+
case "reply":
|
|
150
|
+
return {
|
|
151
|
+
type: "reply",
|
|
152
|
+
answer: requireString(record, "answer"),
|
|
153
|
+
...optionalStrings(record, ["question", "requestId", "turnId"])
|
|
154
|
+
};
|
|
155
|
+
case "capability_register": {
|
|
156
|
+
const list = record.capabilities;
|
|
157
|
+
if (!Array.isArray(list) || list.length === 0) {
|
|
158
|
+
throw new RequestError(400, "'capabilities' must be a non-empty array");
|
|
159
|
+
}
|
|
160
|
+
const capabilities = list.map((entry) => {
|
|
161
|
+
if (!isRecord(entry) || typeof entry.name !== "string") {
|
|
162
|
+
throw new RequestError(400, "each capability needs a 'name'");
|
|
163
|
+
}
|
|
164
|
+
if (!browserActionNames.includes(entry.name)) {
|
|
165
|
+
throw new RequestError(
|
|
166
|
+
400,
|
|
167
|
+
`capability '${entry.name}' is not a declared browser action`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
const { render: _render, ...rest } = entry;
|
|
171
|
+
return { ...rest, side: "client", origin: { kind: "client" } };
|
|
172
|
+
});
|
|
173
|
+
return { type: "capability_register", capabilities };
|
|
174
|
+
}
|
|
175
|
+
case "capability_deregister": {
|
|
176
|
+
const names = record.names;
|
|
177
|
+
if (!Array.isArray(names) || names.some((name) => typeof name !== "string")) {
|
|
178
|
+
throw new RequestError(400, "'names' must be an array of strings");
|
|
179
|
+
}
|
|
180
|
+
return { type: "capability_deregister", names };
|
|
181
|
+
}
|
|
182
|
+
case "capability_result":
|
|
183
|
+
return {
|
|
184
|
+
type: "capability_result",
|
|
185
|
+
callId: requireString(record, "callId"),
|
|
186
|
+
result: record.result
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function runPaths(rootDir, runId) {
|
|
192
|
+
const root = join(rootDir, runId);
|
|
193
|
+
return {
|
|
194
|
+
root,
|
|
195
|
+
workspace: join(root, "workspace"),
|
|
196
|
+
home: join(root, "home"),
|
|
197
|
+
skaileHome: join(root, "skaile-home"),
|
|
198
|
+
tmp: join(root, "tmp")
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
async function createRunDirs(paths) {
|
|
202
|
+
await rm(paths.root, { recursive: true, force: true });
|
|
203
|
+
await mkdir(paths.root, { recursive: true, mode: 448 });
|
|
204
|
+
await Promise.all(
|
|
205
|
+
[paths.workspace, paths.home, paths.skaileHome, paths.tmp].map(
|
|
206
|
+
(dir) => mkdir(dir, { mode: 448 })
|
|
207
|
+
)
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
async function removeRunDirs(paths) {
|
|
211
|
+
await rm(paths.root, { recursive: true, force: true, maxRetries: 3 });
|
|
212
|
+
}
|
|
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
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return view;
|
|
228
|
+
}
|
|
229
|
+
function prepareProviderSnapshot(registry, credentialEnv) {
|
|
230
|
+
const providers = {};
|
|
231
|
+
const env = {};
|
|
232
|
+
const names = /* @__PURE__ */ new Set();
|
|
233
|
+
for (const [id, entry] of registryEntries(requireRegistryShape(registry))) {
|
|
234
|
+
if (entry.kind !== "llm" || !isEntryEnabled(entry)) continue;
|
|
235
|
+
const interpolation = entryPolicyRefusal(
|
|
236
|
+
{ ...entry, credential: void 0 },
|
|
237
|
+
{ envNames: [] }
|
|
238
|
+
);
|
|
239
|
+
if (interpolation) throw new RequestError(400, `registry entry '${id}': ${interpolation}`);
|
|
240
|
+
const canonical = providerCredentialEnvName(id);
|
|
241
|
+
if (names.has(canonical)) {
|
|
242
|
+
throw new RequestError(
|
|
243
|
+
400,
|
|
244
|
+
`registry entry '${id}' collides with another entry's credential name`
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
names.add(canonical);
|
|
248
|
+
const ref = entry.credential?.ref;
|
|
249
|
+
if (ref === void 0) {
|
|
250
|
+
providers[id] = { ...entry };
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
const parsed = parseCredentialRef(ref);
|
|
254
|
+
if (parsed?.form !== "env") {
|
|
255
|
+
throw new RequestError(
|
|
256
|
+
400,
|
|
257
|
+
`registry entry '${id}': only env: credential references are allowed`
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
const secret = credentialEnv[parsed.name];
|
|
261
|
+
if (secret) env[canonical] = secret;
|
|
262
|
+
providers[id] = {
|
|
263
|
+
...entry,
|
|
264
|
+
credential: { ...entry.credential, ref: `env:${canonical}` }
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
const providerIds = Object.keys(providers);
|
|
268
|
+
if (providerIds.length === 0) {
|
|
269
|
+
throw new RequestError(400, "'registry' has no enabled llm provider");
|
|
270
|
+
}
|
|
271
|
+
return { providers, providerIds, credentialEnv: env };
|
|
272
|
+
}
|
|
273
|
+
async function writeRunWorkspace(paths, input) {
|
|
274
|
+
const { appTools } = input;
|
|
275
|
+
const skaileYaml = {
|
|
276
|
+
agent: { definition: "./agent", prompt: input.systemPrompt },
|
|
277
|
+
mcp_servers: [
|
|
278
|
+
{
|
|
279
|
+
id: appTools.mcpName,
|
|
280
|
+
transport: "http",
|
|
281
|
+
url: appTools.mcpUrl,
|
|
282
|
+
headers: { Authorization: `env:${APP_MCP_AUTHORIZATION_ENV}` }
|
|
283
|
+
}
|
|
284
|
+
]
|
|
285
|
+
};
|
|
286
|
+
const agentYaml = {
|
|
287
|
+
name: "app-runner",
|
|
288
|
+
version: "1.0.0",
|
|
289
|
+
tools: { allowed: ["WebFetch"], host_allowed: hostAllowedFor(appTools) }
|
|
290
|
+
};
|
|
291
|
+
await mkdir(join(paths.workspace, "agent"), { recursive: true, mode: 448 });
|
|
292
|
+
await writeFile(join(paths.workspace, "skaile.yaml"), `${JSON.stringify(skaileYaml, null, 2)}
|
|
293
|
+
`);
|
|
294
|
+
await writeFile(
|
|
295
|
+
join(paths.workspace, "agent", "agent.yaml"),
|
|
296
|
+
`${JSON.stringify(agentYaml, null, 2)}
|
|
297
|
+
`
|
|
298
|
+
);
|
|
299
|
+
writeProviderLayer(join(paths.workspace, ".skaile", "providers.yml"), input.snapshot.providers);
|
|
300
|
+
}
|
|
301
|
+
function baseChildEnv(paths) {
|
|
302
|
+
return {
|
|
303
|
+
PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin",
|
|
304
|
+
LANG: process.env.LANG ?? "C.UTF-8",
|
|
305
|
+
HOME: paths.home,
|
|
306
|
+
SKAILE_HOME: paths.skaileHome,
|
|
307
|
+
TMPDIR: paths.tmp
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const log = consola.withTag("app-runner-host");
|
|
312
|
+
const MAX_BODY_BYTES = 4 * 1024 * 1024;
|
|
313
|
+
const MAX_TIMER_MS = 2 ** 31 - 1;
|
|
314
|
+
const EXPIRY_GRACE_MS = 5e3;
|
|
315
|
+
const CHILD_HOST = "127.0.0.1";
|
|
316
|
+
function json(res, status, body) {
|
|
317
|
+
if (res.headersSent) {
|
|
318
|
+
res.end();
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (body === void 0) {
|
|
322
|
+
res.writeHead(status).end();
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(body));
|
|
326
|
+
}
|
|
327
|
+
async function readJson(req) {
|
|
328
|
+
const declared = Number(req.headers["content-length"]);
|
|
329
|
+
if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
|
|
330
|
+
throw new RequestError(413, "request body too large");
|
|
331
|
+
}
|
|
332
|
+
const chunks = [];
|
|
333
|
+
let size = 0;
|
|
334
|
+
for await (const chunk of req) {
|
|
335
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
336
|
+
size += buffer.length;
|
|
337
|
+
if (size > MAX_BODY_BYTES) throw new RequestError(413, "request body too large");
|
|
338
|
+
chunks.push(buffer);
|
|
339
|
+
}
|
|
340
|
+
try {
|
|
341
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
342
|
+
} catch {
|
|
343
|
+
throw new RequestError(400, "request body is not valid JSON");
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
function erase(record) {
|
|
347
|
+
for (const key of Object.keys(record)) delete record[key];
|
|
348
|
+
}
|
|
349
|
+
function createAppRunnerHost(options) {
|
|
350
|
+
assertAppTools(options.appTools);
|
|
351
|
+
const rootDir = resolve(options.rootDir);
|
|
352
|
+
const { appTools, authenticate, onEvent } = options;
|
|
353
|
+
const runs = /* @__PURE__ */ new Map();
|
|
354
|
+
let closed = false;
|
|
355
|
+
function tearDown(run) {
|
|
356
|
+
run.teardown ??= (async () => {
|
|
357
|
+
run.state = "closing";
|
|
358
|
+
clearTimeout(run.leaseTimer);
|
|
359
|
+
clearTimeout(run.reapTimer);
|
|
360
|
+
try {
|
|
361
|
+
await run.adapter.disconnect();
|
|
362
|
+
} catch (err) {
|
|
363
|
+
log.warn(`[${run.id}] disconnect failed: ${err.message}`);
|
|
364
|
+
}
|
|
365
|
+
await run.pump?.catch(() => {
|
|
366
|
+
});
|
|
367
|
+
for (const record of run.secrets) erase(record);
|
|
368
|
+
try {
|
|
369
|
+
await removeRunDirs(run.paths);
|
|
370
|
+
} catch (err) {
|
|
371
|
+
log.warn(`[${run.id}] scratch removal failed: ${err.message}`);
|
|
372
|
+
}
|
|
373
|
+
if (runs.get(run.id) === run) runs.delete(run.id);
|
|
374
|
+
})();
|
|
375
|
+
return run.teardown;
|
|
376
|
+
}
|
|
377
|
+
async function terminate(run) {
|
|
378
|
+
await run.startup?.catch(() => {
|
|
379
|
+
});
|
|
380
|
+
await tearDown(run);
|
|
381
|
+
}
|
|
382
|
+
async function pump(run) {
|
|
383
|
+
try {
|
|
384
|
+
for await (const event of run.adapter.listen()) {
|
|
385
|
+
if (run.teardown) return;
|
|
386
|
+
await onEvent(run.id, event);
|
|
387
|
+
}
|
|
388
|
+
} catch (err) {
|
|
389
|
+
log.error(`[${run.id}] event delivery failed; terminating: ${err.message}`);
|
|
390
|
+
void tearDown(run);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
function armLease(run) {
|
|
394
|
+
clearTimeout(run.leaseTimer);
|
|
395
|
+
if (run.state !== "running") return;
|
|
396
|
+
const remaining = run.expiresAt - Date.now();
|
|
397
|
+
if (remaining <= 0) {
|
|
398
|
+
void expire(run);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
run.leaseTimer = setTimeout(() => armLease(run), Math.min(remaining, MAX_TIMER_MS));
|
|
402
|
+
run.leaseTimer.unref();
|
|
403
|
+
}
|
|
404
|
+
async function expire(run) {
|
|
405
|
+
if (run.state !== "running") return;
|
|
406
|
+
run.state = "expired";
|
|
407
|
+
log.info(`[${run.id}] lease expired`);
|
|
408
|
+
run.reapTimer = setTimeout(() => void tearDown(run), EXPIRY_GRACE_MS);
|
|
409
|
+
run.reapTimer.unref();
|
|
410
|
+
try {
|
|
411
|
+
await run.adapter.abort();
|
|
412
|
+
} catch (err) {
|
|
413
|
+
log.warn(`[${run.id}] cancel on expiry failed: ${err.message}`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
async function start(request) {
|
|
417
|
+
const snapshot = prepareProviderSnapshot(request.registry, request.credentialEnv);
|
|
418
|
+
const paths = runPaths(rootDir, request.id);
|
|
419
|
+
const wsAuthToken = randomBytes(32).toString("base64url");
|
|
420
|
+
const mcpEnv = { [APP_MCP_AUTHORIZATION_ENV]: request.mcpAuthorization };
|
|
421
|
+
const credentialEnv = { ...baseChildEnv(paths), ...snapshot.credentialEnv };
|
|
422
|
+
const run = {
|
|
423
|
+
id: request.id,
|
|
424
|
+
paths,
|
|
425
|
+
state: "starting",
|
|
426
|
+
expiresAt: request.expiresAt,
|
|
427
|
+
adapter: new ProcessManagerAdapter(),
|
|
428
|
+
secrets: [request.credentialEnv, snapshot.credentialEnv, credentialEnv, mcpEnv]
|
|
429
|
+
};
|
|
430
|
+
runs.set(run.id, run);
|
|
431
|
+
run.startup = (async () => {
|
|
432
|
+
await createRunDirs(paths);
|
|
433
|
+
await writeRunWorkspace(paths, {
|
|
434
|
+
systemPrompt: request.systemPrompt,
|
|
435
|
+
snapshot,
|
|
436
|
+
appTools
|
|
437
|
+
});
|
|
438
|
+
run.pump = pump(run);
|
|
439
|
+
await run.adapter.connect({
|
|
440
|
+
projectDir: paths.workspace,
|
|
441
|
+
harness: "omp",
|
|
442
|
+
model: request.model,
|
|
443
|
+
registry: snapshot.providers,
|
|
444
|
+
providers: snapshot.providerIds,
|
|
445
|
+
credentialEnv,
|
|
446
|
+
env: mcpEnv,
|
|
447
|
+
host: CHILD_HOST,
|
|
448
|
+
wsAuthToken
|
|
449
|
+
});
|
|
450
|
+
if (run.teardown) throw new Error("run was terminated while starting");
|
|
451
|
+
run.state = "running";
|
|
452
|
+
armLease(run);
|
|
453
|
+
})();
|
|
454
|
+
try {
|
|
455
|
+
await run.startup;
|
|
456
|
+
} catch (err) {
|
|
457
|
+
log.error(`[${run.id}] start failed: ${err.message}`);
|
|
458
|
+
await tearDown(run);
|
|
459
|
+
throw err;
|
|
460
|
+
}
|
|
461
|
+
return run;
|
|
462
|
+
}
|
|
463
|
+
async function startRun(req, res) {
|
|
464
|
+
const request = parseStartRequest(await readJson(req), Date.now());
|
|
465
|
+
if (runs.has(request.id)) throw new RequestError(409, "a run with this id is already active");
|
|
466
|
+
try {
|
|
467
|
+
const run = await start(request);
|
|
468
|
+
json(res, 201, { id: run.id, expiresAt: new Date(run.expiresAt).toISOString() });
|
|
469
|
+
} catch (err) {
|
|
470
|
+
if (err instanceof RequestError) throw err;
|
|
471
|
+
json(res, 503, { error: "runner could not be started" });
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
function findRun(id) {
|
|
475
|
+
const run = isUuid(id) ? runs.get(id) : void 0;
|
|
476
|
+
if (!run || run.teardown) throw new RequestError(404, "no such run");
|
|
477
|
+
return run;
|
|
478
|
+
}
|
|
479
|
+
async function sendCommand(req, res, id) {
|
|
480
|
+
const run = findRun(id);
|
|
481
|
+
const command = parseCommand(await readJson(req), appTools.browserActionNames);
|
|
482
|
+
if (run.state === "starting") throw new RequestError(409, "run is still starting");
|
|
483
|
+
if (run.state === "expired" && command.type !== "cancel") {
|
|
484
|
+
throw new RequestError(410, "run lease has expired");
|
|
485
|
+
}
|
|
486
|
+
try {
|
|
487
|
+
await run.adapter.sendCommand(command);
|
|
488
|
+
} catch {
|
|
489
|
+
throw new RequestError(409, "run process is not connected");
|
|
490
|
+
}
|
|
491
|
+
json(res, 202, { accepted: true });
|
|
492
|
+
}
|
|
493
|
+
async function renewLease(req, res, id) {
|
|
494
|
+
const run = findRun(id);
|
|
495
|
+
const expiresAt = parseLeaseRequest(await readJson(req), Date.now());
|
|
496
|
+
if (run.state === "starting") throw new RequestError(409, "run is still starting");
|
|
497
|
+
if (run.state === "expired") throw new RequestError(410, "run lease has expired");
|
|
498
|
+
run.expiresAt = expiresAt;
|
|
499
|
+
armLease(run);
|
|
500
|
+
json(res, 200, { expiresAt: new Date(expiresAt).toISOString() });
|
|
501
|
+
}
|
|
502
|
+
async function deleteRun(res, id) {
|
|
503
|
+
if (!isUuid(id)) throw new RequestError(404, "no such run");
|
|
504
|
+
const run = runs.get(id);
|
|
505
|
+
if (run) await terminate(run);
|
|
506
|
+
json(res, 204);
|
|
507
|
+
}
|
|
508
|
+
async function route(req, res) {
|
|
509
|
+
const segments = new URL(req.url ?? "/", "http://runner.invalid").pathname.split("/").filter((segment) => segment !== "");
|
|
510
|
+
const method = req.method ?? "GET";
|
|
511
|
+
const [root, id, leaf] = segments;
|
|
512
|
+
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");
|
|
531
|
+
}
|
|
532
|
+
return {
|
|
533
|
+
async handler(req, res) {
|
|
534
|
+
let allowed;
|
|
535
|
+
try {
|
|
536
|
+
allowed = await authenticate(req);
|
|
537
|
+
} catch (err) {
|
|
538
|
+
log.error(`authenticate threw: ${err.message}`);
|
|
539
|
+
json(res, 500, { error: "authentication unavailable" });
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
if (!allowed) {
|
|
543
|
+
json(res, 401, { error: "unauthorized" });
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
try {
|
|
547
|
+
if (closed) throw new RequestError(503, "runner host is closed");
|
|
548
|
+
await route(req, res);
|
|
549
|
+
} catch (err) {
|
|
550
|
+
if (err instanceof RequestError) {
|
|
551
|
+
json(res, err.status, { error: err.message });
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
log.error(`request failed: ${err.message}`);
|
|
555
|
+
json(res, 500, { error: "internal error" });
|
|
556
|
+
}
|
|
557
|
+
},
|
|
558
|
+
async close() {
|
|
559
|
+
closed = true;
|
|
560
|
+
await Promise.all([...runs.values()].map((run) => terminate(run)));
|
|
561
|
+
}
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
export { createAppRunnerHost };
|