@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.
- package/LICENSE +21 -0
- package/README.md +1055 -0
- package/bin/agent-discovery.mjs +71 -0
- package/bin/agent-icon.mjs +77 -0
- package/bin/agent-models.mjs +77 -0
- package/bin/agent-type.mjs +51 -0
- package/bin/agentdev.mjs +657 -0
- package/bin/app-route-script.mjs +59 -0
- package/bin/app-runtime-contract.mjs +2 -0
- package/bin/appdev-remote.mjs +346 -0
- package/bin/appdev.mjs +4446 -0
- package/bin/apps.mjs +5512 -0
- package/bin/capability-calls.mjs +437 -0
- package/bin/capsule-data.mjs +260 -0
- package/bin/client.mjs +189 -0
- package/bin/commands.mjs +1194 -0
- package/bin/dev-capsules.mjs +1599 -0
- package/bin/dev-contract.mjs +262 -0
- package/bin/dev-data.mjs +287 -0
- package/bin/dev-members.mjs +18 -0
- package/bin/dev-net.mjs +316 -0
- package/bin/dev-notification-popup.mjs +628 -0
- package/bin/dev-ports.mjs +567 -0
- package/bin/dev-server-binding.mjs +35 -0
- package/bin/dev-server-ops.mjs +1086 -0
- package/bin/dev-ui/IoskeleyMono-400.woff2 +0 -0
- package/bin/dev-ui/IoskeleyMono-600.woff2 +0 -0
- package/bin/dev-ui/OFL.txt +92 -0
- package/bin/dev-ui/agent-robot.webp +0 -0
- package/bin/dev-ui/app.js +5217 -0
- package/bin/dev-ui/highlight.js +195 -0
- package/bin/dev-ui/index.html +34 -0
- package/bin/dev-ui/style.css +3640 -0
- package/bin/devlint.mjs +112 -0
- package/bin/devserver.mjs +2127 -0
- package/bin/devtriggers.mjs +367 -0
- package/bin/endpoints.mjs +156 -0
- package/bin/errors.mjs +61 -0
- package/bin/files.mjs +169 -0
- package/bin/horizontal-capabilities/v1/contract.json +280 -0
- package/bin/http.mjs +500 -0
- package/bin/lint-manifests/justbash-commands.json +88 -0
- package/bin/lint-manifests/python-stdlib.json +295 -0
- package/bin/login-page.mjs +488 -0
- package/bin/schedules.mjs +664 -0
- package/bin/server-sandbox.mjs +204 -0
- package/bin/servicedev.mjs +425 -0
- package/bin/sync.mjs +357 -0
- package/bin/terminus.js +3666 -0
- package/bin/toolchain.mjs +125 -0
- package/bin/vendor/app-runtime-v1/app-host.json +124 -0
- package/bin/vendor/app-runtime-v1/capability-calls.json +412 -0
- package/bin/vendor/app-runtime-v1/doors.json +2867 -0
- package/bin/vendor/appd/node-harness.mjs +209 -0
- package/bin/vendor/appd/python-harness.py +12 -0
- package/bin/vendor/appd/server-protocol.json +84 -0
- package/bin/vendor/where.mjs +541 -0
- package/bin/versioning.mjs +72 -0
- package/bin/write-rules.mjs +398 -0
- 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
|
+
}
|