@terminus-ai/cli 0.0.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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1055 -0
  3. package/bin/agent-discovery.mjs +71 -0
  4. package/bin/agent-icon.mjs +77 -0
  5. package/bin/agent-models.mjs +77 -0
  6. package/bin/agent-type.mjs +51 -0
  7. package/bin/agentdev.mjs +657 -0
  8. package/bin/app-route-script.mjs +59 -0
  9. package/bin/app-runtime-contract.mjs +2 -0
  10. package/bin/appdev-remote.mjs +346 -0
  11. package/bin/appdev.mjs +4446 -0
  12. package/bin/apps.mjs +5512 -0
  13. package/bin/capability-calls.mjs +437 -0
  14. package/bin/capsule-data.mjs +260 -0
  15. package/bin/client.mjs +189 -0
  16. package/bin/commands.mjs +1194 -0
  17. package/bin/dev-capsules.mjs +1599 -0
  18. package/bin/dev-contract.mjs +262 -0
  19. package/bin/dev-data.mjs +287 -0
  20. package/bin/dev-members.mjs +18 -0
  21. package/bin/dev-net.mjs +316 -0
  22. package/bin/dev-notification-popup.mjs +628 -0
  23. package/bin/dev-ports.mjs +567 -0
  24. package/bin/dev-server-binding.mjs +35 -0
  25. package/bin/dev-server-ops.mjs +1086 -0
  26. package/bin/dev-ui/IoskeleyMono-400.woff2 +0 -0
  27. package/bin/dev-ui/IoskeleyMono-600.woff2 +0 -0
  28. package/bin/dev-ui/OFL.txt +92 -0
  29. package/bin/dev-ui/agent-robot.webp +0 -0
  30. package/bin/dev-ui/app.js +5217 -0
  31. package/bin/dev-ui/highlight.js +195 -0
  32. package/bin/dev-ui/index.html +34 -0
  33. package/bin/dev-ui/style.css +3640 -0
  34. package/bin/devlint.mjs +112 -0
  35. package/bin/devserver.mjs +2127 -0
  36. package/bin/devtriggers.mjs +367 -0
  37. package/bin/endpoints.mjs +156 -0
  38. package/bin/errors.mjs +61 -0
  39. package/bin/files.mjs +169 -0
  40. package/bin/horizontal-capabilities/v1/contract.json +280 -0
  41. package/bin/http.mjs +500 -0
  42. package/bin/lint-manifests/justbash-commands.json +88 -0
  43. package/bin/lint-manifests/python-stdlib.json +295 -0
  44. package/bin/login-page.mjs +488 -0
  45. package/bin/schedules.mjs +664 -0
  46. package/bin/server-sandbox.mjs +204 -0
  47. package/bin/servicedev.mjs +425 -0
  48. package/bin/sync.mjs +357 -0
  49. package/bin/terminus.js +3666 -0
  50. package/bin/toolchain.mjs +125 -0
  51. package/bin/vendor/app-runtime-v1/app-host.json +124 -0
  52. package/bin/vendor/app-runtime-v1/capability-calls.json +412 -0
  53. package/bin/vendor/app-runtime-v1/doors.json +2867 -0
  54. package/bin/vendor/appd/node-harness.mjs +209 -0
  55. package/bin/vendor/appd/python-harness.py +12 -0
  56. package/bin/vendor/appd/server-protocol.json +84 -0
  57. package/bin/vendor/where.mjs +541 -0
  58. package/bin/versioning.mjs +72 -0
  59. package/bin/write-rules.mjs +398 -0
  60. package/package.json +41 -0
@@ -0,0 +1,1086 @@
1
+ /**
2
+ * An app's server code (`capabilities.server`) under `terminus dev`, run the
3
+ * way the platform runs it, so what works here works once published.
4
+ *
5
+ * Every op runs in a fresh process through the platform's own harness,
6
+ * vendored byte for byte in vendor/appd/: the same CommonJS wrapper and
7
+ * stdlib-only require table, the invocation and result files, and syscalls
8
+ * made by running an `agentos-terminus` command. That command relays its
9
+ * command line (by curl, or dev-server-binding.mjs without it) to the
10
+ * binding host below, which stands where the platform's does — outside the
11
+ * op's sandbox — and shapes each call and answer as the platform's binding
12
+ * host does, while the broker behind it answers with the platform's rules,
13
+ * limits and messages (terminus-backend: docs/app-server.md,
14
+ * bashd/server.js, src/routes/app_server/).
15
+ *
16
+ * The sandbox is Node's permission model: the op reads only its own files,
17
+ * writes only its copy of server/, has no network and next to no
18
+ * environment, and each call starts from a clean copy, so nothing carries
19
+ * from one call to the next except what the op stored through the
20
+ * platform. Records live in the dev data space: one pool per app (global)
21
+ * and one per member's installation.
22
+ */
23
+
24
+ import { spawnSync } from "node:child_process";
25
+ import { randomBytes } from "node:crypto";
26
+ import { accessSync, constants as fsConstants } from "node:fs";
27
+ import { writeFile } from "node:fs/promises";
28
+ import { createServer } from "node:http";
29
+ import path from "node:path";
30
+ import { fileURLToPath } from "node:url";
31
+
32
+ import { readServerProgram } from "./apps.mjs";
33
+ import { CliError } from "./client.mjs";
34
+ import { fetchPublicPage } from "./dev-net.mjs";
35
+ import { SERVER_PROTOCOL, runServerHarness, stopGroup } from "./server-sandbox.mjs";
36
+ import { compareJson, compareStrings, jsonContains } from "./vendor/where.mjs";
37
+
38
+ export { SERVER_PROTOCOL };
39
+
40
+ const BINDING_SCRIPT = fileURLToPath(new URL("./dev-server-binding.mjs", import.meta.url));
41
+
42
+ const LIMITS = SERVER_PROTOCOL.limits;
43
+ const SYNC_OP_TIMEOUT_MS = LIMITS.sync_op_timeout_ms;
44
+ /** The broker's own bound on a record id (records.rs MAX_DOC_ID_BYTES). */
45
+ const MAX_DOC_ID_BYTES = 200;
46
+ /** No cursor the records plane mints comes near this; anything longer is not one. */
47
+ const MAX_CURSOR_CHARS = 4096;
48
+ /** The broker ticket outlives the op's window by this margin (B5). */
49
+ const TICKET_GRACE_MS = 60_000;
50
+ /** A `server.run` step keeps the tail of the op's logs in the job result. */
51
+ const JOB_LOG_TAIL_CHARS = 2000;
52
+ /** The binding host's per-execution budget for bytes read out of the capsule. */
53
+ const MAX_HYDRATION_TOTAL_BYTES = 16 * 1024 * 1024;
54
+ /** The binding host's per-call deadline, inside the op's own window. */
55
+ const BROKER_CALL_TIMEOUT_MS = 15_000;
56
+ const BROKER_TIMED_OUT = Symbol("broker call timed out");
57
+ /** One file handed to the op must fit the sandbox's captured output. */
58
+ const MAX_HYDRATION_FILE_BYTES = 4 * 1024 * 1024;
59
+ /**
60
+ * The sandbox's cap on one command line (`maxProcessArgvBytes` on the
61
+ * platform's server tier: its 256 KiB script budget + 4 KiB). A syscall's
62
+ * arguments ride its command line, so a bigger `records.put` or
63
+ * `capsule.write` never starts — well before those actions' own limits.
64
+ * (On a Linux host the kernel refuses any one argument over 128 KiB first.)
65
+ */
66
+ const MAX_PROCESS_ARGV_BYTES = 256 * 1024 + 4096;
67
+ const MAX_RELAY_BODY_BYTES = 8 * 1024 * 1024;
68
+ /** The chat lane's model cap, which server web fetches share. */
69
+ const WEB_FETCH_BODY_CHARS = 48 * 1024;
70
+ /** Who the server-op lane's fetches say they are. */
71
+ const SERVER_FETCH_USER_AGENT = "terminus-appd-fetch/1.0";
72
+
73
+ /** A coded failure, answered with the platform's `{error: {code, message}}`. */
74
+ export function serverError(status, code, message) {
75
+ return Object.assign(new CliError(message), { status, apiCode: code });
76
+ }
77
+
78
+ /** A member's installation, as `terminus dev` names it everywhere. */
79
+ export function devInstallationId(member) {
80
+ return `dev-${member}`;
81
+ }
82
+
83
+ /**
84
+ * The platform re-serializes every JSON value through a sorted map, so
85
+ * objects reach app code with their keys in byte order at every depth.
86
+ */
87
+ export function sortKeysBytewise(value) {
88
+ if (Array.isArray(value)) return value.map(sortKeysBytewise);
89
+ if (value && typeof value === "object") {
90
+ const sorted = {};
91
+ for (const key of Object.keys(value).sort((left, right) => Buffer.compare(Buffer.from(left), Buffer.from(right)))) {
92
+ sorted[key] = sortKeysBytewise(value[key]);
93
+ }
94
+ return sorted;
95
+ }
96
+ return value;
97
+ }
98
+
99
+ /** A number as PostgreSQL's numeric prints it: plain digits, no exponent. */
100
+ function jsonbNumber(number) {
101
+ if (!Number.isFinite(number)) return "null";
102
+ const text = String(number);
103
+ if (!/e/i.test(text)) return text;
104
+ const [mantissa, exponentText] = text.toLowerCase().split("e");
105
+ const negative = mantissa.startsWith("-");
106
+ const unsigned = negative ? mantissa.slice(1) : mantissa;
107
+ const dot = unsigned.indexOf(".");
108
+ const digits = unsigned.replace(".", "");
109
+ const point = (dot === -1 ? unsigned.length : dot) + Number(exponentText);
110
+ let plain;
111
+ if (point <= 0) plain = `0.${"0".repeat(-point)}${digits}`;
112
+ else if (point >= digits.length) plain = digits + "0".repeat(point - digits.length);
113
+ else plain = `${digits.slice(0, point)}.${digits.slice(point)}`;
114
+ return negative ? `-${plain}` : plain;
115
+ }
116
+
117
+ function compareJsonbKeys(left, right) {
118
+ const leftBytes = Buffer.from(left);
119
+ const rightBytes = Buffer.from(right);
120
+ if (leftBytes.length !== rightBytes.length) return leftBytes.length - rightBytes.length;
121
+ return Buffer.compare(leftBytes, rightBytes);
122
+ }
123
+
124
+ /**
125
+ * A value as `jsonb::text` prints it — the length the platform counts a
126
+ * stored record at when it is replaced or deleted (`octet_length(data::text)`).
127
+ */
128
+ export function jsonbText(value) {
129
+ if (value === null || value === undefined) return "null";
130
+ if (Array.isArray(value)) return `[${value.map(jsonbText).join(", ")}]`;
131
+ switch (typeof value) {
132
+ case "string":
133
+ return JSON.stringify(value);
134
+ case "number":
135
+ return jsonbNumber(value);
136
+ case "boolean":
137
+ return value ? "true" : "false";
138
+ case "object": {
139
+ const keys = Object.keys(value).sort(compareJsonbKeys);
140
+ return `{${keys.map((key) => `${JSON.stringify(key)}: ${jsonbText(value[key])}`).join(", ")}}`;
141
+ }
142
+ default:
143
+ return "null";
144
+ }
145
+ }
146
+
147
+ function isPlainObject(value) {
148
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
149
+ }
150
+
151
+
152
+ function shellQuote(value) {
153
+ return `'${String(value).replaceAll("'", "'\\''")}'`;
154
+ }
155
+
156
+ /** The last `count` characters of the op's logs. */
157
+ function logTail(logs, count) {
158
+ const characters = [...logs];
159
+ return characters.length <= count ? logs : characters.slice(-count).join("");
160
+ }
161
+
162
+ /** The `capabilities.collect` channels a server op may submit on. */
163
+ function documentsChannels(capabilities) {
164
+ const channels = capabilities?.collect?.channels;
165
+ if (!isPlainObject(channels)) return [];
166
+ return Object.entries(channels)
167
+ .filter(([, channel]) => channel?.kind === "documents")
168
+ .map(([name]) => name);
169
+ }
170
+
171
+ /**
172
+ * A refused syscall as the binding host frames it on the op's stderr
173
+ * (server-protocol.json `errors.format`): "<action> failed [<code> <status>]:
174
+ * <message>". The platform's harness parses the frame back into the Error it
175
+ * throws, so an op branches on `error.code` and `error.status`, never on the
176
+ * message; a failure nobody framed reaches it as internal_error / 500.
177
+ */
178
+ export function bindingFailure(action, code, status, message) {
179
+ return new Error(`${action} failed [${code} ${status}]: ${message}`);
180
+ }
181
+
182
+ // ── the records plane's queries ───────────────────────────────────────────
183
+ // terminus-backend src/routes/app_server/records.rs: one page of a pool's
184
+ // collection, filtered by containment, in doc-id order or by one top-level
185
+ // field, cut at `limit` documents and 4 MiB, resumed by an opaque cursor.
186
+
187
+ /** Echo a caller's string back in an error without echoing a novel. */
188
+ function shown(raw) {
189
+ const characters = [...raw];
190
+ return characters.length <= 64 ? raw : `${characters.slice(0, 64).join("")}...`;
191
+ }
192
+
193
+ const queryRefusal = (message) => serverError(400, "bad_request", message);
194
+
195
+ /**
196
+ * The order a query reads in: `field` ascending, `-field` descending — ONE
197
+ * top-level key of 1-64 letters, digits, '_', '-' or '.' (dots are literal
198
+ * characters, never a path) — and nothing for the doc-id order. `spec` is
199
+ * the spelling a cursor is bound to.
200
+ */
201
+ export function parseRecordSort(raw) {
202
+ const text = typeof raw === "string" ? raw.trim() : "";
203
+ if (!text) return { spec: "", field: null, descending: false };
204
+ const descending = text.startsWith("-");
205
+ const field = descending ? text.slice(1) : text;
206
+ if (!field || field.length > 64 || !/^[A-Za-z0-9_.-]+$/u.test(field)) {
207
+ throw queryRefusal(
208
+ `invalid sort '${shown(text)}': sort names one top-level field (\`field\` ascending, \`-field\` descending) of 1-64 letters, digits, '_', '-' or '.'`,
209
+ );
210
+ }
211
+ return { spec: text, field, descending };
212
+ }
213
+
214
+ /** Where a page stopped, as callers carry it: base64url of `{s, k?, d}` —
215
+ * the sort it pages, the last document's sort value as JSON text (absent in
216
+ * the id order and for a document that lacked the field) and its doc id. */
217
+ function encodeCursor(spec, key, docId) {
218
+ const cursor = key === null ? { s: spec, d: docId } : { s: spec, k: key, d: docId };
219
+ return Buffer.from(JSON.stringify(cursor)).toString("base64url");
220
+ }
221
+
222
+ const UTF8 = new TextDecoder("utf-8", { fatal: true });
223
+
224
+ /** A cursor this plane minted for `sort`, or a bad_request naming why not. */
225
+ function decodeCursor(raw, sort) {
226
+ const refused = () => queryRefusal("'after' is not a cursor a records query returned");
227
+ if (raw.length > MAX_CURSOR_CHARS || !/^[A-Za-z0-9_-]*$/u.test(raw)) throw refused();
228
+ const bytes = Buffer.from(raw, "base64url");
229
+ // Canonical unpadded base64url only: the platform's decoder takes nothing else.
230
+ if (bytes.toString("base64url") !== raw) throw refused();
231
+ let cursor;
232
+ try {
233
+ cursor = JSON.parse(UTF8.decode(bytes));
234
+ } catch {
235
+ throw refused();
236
+ }
237
+ if (!isPlainObject(cursor) || typeof cursor.s !== "string" || typeof cursor.d !== "string"
238
+ || (cursor.k !== undefined && cursor.k !== null && typeof cursor.k !== "string")) {
239
+ throw refused();
240
+ }
241
+ if (cursor.s !== sort.spec) {
242
+ throw queryRefusal(`'after' continues the sort '${shown(cursor.s)}', not '${sort.spec}'; start again without 'after'`);
243
+ }
244
+ if (!cursor.d || Buffer.byteLength(cursor.d) > MAX_DOC_ID_BYTES) throw refused();
245
+ const key = cursor.k ?? null;
246
+ if (key === null) return { docId: cursor.d, key: null };
247
+ // Only a scalar's JSON text is ever minted into a cursor.
248
+ if (!sort.field || Buffer.byteLength(key) > LIMITS.records_sort_value_bytes || /^\s*[[{]/u.test(key)) throw refused();
249
+ let value;
250
+ try {
251
+ value = JSON.parse(key);
252
+ } catch {
253
+ throw refused();
254
+ }
255
+ return { docId: cursor.d, key, value };
256
+ }
257
+
258
+ /** At most `limit` documents and — past the first — at most 4 MiB of their
259
+ * data; whether rows remain after the page. */
260
+ function cutPage(rows, limit) {
261
+ const page = [];
262
+ let bytes = 0;
263
+ for (const row of rows) {
264
+ if (page.length >= limit) break;
265
+ const size = Buffer.byteLength(JSON.stringify(row.data));
266
+ if (page.length && bytes + size > LIMITS.records_query_page_bytes) break;
267
+ bytes += size;
268
+ page.push(row);
269
+ }
270
+ return { page, more: rows.length > page.length };
271
+ }
272
+
273
+ /**
274
+ * One page of `rows` (one pool's collection: `{docId, data, version}`) the
275
+ * way the platform reads it. `where` is a containment filter; `sort` orders
276
+ * by one top-level field as JSONB orders values (JSON null lowest, strings,
277
+ * numbers numerically, booleans), a record lacking the field after every
278
+ * record holding it in either direction, ties by doc id; a record whose sort
279
+ * field holds an object, an array or a value whose JSON text passes 1 KiB
280
+ * fails the query on every page. `limit` is 1-100 (100 when absent), never
281
+ * clamped. Answers `{documents: [{doc_id, data, version}], next_cursor}`.
282
+ */
283
+ export function queryServerRecords(rows, { where = null, sort = null, after = null, limit = null } = {}) {
284
+ if (where !== null && !isPlainObject(where)) {
285
+ throw queryRefusal("'where' must be a JSON object (containment match)");
286
+ }
287
+ const order = parseRecordSort(sort);
288
+ let size = LIMITS.records_query_limit;
289
+ if (limit !== null) {
290
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > LIMITS.records_query_limit) {
291
+ throw queryRefusal(
292
+ `limit ${limit} is out of range: a page holds 1-${LIMITS.records_query_limit} records; read on with next_cursor`,
293
+ );
294
+ }
295
+ size = limit;
296
+ }
297
+ const resume = typeof after === "string" && after.trim() ? decodeCursor(after.trim(), order) : null;
298
+ const matching = rows.filter((row) => where === null || jsonContains(row.data, where));
299
+ const byDocId = (left, right) => compareStrings(left.docId, right.docId);
300
+ let read;
301
+ let keyOf = () => null;
302
+ if (!order.field) {
303
+ read = matching
304
+ .filter((row) => !resume || compareStrings(row.docId, resume.docId) > 0)
305
+ .sort(byDocId);
306
+ } else {
307
+ // 0: a value no page can order by; 1: a sortable value; 2: no field.
308
+ const ranked = matching.map((row) => {
309
+ const value = isPlainObject(row.data) && Object.hasOwn(row.data, order.field) ? row.data[order.field] : undefined;
310
+ if (value === undefined) return { row, rank: 2, value };
311
+ const text = jsonbText(value);
312
+ const unorderable = Array.isArray(value) || isPlainObject(value)
313
+ || Buffer.byteLength(text) > LIMITS.records_sort_value_bytes;
314
+ return { row, rank: unorderable ? 0 : 1, value, text };
315
+ });
316
+ const direction = order.descending ? -1 : 1;
317
+ const afterResume = (item) => {
318
+ if (!resume || item.rank === 0) return true;
319
+ if (resume.key === null) return item.rank === 2 && compareStrings(item.row.docId, resume.docId) > 0;
320
+ if (item.rank === 2) return true;
321
+ const compared = compareJson(item.value, resume.value) * direction;
322
+ return compared > 0 || (compared === 0 && compareStrings(item.row.docId, resume.docId) > 0);
323
+ };
324
+ const ordered = ranked.filter(afterResume).sort((left, right) => (left.rank - right.rank)
325
+ || (left.rank === 2 ? 0 : compareJson(left.value, right.value) * direction)
326
+ || byDocId(left.row, right.row));
327
+ const first = ordered[0];
328
+ if (first?.rank === 0) {
329
+ const type = Array.isArray(first.value) ? "array" : isPlainObject(first.value) ? "object"
330
+ : first.value === null ? "null" : typeof first.value;
331
+ const holds = type === "object" ? "an object" : type === "array" ? "an array"
332
+ : `a ${type} longer than ${LIMITS.records_sort_value_bytes} bytes as JSON`;
333
+ throw queryRefusal(
334
+ `record '${first.row.docId}' holds ${holds} in sort field '${order.field}'; sort by a field holding strings, numbers, booleans or null`,
335
+ );
336
+ }
337
+ const keys = new Map(ordered.map((item) => [item.row, item.rank === 1 ? item.text : null]));
338
+ keyOf = (row) => keys.get(row);
339
+ read = ordered.map((item) => item.row);
340
+ }
341
+ const { page, more } = cutPage(read.slice(0, size + 1), size);
342
+ const last = page.at(-1);
343
+ return {
344
+ documents: page.map((row) => ({ data: sortKeysBytewise(row.data), doc_id: row.docId, version: row.version })),
345
+ next_cursor: more && last ? encodeCursor(order.spec, keyOf(last), last.docId) : null,
346
+ };
347
+ }
348
+
349
+ // ── the server-op fetch lane ──────────────────────────────────────────────
350
+
351
+ /**
352
+ * The broker's `web_fetch`, as the platform's server-op lane makes it
353
+ * (terminus-backend `web_fetch_for_server_op`): the one guarded public-text
354
+ * fetch the app's own `net.fetch` door uses (dev-net.mjs, `fetch_public_text`
355
+ * upstream) under the server lane's user agent — the whole page, or a refusal
356
+ * past 512 KiB, never its start. Its refusals are the platform's typed ones
357
+ * (url_not_allowed 400, upstream_failed 502, payload_too_large 413,
358
+ * deadline_exceeded 504), which the binding host frames for the op like any
359
+ * broker refusal. `options` are the net door's test seams (`fetchImpl`,
360
+ * `timeoutMs`).
361
+ */
362
+ export function fetchPublicText(rawUrl, options = {}) {
363
+ return fetchPublicPage(rawUrl, { ...options, partial: false, userAgent: SERVER_FETCH_USER_AGENT });
364
+ }
365
+
366
+ // ── the sync door ─────────────────────────────────────────────────────────
367
+
368
+ async function readLimitedBody(request, limit) {
369
+ const chunks = [];
370
+ let size = 0;
371
+ for await (const chunk of request) {
372
+ size += chunk.length;
373
+ if (size > limit) return null;
374
+ chunks.push(chunk);
375
+ }
376
+ return Buffer.concat(chunks).toString("utf8");
377
+ }
378
+
379
+ /**
380
+ * `POST /_terminus/server/{op}` with `{"input": value}` → `{"result": value}`.
381
+ * The dev host reads the body the way every JSON door does (content type,
382
+ * body limit, an object with no unknown field) and hands it here parsed.
383
+ */
384
+ export async function answerServerDoor(tier, member, opName, body, response) {
385
+ const { output } = await tier.invoke(member, opName, body.input === undefined ? null : body.input, "sync");
386
+ response.writeHead(200, { "content-type": "application/json" });
387
+ response.end(JSON.stringify({ result: output }));
388
+ }
389
+
390
+ // ── the binding host ──────────────────────────────────────────────────────
391
+
392
+ function parseBindingFlags(binding, args) {
393
+ const input = {};
394
+ for (let index = 0; index < args.length; index += 2) {
395
+ const flag = args[index];
396
+ if (!flag.startsWith("--") || index + 1 >= args.length) {
397
+ throw new Error(`invalid arguments for ${binding}`);
398
+ }
399
+ input[flag.slice(2)] = args[index + 1];
400
+ }
401
+ return input;
402
+ }
403
+
404
+ function requireBindingFields(input, required) {
405
+ for (const field of required) {
406
+ if (typeof input[field] !== "string") throw new Error(`invalid input: '${field}' must be a string`);
407
+ }
408
+ return input;
409
+ }
410
+
411
+ /** A binding's JSON argument; text that is not JSON is refused before any
412
+ * broker call, framed like the broker's own refusals. */
413
+ function parseJsonArgument(action, name, text) {
414
+ try {
415
+ return JSON.parse(text);
416
+ } catch {
417
+ throw bindingFailure(action, "bad_request", 400, `'${name}' must be valid JSON text`);
418
+ }
419
+ }
420
+
421
+ /** The bytes one command line takes: every argument and its terminator. */
422
+ function commandLineBytes(args) {
423
+ return ["agentos-terminus", ...args].reduce((total, arg) => total + Buffer.byteLength(arg) + 1, 0);
424
+ }
425
+
426
+ /** The first curl on PATH that can relay (--fail-with-body needs 7.76), or null. */
427
+ function findRelayCurl() {
428
+ for (const directory of String(process.env.PATH ?? "").split(path.delimiter)) {
429
+ if (!directory || !path.isAbsolute(directory)) continue;
430
+ const candidate = path.join(directory, "curl");
431
+ try {
432
+ accessSync(candidate, fsConstants.X_OK);
433
+ } catch {
434
+ continue;
435
+ }
436
+ const probe = spawnSync(candidate, ["--version"], { encoding: "utf8" });
437
+ const version = /^curl (\d+)\.(\d+)/.exec(String(probe.stdout ?? ""));
438
+ if (!version) continue;
439
+ const [major, minor] = [Number(version[1]), Number(version[2])];
440
+ if (major > 7 || (major === 7 && minor >= 76)) return candidate;
441
+ }
442
+ return null;
443
+ }
444
+
445
+ /**
446
+ * The server-op syscalls exactly as the platform's binding host defines
447
+ * them (terminus-backend bashd/server.js): the flags each takes, the one
448
+ * broker action each makes, and the fields each answers with.
449
+ */
450
+ export const SERVER_BINDINGS = {
451
+ "records-get": {
452
+ capability: "records",
453
+ required: ["scope", "collection", "doc"],
454
+ async run(call, { scope, collection, doc }) {
455
+ const payload = await call("records_get", { scope, collection, doc_id: doc });
456
+ return {
457
+ found: payload.found === true,
458
+ doc: payload.found === true ? (payload.doc ?? null) : null,
459
+ version: Number(payload.version) || 0,
460
+ };
461
+ },
462
+ },
463
+ "records-put": {
464
+ capability: "records",
465
+ required: ["scope", "collection", "doc", "data"],
466
+ async run(call, { scope, collection, doc, data, version }) {
467
+ const parsed = parseJsonArgument("records_put", "data", data);
468
+ const payload = await call("records_put", {
469
+ scope,
470
+ collection,
471
+ doc_id: doc,
472
+ data: parsed,
473
+ ...(version == null ? {} : { if_version: Number(version) || 0 }),
474
+ });
475
+ return { version: Number(payload.version) || 0 };
476
+ },
477
+ },
478
+ "records-delete": {
479
+ capability: "records",
480
+ required: ["scope", "collection", "doc"],
481
+ async run(call, { scope, collection, doc, version }) {
482
+ const payload = await call("records_delete", {
483
+ scope,
484
+ collection,
485
+ doc_id: doc,
486
+ ...(version == null ? {} : { if_version: Number(version) || 0 }),
487
+ });
488
+ return { deleted: payload.deleted === true };
489
+ },
490
+ },
491
+ "records-query": {
492
+ capability: "records",
493
+ required: ["scope", "collection"],
494
+ async run(call, { scope, collection, where, sort, after, limit }) {
495
+ const filter = where == null ? null : parseJsonArgument("records_query", "where", where);
496
+ const payload = await call("records_query", {
497
+ scope,
498
+ collection,
499
+ ...(filter == null ? {} : { where: filter }),
500
+ ...(sort == null ? {} : { sort }),
501
+ ...(after == null ? {} : { after }),
502
+ ...(limit == null ? {} : { limit: Number(limit) || 0 }),
503
+ });
504
+ const documents = Array.isArray(payload.documents) ? payload.documents : [];
505
+ return {
506
+ documents: documents.map((row) => ({
507
+ doc: String(row && row.doc_id != null ? row.doc_id : ""),
508
+ data: row && row.data !== undefined ? row.data : null,
509
+ version: Number(row && row.version) || 0,
510
+ })),
511
+ next_cursor: typeof payload.next_cursor === "string" ? payload.next_cursor : null,
512
+ };
513
+ },
514
+ },
515
+ "capsule-read": {
516
+ capability: "capsule",
517
+ required: ["path"],
518
+ async run(call, { path: requested }, ticket) {
519
+ const payload = await call("capsule_read", { path: String(requested || "") });
520
+ const bytes = Buffer.from(String(payload.content_b64 || ""), "base64");
521
+ if (bytes.length > MAX_HYDRATION_FILE_BYTES) {
522
+ throw bindingFailure("capsule_read", "payload_too_large", 413, "capsule file exceeds the hydration budget");
523
+ }
524
+ ticket.hydratedBytes += bytes.length;
525
+ if (ticket.hydratedBytes > MAX_HYDRATION_TOTAL_BYTES) {
526
+ throw bindingFailure(
527
+ "capsule_read",
528
+ "payload_too_large",
529
+ 413,
530
+ "the hydration byte budget for this execution is exhausted",
531
+ );
532
+ }
533
+ return {
534
+ path: typeof payload.path === "string" ? payload.path : String(requested || ""),
535
+ size_bytes: bytes.length,
536
+ content_b64: bytes.toString("base64"),
537
+ };
538
+ },
539
+ },
540
+ "capsule-write": {
541
+ capability: "capsule",
542
+ required: ["path", "content"],
543
+ async run(call, { path: requested, content }) {
544
+ const payload = await call("capsule_write", {
545
+ path: String(requested || ""),
546
+ content_b64: String(content || ""),
547
+ });
548
+ return {
549
+ path: typeof payload.path === "string" ? payload.path : String(requested || ""),
550
+ size_bytes: Number(payload.size_bytes) || 0,
551
+ };
552
+ },
553
+ },
554
+ "web-fetch": {
555
+ capability: "web_fetch",
556
+ required: ["url"],
557
+ async run(call, { url }) {
558
+ const payload = await call("web_fetch", { url });
559
+ return {
560
+ status: Number(payload.status) || 0,
561
+ url: typeof payload.url === "string" ? payload.url : url,
562
+ content_type: typeof payload.content_type === "string" ? payload.content_type : "",
563
+ body: typeof payload.body === "string" ? payload.body : "",
564
+ truncated: payload.truncated === true,
565
+ };
566
+ },
567
+ },
568
+ "egress-invoke": {
569
+ capability: "egress",
570
+ required: ["template"],
571
+ async run(call, { template, params }) {
572
+ const parsedParams = params == null ? {} : parseJsonArgument("egress_invoke", "params", params);
573
+ const payload = await call("egress_invoke", { template: String(template || ""), params: parsedParams });
574
+ return {
575
+ status: Number(payload.status) || 0,
576
+ content_type: typeof payload.content_type === "string" ? payload.content_type : "",
577
+ body: typeof payload.body === "string" ? payload.body : "",
578
+ body_b64: typeof payload.body_b64 === "string" ? payload.body_b64 : "",
579
+ truncated: payload.truncated === true,
580
+ };
581
+ },
582
+ },
583
+ "collect-submit": {
584
+ capability: "collect",
585
+ required: ["channel", "payload"],
586
+ async run(call, { channel, payload: payloadText }) {
587
+ const parsed = parseJsonArgument("collect_submit", "payload", payloadText);
588
+ const payload = await call("collect_submit", { channel: String(channel || ""), payload: parsed });
589
+ return {
590
+ id: typeof payload.id === "string" ? payload.id : "",
591
+ delivered: payload.delivered === true,
592
+ };
593
+ },
594
+ },
595
+ };
596
+
597
+ // ── the tier ──────────────────────────────────────────────────────────────
598
+
599
+ /**
600
+ * The server tier for one `terminus dev` session.
601
+ *
602
+ * - `manifest`: the compiled package manifest (`capabilities.server` is the
603
+ * contract, exactly as a release pins it).
604
+ * - `store`: the dev system store (the records plane).
605
+ * - `capsule`: `{read(member, path) → Buffer, write(member, path, bytes)}` over
606
+ * the member's private data, as the app's own data doors see it.
607
+ * - `collect(member, channel, payload)` → `{id, delivered}`.
608
+ * - `fetchText(url)`: the web fetch lane, `fetchPublicText` (the dev host
609
+ * hands it the net doors' stand-in for the public web in tests).
610
+ * - `log(line)`: where op logs and outcomes are printed.
611
+ * - `relay`: "auto" (curl when it can, else node), or "curl"/"node" (tests).
612
+ */
613
+ export function createDevServerTier({
614
+ dir,
615
+ manifest,
616
+ store,
617
+ capsule,
618
+ collect,
619
+ fetchText = fetchPublicText,
620
+ log = (line) => console.log(line),
621
+ relay = "auto",
622
+ }) {
623
+ const capabilities = manifest.capabilities ?? {};
624
+ const tickets = new Map();
625
+ /** Ops running now: closing the session stops them. */
626
+ const running = new Set();
627
+ let bindingHost = null;
628
+ let bindingHostUrl = null;
629
+ /** The curl that relays syscalls (null: none can), found on first use. */
630
+ let relayCurl;
631
+
632
+ function recordScope(raw) {
633
+ return raw === "global" || raw === "installation" ? raw : null;
634
+ }
635
+
636
+ function requireDeclaredRecords(scope, collection) {
637
+ const records = capabilities.server?.records;
638
+ const scopes = Array.isArray(records?.scopes) ? records.scopes : [];
639
+ const collections = Array.isArray(records?.collections) ? records.collections : [];
640
+ if (!scopes.includes(scope) || !collections.includes(collection)) {
641
+ throw serverError(403, "forbidden", `the release does not declare server records for '${scope}' / '${collection}'`);
642
+ }
643
+ }
644
+
645
+ function requireGrant(ticket, capability, what) {
646
+ if (!ticket.capabilities.has(capability)) {
647
+ throw serverError(403, "forbidden", `this execution's ticket does not grant ${what}`);
648
+ }
649
+ }
650
+
651
+ function trimmed(value) {
652
+ return typeof value === "string" ? value.trim() : "";
653
+ }
654
+
655
+ /** One broker action (terminus-backend src/routes/app_server/broker.rs). */
656
+ async function brokerAction(ticket, body) {
657
+ const action = String(body.action ?? "");
658
+ switch (action) {
659
+ case "records_get":
660
+ case "records_put":
661
+ case "records_delete":
662
+ case "records_query": {
663
+ requireGrant(ticket, "records", "records");
664
+ const scope = recordScope(body.scope);
665
+ if (!scope) throw serverError(400, "bad_request", "scope must be 'global' or 'installation'");
666
+ const collection = trimmed(body.collection);
667
+ if (!collection) throw serverError(400, "bad_request", "records actions require 'collection'");
668
+ requireDeclaredRecords(scope, collection);
669
+ const installationId = scope === "installation" ? ticket.installationId : null;
670
+ if (action === "records_query") {
671
+ return queryServerRecords(store.serverRecords({ scope, installationId, collection }), {
672
+ where: body.where ?? null,
673
+ sort: body.sort ?? null,
674
+ after: body.after ?? null,
675
+ limit: body.limit ?? null,
676
+ });
677
+ }
678
+ const docId = trimmed(body.doc_id);
679
+ if (!docId || Buffer.byteLength(docId) > 200) {
680
+ throw serverError(400, "bad_request", "records actions require 'doc_id'");
681
+ }
682
+ const key = { scope, installationId, collection, docId };
683
+ const ifVersion = body.if_version === undefined || body.if_version === null
684
+ ? null
685
+ : Math.trunc(Number(body.if_version));
686
+ if (action === "records_get") {
687
+ const found = store.serverRecord(key);
688
+ return found
689
+ ? { doc: sortKeysBytewise(found.data), found: true, version: found.version }
690
+ : { doc: null, found: false, version: 0 };
691
+ }
692
+ if (action === "records_put") {
693
+ if (body.data === undefined || body.data === null) {
694
+ throw serverError(400, "bad_request", "records_put requires 'data'");
695
+ }
696
+ const dataJson = JSON.stringify(body.data);
697
+ const newBytes = Buffer.byteLength(dataJson);
698
+ if (newBytes > LIMITS.record_doc_bytes) {
699
+ throw serverError(413, "payload_too_large", `record is ${newBytes} bytes; the per-record budget is ${LIMITS.record_doc_bytes}`);
700
+ }
701
+ const quotaBytes = scope === "global" ? LIMITS.records_global_quota_bytes : LIMITS.records_installation_quota_bytes;
702
+ const outcome = store.putServerRecord({
703
+ ...key,
704
+ dataJson,
705
+ newBytes,
706
+ storedBytes: Buffer.byteLength(jsonbText(body.data)),
707
+ ifVersion,
708
+ quotaBytes,
709
+ });
710
+ // A compare-and-set miss is a version_conflict (the words keep the
711
+ // phrases older server code matches on); a full pool is a quota,
712
+ // and the refused write never happened.
713
+ if (outcome.conflict) throw serverError(409, "version_conflict", outcome.conflict);
714
+ if (outcome.overQuota !== undefined) {
715
+ throw serverError(
716
+ 402,
717
+ "quota_exceeded",
718
+ `this write would put the app's ${scope} records at ${outcome.overQuota} bytes; the quota is ${quotaBytes}`,
719
+ );
720
+ }
721
+ return { version: outcome.version };
722
+ }
723
+ const outcome = store.deleteServerRecord({ ...key, ifVersion });
724
+ if (outcome.conflict) throw serverError(409, "version_conflict", outcome.conflict);
725
+ return { deleted: outcome.deleted };
726
+ }
727
+ case "capsule_read":
728
+ case "capsule_write": {
729
+ requireGrant(ticket, "capsule", "capsule access");
730
+ const requested = trimmed(body.path);
731
+ if (!requested) throw serverError(400, "bad_request", "capsule actions require 'path'");
732
+ if (action === "capsule_read") {
733
+ const bytes = await capsule.read(ticket.member, requested);
734
+ if (bytes.length > LIMITS.capsule_file_bytes) {
735
+ throw serverError(413, "payload_too_large", `'${requested}' is ${bytes.length} bytes; server capsule reads are capped at ${LIMITS.capsule_file_bytes}`);
736
+ }
737
+ return { content_b64: bytes.toString("base64"), path: requested, size_bytes: bytes.length };
738
+ }
739
+ const encoded = typeof body.content_b64 === "string" ? body.content_b64 : "";
740
+ if (encoded.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(encoded)) {
741
+ throw serverError(400, "bad_request", "capsule_write requires base64 'content_b64'");
742
+ }
743
+ const bytes = Buffer.from(encoded, "base64");
744
+ if (bytes.length > LIMITS.capsule_file_bytes) {
745
+ throw serverError(413, "payload_too_large", `'${requested}' is ${bytes.length} bytes; server capsule writes are capped at ${LIMITS.capsule_file_bytes}`);
746
+ }
747
+ await capsule.write(ticket.member, requested, bytes);
748
+ return { path: requested, size_bytes: bytes.length };
749
+ }
750
+ case "web_fetch": {
751
+ requireGrant(ticket, "web_fetch", "web_fetch");
752
+ const url = trimmed(body.url);
753
+ if (!url) throw serverError(400, "bad_request", "web_fetch requires 'url'");
754
+ const day = new Date().toISOString().slice(0, 10);
755
+ if (store.bumpServerFetches(ticket.installationId, day) > LIMITS.web_fetches_per_installation_per_day) {
756
+ throw serverError(
757
+ 429,
758
+ "rate_limited",
759
+ `this installation has used its ${LIMITS.web_fetches_per_installation_per_day} server web fetches for today`,
760
+ );
761
+ }
762
+ const fetched = await fetchText(url);
763
+ const characters = [...String(fetched.body ?? "")];
764
+ const truncated = characters.length > WEB_FETCH_BODY_CHARS;
765
+ return {
766
+ body: truncated ? characters.slice(0, WEB_FETCH_BODY_CHARS).join("") : String(fetched.body ?? ""),
767
+ content_type: fetched.content_type ?? "",
768
+ status: fetched.status ?? 0,
769
+ truncated,
770
+ url: fetched.url ?? url,
771
+ };
772
+ }
773
+ case "collect_submit": {
774
+ requireGrant(ticket, "collect", "collect");
775
+ const channel = trimmed(body.channel);
776
+ if (!channel) throw serverError(400, "bad_request", "collect_submit requires 'channel'");
777
+ const payload = body.payload === undefined ? null : body.payload;
778
+ if (Buffer.byteLength(JSON.stringify(payload)) > LIMITS.collect_payload_bytes) {
779
+ throw serverError(413, "payload_too_large", `a collect submission is capped at ${LIMITS.collect_payload_bytes} bytes`);
780
+ }
781
+ return collect(ticket.member, channel, payload);
782
+ }
783
+ case "egress_invoke":
784
+ throw serverError(403, "forbidden", "egress from server code is reserved and not yet enabled");
785
+ default:
786
+ throw serverError(400, "bad_request", `unknown server-broker action '${action}'`);
787
+ }
788
+ }
789
+
790
+ /** Each syscall is one broker call; an execution gets 600 a minute. */
791
+ function countBrokerCall(ticket) {
792
+ const minute = Math.floor(Date.now() / 60_000);
793
+ if (ticket.minute !== minute) {
794
+ ticket.minute = minute;
795
+ ticket.calls = 0;
796
+ }
797
+ ticket.calls += 1;
798
+ if (ticket.calls > LIMITS.broker_calls_per_minute) {
799
+ throw serverError(429, "rate_limited", `server broker is limited to ${LIMITS.broker_calls_per_minute} attempts per minute`);
800
+ }
801
+ }
802
+
803
+ /**
804
+ * One broker call as the binding host makes it: JSON on the wire, inside
805
+ * the op's window and 15 s at most, and a refusal reaching the op framed
806
+ * "<action> failed [<code> <status>]: <message>" — the broker's catalogue
807
+ * code and status, deadline_exceeded for a call past its deadline,
808
+ * upstream_failed for an answer that is not JSON, internal_error for the
809
+ * broker's own failure.
810
+ */
811
+ async function callBroker(ticket, action, payload) {
812
+ const budget = Math.max(1000, Math.min(BROKER_CALL_TIMEOUT_MS, ticket.deadlineAt - Date.now()));
813
+ let timer;
814
+ const expired = new Promise((resolve) => {
815
+ timer = setTimeout(() => resolve(BROKER_TIMED_OUT), budget);
816
+ });
817
+ try {
818
+ countBrokerCall(ticket);
819
+ const answer = await Promise.race([
820
+ brokerAction(ticket, JSON.parse(JSON.stringify({ action, ...payload }))),
821
+ expired,
822
+ ]);
823
+ if (answer === BROKER_TIMED_OUT) {
824
+ throw serverError(504, "deadline_exceeded", "the platform did not answer in time");
825
+ }
826
+ if (!answer || typeof answer !== "object") {
827
+ throw serverError(502, "upstream_failed", "the platform answered with invalid JSON");
828
+ }
829
+ return answer;
830
+ } catch (error) {
831
+ const coded = typeof error?.apiCode === "string" && Number.isInteger(error?.status);
832
+ throw bindingFailure(
833
+ action,
834
+ coded ? error.apiCode : "internal_error",
835
+ coded ? error.status : 500,
836
+ String(error?.message ?? error),
837
+ );
838
+ } finally {
839
+ clearTimeout(timer);
840
+ }
841
+ }
842
+
843
+ /** One `agentos-terminus <binding> --flag value ...` command, run for the op. */
844
+ async function runBinding(ticket, args) {
845
+ const [binding = "", ...flags] = args;
846
+ const definition = Object.hasOwn(SERVER_BINDINGS, binding) ? SERVER_BINDINGS[binding] : null;
847
+ // A binding exists only when the execution was granted its syscall.
848
+ if (!definition || !ticket.capabilities.has(definition.capability)) {
849
+ throw new Error(`Unknown binding "terminus:${binding}"`);
850
+ }
851
+ const input = requireBindingFields(parseBindingFlags(binding, flags), definition.required);
852
+ return definition.run((action, payload) => callBroker(ticket, action, payload), input, ticket);
853
+ }
854
+
855
+ /**
856
+ * The binding host's door: the relay posts the command line (each argument
857
+ * one form field `a`) and prints what comes back — the envelope on stdout,
858
+ * or the message on stderr with exit status 1.
859
+ */
860
+ async function answerBindingHost(request, response) {
861
+ const answer = (status, type, text) => {
862
+ response.writeHead(status, { "content-type": type });
863
+ response.end(text);
864
+ };
865
+ if (request.method !== "POST") return answer(405, "text/plain; charset=utf-8", "the binding host takes POST");
866
+ const raw = await readLimitedBody(request, MAX_RELAY_BODY_BYTES);
867
+ const args = raw === null ? null : new URLSearchParams(raw).getAll("a");
868
+ if (args === null || commandLineBytes(args) > MAX_PROCESS_ARGV_BYTES) {
869
+ // The sandbox's process table caps one command line: an oversized
870
+ // syscall never starts, and the harness says so as it says any failed
871
+ // spawn — unframed, so the op sees internal_error.
872
+ return answer(400, "text/plain; charset=utf-8", `${args?.[0] || "agentos-terminus"} failed: spawnSync agentos-terminus E2BIG`);
873
+ }
874
+ const header = String(request.headers.authorization ?? "");
875
+ const ticket = header.startsWith("Bearer ") ? tickets.get(header.slice("Bearer ".length)) : null;
876
+ if (!ticket || ticket.expiresAt < Date.now()) {
877
+ const binding = args[0] ?? "";
878
+ const action = Object.hasOwn(SERVER_PROTOCOL.bindings, binding) ? SERVER_PROTOCOL.bindings[binding] : binding;
879
+ return answer(401, "text/plain; charset=utf-8", bindingFailure(action, "unauthorized", 401, "valid bearer token required").message);
880
+ }
881
+ try {
882
+ const result = await runBinding(ticket, args);
883
+ answer(200, "application/json", JSON.stringify({ ok: true, result }));
884
+ } catch (error) {
885
+ answer(400, "text/plain; charset=utf-8", String(error?.message ?? error));
886
+ }
887
+ }
888
+
889
+ async function ensureBindingHost() {
890
+ if (bindingHostUrl) return bindingHostUrl;
891
+ bindingHost = createServer((request, response) => {
892
+ answerBindingHost(request, response).catch(() => {
893
+ if (!response.headersSent) response.writeHead(500);
894
+ response.end();
895
+ });
896
+ });
897
+ await new Promise((resolve, reject) => {
898
+ bindingHost.once("error", reject);
899
+ bindingHost.listen(0, "127.0.0.1", () => {
900
+ bindingHost.off("error", reject);
901
+ resolve();
902
+ });
903
+ });
904
+ bindingHostUrl = `http://127.0.0.1:${bindingHost.address().port}/`;
905
+ return bindingHostUrl;
906
+ }
907
+
908
+ /**
909
+ * The op's `agentos-terminus`: a relay to the binding host, which stays
910
+ * outside the sandbox. curl starts in a fraction of Node's time, so it
911
+ * carries the call when it can (--fail-with-body needs curl 7.76).
912
+ */
913
+ async function writeRelay(hostDir, token) {
914
+ const url = await ensureBindingHost();
915
+ if (relayCurl === undefined) relayCurl = relay === "node" ? null : findRelayCurl();
916
+ if (relay === "curl" && !relayCurl) throw serverError(503, "server_unavailable", "no curl (7.76 or later) to relay syscalls");
917
+ const relayPath = path.join(hostDir, "agentos-terminus");
918
+ if (relayCurl) {
919
+ const headerFile = path.join(hostDir, "authorization");
920
+ await writeFile(headerFile, `authorization: Bearer ${token}\n`, { mode: 0o600 });
921
+ await writeFile(relayPath, [
922
+ "#!/bin/sh",
923
+ // The binding's action (records-put → records_put), for a refusal the
924
+ // relay has to frame itself — from the protocol's own table, since
925
+ // the op's PATH holds nothing but this relay.
926
+ "case \"$1\" in",
927
+ ...Object.entries(SERVER_PROTOCOL.bindings).map(([binding, action]) => ` ${binding}) action=${action} ;;`),
928
+ " *) action=$1 ;;",
929
+ "esac",
930
+ "n=$#",
931
+ "for a do set -- \"$@\" --data-urlencode \"a=$a\"; done",
932
+ "shift \"$n\"",
933
+ `out=$(${shellQuote(relayCurl)} -s --fail-with-body -X POST -H ${shellQuote(`@${headerFile}`)} "$@" ${shellQuote(url)})`,
934
+ "code=$?",
935
+ "if [ \"$code\" -eq 0 ]; then printf '%s' \"$out\"; exit 0; fi",
936
+ "if [ -n \"$out\" ]; then printf '%s\\n' \"$out\" >&2; else",
937
+ " echo \"$action failed [service_unavailable 503]: the platform could not be reached\" >&2",
938
+ "fi",
939
+ "exit 1",
940
+ "",
941
+ ].join("\n"), { mode: 0o755 });
942
+ return;
943
+ }
944
+ const ticketPath = path.join(hostDir, "ticket.json");
945
+ await writeFile(ticketPath, JSON.stringify({ url, token }), { mode: 0o600 });
946
+ // Node hands its sandbox to child processes through NODE_OPTIONS; the
947
+ // relay is not part of the sandbox, so it drops it.
948
+ await writeFile(relayPath, [
949
+ "#!/bin/sh",
950
+ "unset NODE_OPTIONS",
951
+ `exec ${shellQuote(process.execPath)} ${shellQuote(BINDING_SCRIPT)} ${shellQuote(ticketPath)} "$@"`,
952
+ "",
953
+ ].join("\n"), { mode: 0o755 });
954
+ }
955
+
956
+ /** The syscalls an exec may make, minted as the platform mints them. */
957
+ function grantedCapabilities(runtime) {
958
+ if (runtime !== "node") return [];
959
+ const granted = [];
960
+ if ((capabilities.server?.records?.scopes ?? []).length) granted.push("records");
961
+ if (documentsChannels(capabilities).length) granted.push("collect");
962
+ granted.push("capsule", "web_fetch");
963
+ if (Array.isArray(capabilities.egress?.templates) && capabilities.egress.templates.length) granted.push("egress");
964
+ return granted;
965
+ }
966
+
967
+ async function execute({ runtime, entry, program, invocation, timeoutMs, granted, member }) {
968
+ let token = null;
969
+ try {
970
+ return await runServerHarness({
971
+ runtime,
972
+ entry,
973
+ program,
974
+ invocation,
975
+ timeoutMs,
976
+ running,
977
+ prepareHost: granted.length
978
+ ? async (hostDir) => {
979
+ token = randomBytes(32).toString("base64url");
980
+ tickets.set(token, {
981
+ member,
982
+ installationId: invocation.ctx.installation_id,
983
+ capabilities: new Set(granted),
984
+ deadlineAt: Date.now() + timeoutMs,
985
+ expiresAt: Date.now() + timeoutMs + TICKET_GRACE_MS,
986
+ minute: 0,
987
+ calls: 0,
988
+ hydratedBytes: 0,
989
+ });
990
+ await writeRelay(hostDir, token);
991
+ }
992
+ : null,
993
+ });
994
+ } finally {
995
+ if (token) tickets.delete(token);
996
+ }
997
+ }
998
+
999
+ /**
1000
+ * Run one declared op (`run_server_op`). `trigger` is "sync" (the app
1001
+ * called it: 10 s at most, background-only ops refused) or "background"
1002
+ * (a `server.run` step: the op's whole budget). Answers `{output, logs}`.
1003
+ *
1004
+ * Refusals are the platform's (docs/app-server.md B4): an op the release
1005
+ * does not declare, or no server code at all, is 404 not_found; a
1006
+ * background-only op called from the app 400 bad_request; a tier that
1007
+ * cannot run the op here (no python3, no sandbox on this OS) 503
1008
+ * server_unavailable; an op out of time 504 deadline_exceeded; an op that
1009
+ * threw 502 upstream_failed, its stderr the message. The local tier is
1010
+ * never switched off, so it never answers server_disabled.
1011
+ */
1012
+ async function invoke(member, opName, input, trigger) {
1013
+ const server = capabilities.server;
1014
+ const ops = isPlainObject(server) && isPlainObject(server.ops) ? server.ops : {};
1015
+ const declared = Object.hasOwn(ops, opName) && isPlainObject(ops[opName]) ? ops[opName] : null;
1016
+ if (!declared) throw serverError(404, "not_found", `this app declares no server op '${opName}'`);
1017
+ if (trigger === "sync" && declared.background_only === true) {
1018
+ throw serverError(400, "bad_request", `server op '${opName}' is background-only and cannot be called from the app`);
1019
+ }
1020
+ const budget = Number.isSafeInteger(declared.timeout_ms) && declared.timeout_ms >= 0
1021
+ ? declared.timeout_ms
1022
+ : SYNC_OP_TIMEOUT_MS;
1023
+ const timeoutMs = trigger === "sync" ? Math.min(budget, SYNC_OP_TIMEOUT_MS) : budget;
1024
+ const runtime = server.language === "python" ? "python" : "node";
1025
+ if (runtime === "node" && process.platform === "win32") {
1026
+ throw serverError(503, "server_unavailable", "terminus dev runs node server ops on macOS and Linux");
1027
+ }
1028
+ let program;
1029
+ try {
1030
+ program = await readServerProgram(dir, server);
1031
+ } catch (error) {
1032
+ throw serverError(500, "internal_error", String(error?.message ?? error));
1033
+ }
1034
+ const entrypoint = String(server.entrypoint ?? "");
1035
+ const entry = entrypoint.startsWith("server/") ? entrypoint.slice("server/".length) : entrypoint;
1036
+ const invocation = sortKeysBytewise({
1037
+ ctx: { installation_id: devInstallationId(member), trigger, user_id: member },
1038
+ input,
1039
+ op: opName,
1040
+ });
1041
+ const run = await execute({
1042
+ runtime,
1043
+ entry,
1044
+ program,
1045
+ invocation,
1046
+ timeoutMs,
1047
+ granted: grantedCapabilities(runtime),
1048
+ member,
1049
+ });
1050
+ const label = `[server @${member} ${opName}]`;
1051
+ for (const line of run.logs.split("\n")) if (line.trim()) log(`${label} ${line}`);
1052
+ if (run.outcome !== "completed") {
1053
+ log(`${label} ${run.outcome} after ${run.durationMs} ms: ${run.errorText.split("\n")[0]}`);
1054
+ if (run.unavailable) throw serverError(503, "server_unavailable", run.errorText);
1055
+ if (run.outcome === "timed_out") {
1056
+ throw serverError(504, "deadline_exceeded", `server op '${opName}' did not finish within ${timeoutMs} ms`);
1057
+ }
1058
+ throw serverError(502, "upstream_failed", `server op '${opName}' ${run.outcome}: ${run.errorText || "no error output"}`);
1059
+ }
1060
+ log(`${label} ${trigger === "sync" ? "answered" : "ran"} in ${run.durationMs} ms`);
1061
+ return { output: sortKeysBytewise(run.output), logs: run.logs };
1062
+ }
1063
+
1064
+ /** A `server.run` automation step: `{op, input?}` → `{result, logs}`. */
1065
+ async function runStep(member, params) {
1066
+ const op = typeof params?.op === "string" ? params.op.trim() : "";
1067
+ if (!op) throw serverError(400, "bad_request", "server.run params need 'op'");
1068
+ const input = isPlainObject(params) && Object.hasOwn(params, "input") ? params.input : {};
1069
+ const { output, logs } = await invoke(member, op, input, "background");
1070
+ return { logs: logTail(logs, JOB_LOG_TAIL_CHARS), result: output };
1071
+ }
1072
+
1073
+ async function close() {
1074
+ for (const child of running) stopGroup(child);
1075
+ running.clear();
1076
+ tickets.clear();
1077
+ if (bindingHost) {
1078
+ bindingHost.closeAllConnections?.();
1079
+ await new Promise((resolve) => bindingHost.close(() => resolve()));
1080
+ bindingHost = null;
1081
+ bindingHostUrl = null;
1082
+ }
1083
+ }
1084
+
1085
+ return { invoke, runStep, close };
1086
+ }