@foldrun/core 0.6.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +14 -1
  2. package/dist/index.d.ts +3 -0
  3. package/dist/index.js +3 -0
  4. package/dist/src/agent-import.d.ts +21 -0
  5. package/dist/src/agent-import.js +116 -0
  6. package/dist/src/api-tools.js +14 -2
  7. package/dist/src/approvals.d.ts +8 -1
  8. package/dist/src/approvals.js +10 -5
  9. package/dist/src/completions.js +1 -0
  10. package/dist/src/confine.d.ts +1 -0
  11. package/dist/src/confine.js +33 -4
  12. package/dist/src/deploy.d.ts +9 -0
  13. package/dist/src/deploy.js +31 -3
  14. package/dist/src/evals.js +22 -5
  15. package/dist/src/hooks.d.ts +114 -0
  16. package/dist/src/hooks.js +370 -0
  17. package/dist/src/host-env.d.ts +14 -0
  18. package/dist/src/host-env.js +26 -0
  19. package/dist/src/install-helper.d.ts +17 -0
  20. package/dist/src/install-helper.js +134 -0
  21. package/dist/src/install-tool.d.ts +128 -0
  22. package/dist/src/install-tool.js +133 -0
  23. package/dist/src/kinds.d.ts +1 -1
  24. package/dist/src/kinds.js +26 -0
  25. package/dist/src/library.d.ts +1 -1
  26. package/dist/src/library.js +17 -1
  27. package/dist/src/mail.js +1 -1
  28. package/dist/src/model-credential.d.ts +16 -0
  29. package/dist/src/model-credential.js +40 -0
  30. package/dist/src/model-env.d.ts +17 -0
  31. package/dist/src/model-env.js +57 -0
  32. package/dist/src/notify.d.ts +8 -0
  33. package/dist/src/notify.js +10 -1
  34. package/dist/src/package.d.ts +67 -0
  35. package/dist/src/package.js +594 -0
  36. package/dist/src/platform.d.ts +5 -0
  37. package/dist/src/platform.js +1 -0
  38. package/dist/src/provider-health.js +3 -0
  39. package/dist/src/providers.d.ts +5 -0
  40. package/dist/src/providers.js +21 -1
  41. package/dist/src/run-container.d.ts +10 -0
  42. package/dist/src/run-container.js +72 -14
  43. package/dist/src/runner.d.ts +205 -0
  44. package/dist/src/runner.js +273 -36
  45. package/dist/src/runtime.d.ts +77 -0
  46. package/dist/src/runtime.js +230 -5
  47. package/dist/src/script-tools.d.ts +2 -0
  48. package/dist/src/script-tools.js +3 -1
  49. package/dist/src/secrets.d.ts +5 -0
  50. package/dist/src/secrets.js +37 -6
  51. package/dist/src/starter.d.ts +12 -0
  52. package/dist/src/starter.js +38 -4
  53. package/dist/src/step-exec.d.ts +21 -2
  54. package/dist/src/step-exec.js +165 -7
  55. package/dist/src/storage.d.ts +7 -1
  56. package/dist/src/storage.js +14 -1
  57. package/dist/src/store.d.ts +31 -1
  58. package/dist/src/store.js +90 -8
  59. package/dist/src/tool-names.d.ts +3 -1
  60. package/dist/src/tool-names.js +4 -2
  61. package/dist/src/tool-test.js +8 -1
  62. package/dist/src/zip.d.ts +17 -0
  63. package/dist/src/zip.js +184 -0
  64. package/index.ts +3 -0
  65. package/package.json +18 -1
  66. package/src/agent-import.ts +154 -0
  67. package/src/api-tools.ts +16 -1
  68. package/src/approvals.ts +10 -3
  69. package/src/completions.ts +1 -0
  70. package/src/confine.ts +36 -5
  71. package/src/deploy.ts +28 -3
  72. package/src/evals.ts +21 -3
  73. package/src/hooks.ts +406 -0
  74. package/src/host-env.ts +27 -0
  75. package/src/install-helper.ts +143 -0
  76. package/src/install-tool.ts +154 -0
  77. package/src/kinds.ts +28 -0
  78. package/src/library.ts +18 -2
  79. package/src/mail.ts +1 -1
  80. package/src/model-credential.ts +41 -0
  81. package/src/model-env.ts +66 -0
  82. package/src/notify.ts +11 -1
  83. package/src/package.ts +664 -0
  84. package/src/platform.ts +6 -0
  85. package/src/provider-health.ts +3 -0
  86. package/src/providers.ts +27 -1
  87. package/src/run-container.ts +75 -14
  88. package/src/runner.ts +279 -36
  89. package/src/runtime.ts +235 -5
  90. package/src/script-tools.ts +5 -1
  91. package/src/secrets.ts +34 -6
  92. package/src/starter.ts +44 -4
  93. package/src/step-exec.ts +171 -11
  94. package/src/storage.ts +13 -0
  95. package/src/store.ts +89 -7
  96. package/src/tool-names.ts +4 -2
  97. package/src/tool-test.ts +8 -1
  98. package/src/zip.ts +189 -0
@@ -0,0 +1,370 @@
1
+ // Hooks: a person's own code at the points every model's step passes through.
2
+ //
3
+ // A hook is a folder — `hooks/<name>/hook.md` (what it listens for) beside
4
+ // `run.mjs` (what it does) — in a workspace, or in the account library for
5
+ // every workspace. An agent turns one on with `hooks: [name]`; a workspace's
6
+ // or the account's AGENTS.md turns one on for every agent under it.
7
+ //
8
+ // Where they run, and why there: at foldrun's own dispatch, the step's
9
+ // PreToolUse / PostToolUse callbacks in step-exec.ts. Every model goes
10
+ // through that one loop — another provider's model is the same loop with the
11
+ // translator changing the wire format underneath — so one hook decides the
12
+ // same way on every model. Nothing here is tied to one provider's features
13
+ // (thinking blocks, effort, server tools).
14
+ //
15
+ // The contract, kept to what the established agent tools agree on (Claude
16
+ // Code, the Agent SDK, Cursor, Copilot, Gemini CLI): the event goes in as
17
+ // one JSON object on stdin, an answer comes back as one JSON object on
18
+ // stdout. Exit 0 with an answer; exit 2 denies with stderr as the reason
19
+ // (the common convention); anything else is a failure.
20
+ //
21
+ // Gates fail closed. A tool.call or step.start hook that throws, times out or
22
+ // answers with the wrong shape refuses the call — a guard that crashes must
23
+ // not become an open door. A watcher (tool.result, step.end) that fails is
24
+ // recorded and the step goes on: what it watches has already happened.
25
+ //
26
+ // A hook has no more reach than the agent: it runs as a child of the step,
27
+ // inside the step's own sandbox, in the agent's directory, with an
28
+ // environment stripped to the basics — no secrets, no model key.
29
+ import fs from "node:fs";
30
+ import path from "node:path";
31
+ import os from "node:os";
32
+ import { spawn } from "node:child_process";
33
+ import matter from "gray-matter";
34
+ export const HOOK_EVENTS = ["tool.call", "tool.result", "step.start", "step.end"];
35
+ /** Events that decide something before it happens: a failure refuses. */
36
+ const GATES = new Set(["tool.call", "step.start"]);
37
+ export const HOOK_TIMEOUT_DEFAULT = { gate: 30, watch: 60 };
38
+ export const HOOK_TIMEOUT_MAX = 120;
39
+ export const HOOK_CODE_MAX = 100 * 1024;
40
+ const NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
41
+ /** Read hook.md, or say in one sentence why it is not a hook. */
42
+ export function readHookDef(dir, scope) {
43
+ const name = path.basename(dir);
44
+ if (!NAME.test(name))
45
+ return { error: `hooks/${name}: a hook's folder name is lowercase letters, digits and dashes` };
46
+ const md = path.join(dir, "hook.md");
47
+ if (!fs.existsSync(md))
48
+ return { error: `hooks/${name}: no hook.md` };
49
+ if (!fs.existsSync(path.join(dir, "run.mjs")))
50
+ return { error: `hooks/${name}: no run.mjs beside hook.md` };
51
+ let data;
52
+ try {
53
+ data = (matter(fs.readFileSync(md, "utf8")).data ?? {});
54
+ }
55
+ catch (err) {
56
+ return { error: `hooks/${name}/hook.md: frontmatter does not parse (${err.message.split("\n")[0]})` };
57
+ }
58
+ const on = data.on;
59
+ if (typeof on !== "string" || !HOOK_EVENTS.includes(on)) {
60
+ return { error: `hooks/${name}/hook.md: on: must be one of ${HOOK_EVENTS.join(", ")}${on === "run.end" ? " (run.end is not available yet)" : ""}` };
61
+ }
62
+ const event = on;
63
+ if (data.tool !== undefined && (typeof data.tool !== "string" || !data.tool.trim())) {
64
+ return { error: `hooks/${name}/hook.md: tool: is a tool name or pattern, like Bash, Write|Edit or mcp__*` };
65
+ }
66
+ if (data.tool !== undefined && event !== "tool.call" && event !== "tool.result") {
67
+ return { error: `hooks/${name}/hook.md: tool: only means something on tool.call and tool.result` };
68
+ }
69
+ // It runs through `node --eval`, one argument, which Linux caps at 128 KB.
70
+ const code = fs.readFileSync(path.join(dir, "run.mjs"), "utf8");
71
+ if (Buffer.byteLength(code) > HOOK_CODE_MAX) {
72
+ return { error: `hooks/${name}/run.mjs is ${Math.round(Buffer.byteLength(code) / 1024)} KB — a hook is one small self-contained module, at most ${HOOK_CODE_MAX / 1024} KB` };
73
+ }
74
+ const deflt = GATES.has(event) ? HOOK_TIMEOUT_DEFAULT.gate : HOOK_TIMEOUT_DEFAULT.watch;
75
+ const timeout = data.timeout === undefined ? deflt : Number(data.timeout);
76
+ if (!Number.isFinite(timeout) || timeout <= 0 || timeout > HOOK_TIMEOUT_MAX) {
77
+ return { error: `hooks/${name}/hook.md: timeout: is seconds, 1 to ${HOOK_TIMEOUT_MAX}` };
78
+ }
79
+ return {
80
+ name,
81
+ dir,
82
+ scope,
83
+ on: event,
84
+ ...(typeof data.tool === "string" ? { tool: data.tool.trim() } : {}),
85
+ timeoutSec: timeout,
86
+ ...(typeof data.description === "string" ? { description: data.description } : {}),
87
+ code,
88
+ };
89
+ }
90
+ /** Every hook folder in a hooks/ directory. */
91
+ export function listHookDirs(hooksDir) {
92
+ if (!fs.existsSync(hooksDir))
93
+ return [];
94
+ return fs.readdirSync(hooksDir, { withFileTypes: true })
95
+ .filter((d) => d.isDirectory() && !d.name.startsWith("."))
96
+ .map((d) => path.join(hooksDir, d.name))
97
+ .sort();
98
+ }
99
+ /** The names a frontmatter `hooks:` holds — a list, or one name. */
100
+ export function hookNames(raw) {
101
+ const list = Array.isArray(raw) ? raw : typeof raw === "string" ? raw.split(",") : [];
102
+ return list.map((x) => String(x).trim()).filter(Boolean);
103
+ }
104
+ /**
105
+ * The hooks one agent runs, in order: what the account turned on, then the
106
+ * workspace, then the agent itself, each name once. A name resolves to the
107
+ * workspace's own hooks/ first, then the library's. Names that resolve to
108
+ * nothing come back in `missing` — `foldrun check` errors on them, and a run
109
+ * refuses to start the step rather than run unguarded.
110
+ */
111
+ export function resolveHooks(args) {
112
+ const hooks = [];
113
+ const missing = [];
114
+ const errors = [];
115
+ const seen = new Set();
116
+ for (const name of args.names) {
117
+ if (seen.has(name))
118
+ continue;
119
+ seen.add(name);
120
+ const own = path.join(args.workspaceRoot, "hooks", name);
121
+ const shared = path.join(args.libraryRoot, "hooks", name);
122
+ const dir = fs.existsSync(own) ? own : fs.existsSync(shared) ? shared : null;
123
+ if (!dir) {
124
+ missing.push(name);
125
+ continue;
126
+ }
127
+ const def = readHookDef(dir, dir === own ? "workspace" : "account");
128
+ if ("error" in def)
129
+ errors.push(def.error);
130
+ else
131
+ hooks.push(def);
132
+ }
133
+ return { hooks, missing, errors };
134
+ }
135
+ /**
136
+ * Does `pattern` name this tool? Alternatives split on `|`, `*` matches any
137
+ * run of characters. A pattern without `__` also matches a tool's short
138
+ * name, so `web` catches `mcp__foldrun_scripts__web` and `Bash` catches Bash.
139
+ */
140
+ export function matchesTool(pattern, tool) {
141
+ if (!pattern)
142
+ return true;
143
+ const short = tool.includes("__") ? tool.slice(tool.lastIndexOf("__") + 2) : tool;
144
+ return pattern.split("|").map((p) => p.trim()).filter(Boolean).some((p) => {
145
+ const re = new RegExp(`^${p.replace(/[.+?^${}()[\]\\]/g, "\\$&").replace(/\*/g, ".*")}$`);
146
+ return re.test(tool) || (!p.includes("__") && re.test(short));
147
+ });
148
+ }
149
+ /** Only these reach a hook's process: enough to run node, nothing to act with. */
150
+ const PASSED_ENV = ["PATH", "LANG", "LC_ALL", "TZ", "TMPDIR", "NODE_PATH"];
151
+ /** One empty home per hook name, made once: a hook reads no dotfile the
152
+ * step's own home holds (a CLI login, a cached token). */
153
+ function hookHome(hook) {
154
+ const dir = path.join(os.tmpdir(), "foldrun-hook-home", hook.name);
155
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
156
+ return dir;
157
+ }
158
+ export function hookEnv(from, hook) {
159
+ const env = {};
160
+ for (const k of PASSED_ENV)
161
+ if (from[k])
162
+ env[k] = from[k];
163
+ env.HOME = hookHome(hook);
164
+ env.FOLDRUN_HOOK = hook.name;
165
+ env.FOLDRUN_HOOK_EVENT = hook.on;
166
+ return env;
167
+ }
168
+ /** Run one hook on one event. Never throws: a failure is an answer. */
169
+ export async function runHook(hook, event, opts) {
170
+ const started = Date.now();
171
+ const done = (a) => ({ ...a, ms: Date.now() - started });
172
+ return new Promise((resolve) => {
173
+ let out = "";
174
+ let err = "";
175
+ let settled = false;
176
+ const finish = (a) => { if (!settled) {
177
+ settled = true;
178
+ resolve(done(a));
179
+ } };
180
+ // Typed: untyped, a stricter tsc (foldrun-web compiles this file) narrows
181
+ // it to never after the catch that returns.
182
+ let child;
183
+ try {
184
+ // From the copy taken at the start of the step, never the file: what
185
+ // guards the step cannot be edited by the step. One self-contained
186
+ // module — Node's built-ins only, no relative imports.
187
+ child = spawn(opts.node ?? process.execPath, ["--input-type=module", "--eval", hook.code], {
188
+ cwd: opts.cwd,
189
+ // Cast: Next's typings (foldrun-web compiles this file) make NODE_ENV
190
+ // required on ProcessEnv; a hook deliberately gets only hookEnv's keys.
191
+ env: hookEnv(opts.env, hook),
192
+ stdio: ["pipe", "pipe", "pipe"],
193
+ // Its own process group, so a timeout can kill what the hook started
194
+ // too: SIGKILL to node alone left a `sleep` or a curl it spawned
195
+ // running in the sandbox after the call had been refused.
196
+ detached: process.platform !== "win32",
197
+ });
198
+ }
199
+ catch (e) {
200
+ return finish({ ok: false, failure: `could not start: ${e.message}` });
201
+ }
202
+ const killAll = () => {
203
+ try {
204
+ if (child.pid && process.platform !== "win32")
205
+ process.kill(-child.pid, "SIGKILL");
206
+ else
207
+ child.kill("SIGKILL");
208
+ }
209
+ catch {
210
+ try {
211
+ child.kill("SIGKILL");
212
+ }
213
+ catch { /* already gone */ }
214
+ }
215
+ };
216
+ const timer = setTimeout(() => {
217
+ killAll();
218
+ finish({ ok: false, failure: `timed out after ${hook.timeoutSec}s` });
219
+ }, hook.timeoutSec * 1000);
220
+ // Keep the TAIL of each stream, not the head: the answer is the last
221
+ // line of stdout and the reason the last lines of stderr, so a hook that
222
+ // logged a megabyte first lost its answer under a head cap and failed.
223
+ // Decoded by the stream, so a character split across two chunks is not
224
+ // turned into replacement characters inside the JSON answer.
225
+ child.stdout.setEncoding("utf8");
226
+ child.stderr.setEncoding("utf8");
227
+ child.stdout.on("data", (b) => { out += b; if (out.length > 1_000_000)
228
+ out = out.slice(-1_000_000); });
229
+ child.stderr.on("data", (b) => { err += b; if (err.length > 20_000)
230
+ err = err.slice(-20_000); });
231
+ child.on("error", (e) => { clearTimeout(timer); finish({ ok: false, failure: `could not start: ${e.message}` }); });
232
+ child.on("close", (code) => {
233
+ clearTimeout(timer);
234
+ // A hook is one shot: whatever it left running in the background goes
235
+ // with it, rather than outlive the call it was asked about.
236
+ if (!settled)
237
+ killAll();
238
+ const stderr = err.trim().split("\n").slice(-3).join(" ").slice(0, 500);
239
+ if (code === 2)
240
+ return finish({ ok: true, decision: "deny", reason: stderr || `${hook.name} refused it` });
241
+ if (code !== 0) {
242
+ // An uncaught throw ends stderr with stack frames and "Node.js vN":
243
+ // the last three lines named neither the error nor its message.
244
+ const lines = err.split("\n").map((l) => l.trim()).filter((l) => l && !/^Node\.js v\d/.test(l) && !/^at /.test(l));
245
+ const thrown = [...lines].reverse().find((l) => /^[A-Za-z]*Error\b.*:/.test(l));
246
+ const why = (thrown ?? lines.slice(-3).join(" ")).slice(0, 500);
247
+ return finish({ ok: false, failure: `exited ${code ?? "on a signal"}${why ? `: ${why}` : ""}` });
248
+ }
249
+ finish(parseAnswer(hook.on, out));
250
+ });
251
+ child.stdin.on("error", () => { });
252
+ child.stdin.end(JSON.stringify(event));
253
+ });
254
+ }
255
+ /** The last JSON object a hook printed, checked against its event's shape. */
256
+ export function parseAnswer(on, stdout) {
257
+ const lines = stdout.trim().split("\n").map((l) => l.trim()).filter(Boolean);
258
+ if (!lines.length)
259
+ return { ok: true, decision: on === "tool.call" ? "allow" : undefined };
260
+ let a;
261
+ try {
262
+ a = JSON.parse(lines[lines.length - 1]);
263
+ }
264
+ catch {
265
+ return { ok: false, failure: "its last line of output is not JSON" };
266
+ }
267
+ if (!a || typeof a !== "object" || Array.isArray(a))
268
+ return { ok: false, failure: "it answered with something other than a JSON object" };
269
+ const o = a;
270
+ const str = (k) => (o[k] === undefined ? undefined : typeof o[k] === "string" ? o[k] : null);
271
+ const note = str("note");
272
+ if (note === null)
273
+ return { ok: false, failure: "note must be text" };
274
+ if (on === "tool.call") {
275
+ const decision = o.decision ?? "allow";
276
+ if (decision !== "allow" && decision !== "deny")
277
+ return { ok: false, failure: `decision must be allow or deny, not ${JSON.stringify(decision)}` };
278
+ const reason = str("reason");
279
+ if (reason === null)
280
+ return { ok: false, failure: "reason must be text" };
281
+ if (decision === "deny" && !reason)
282
+ return { ok: false, failure: "a deny needs a reason — the model reads it" };
283
+ if (o.input !== undefined && (!o.input || typeof o.input !== "object" || Array.isArray(o.input)))
284
+ return { ok: false, failure: "input must be the tool's whole input, as an object" };
285
+ return { ok: true, decision, ...(reason ? { reason } : {}), ...(o.input ? { input: o.input } : {}), ...(note ? { note } : {}) };
286
+ }
287
+ if (on === "step.start") {
288
+ const context = str("context");
289
+ if (context === null)
290
+ return { ok: false, failure: "context must be text" };
291
+ return { ok: true, ...(context ? { context } : {}), ...(note ? { note } : {}) };
292
+ }
293
+ if (on === "step.end") {
294
+ const reply = str("reply");
295
+ if (reply === null)
296
+ return { ok: false, failure: "reply must be text" };
297
+ return { ok: true, ...(reply !== undefined ? { reply } : {}), ...(note ? { note } : {}) };
298
+ }
299
+ return { ok: true, ...(note ? { note } : {}) };
300
+ }
301
+ /** A hook record as it crosses from the sandbox: checked, never trusted. */
302
+ export function isHookRecord(x) {
303
+ if (!x || typeof x !== "object")
304
+ return false;
305
+ const r = x;
306
+ return typeof r.hook === "string" && NAME.test(r.hook)
307
+ && typeof r.event === "string" && HOOK_EVENTS.includes(r.event)
308
+ && typeof r.did === "string" && ["deny", "rewrite", "context", "reply", "note", "error"].includes(r.did)
309
+ && typeof r.ms === "number"
310
+ && (r.tool === undefined || typeof r.tool === "string")
311
+ && (r.reason === undefined || typeof r.reason === "string");
312
+ }
313
+ export function isGate(on) {
314
+ return GATES.has(on);
315
+ }
316
+ /**
317
+ * tool.call: every matching hook in order. The first deny ends it — the most
318
+ * restrictive answer wins; a rewrite is handed on to the next hook, so they
319
+ * chain in the order they were turned on. A hook that fails denies.
320
+ */
321
+ export async function runToolCallHooks(hooks, call, run) {
322
+ const records = [];
323
+ let input = call.input;
324
+ let rewritten = false;
325
+ for (const hook of hooks) {
326
+ if (hook.on !== "tool.call" || !matchesTool(hook.tool, call.tool))
327
+ continue;
328
+ const a = await run(hook, { ...call.base, event: "tool.call", hook: hook.name, tool: { name: call.tool, input } });
329
+ if (!a.ok) {
330
+ const reason = `hook ${hook.name} failed (${a.failure}), so the call is refused`;
331
+ records.push({ hook: hook.name, event: "tool.call", did: "error", tool: call.tool, reason, ms: a.ms });
332
+ return { decision: "deny", reason, records };
333
+ }
334
+ if (a.decision === "deny") {
335
+ records.push({ hook: hook.name, event: "tool.call", did: "deny", tool: call.tool, reason: a.reason, ms: a.ms });
336
+ return { decision: "deny", reason: `hook ${hook.name}: ${a.reason}`, records };
337
+ }
338
+ if (a.input) {
339
+ input = a.input;
340
+ rewritten = true;
341
+ records.push({ hook: hook.name, event: "tool.call", did: "rewrite", tool: call.tool, ...(a.reason ? { reason: a.reason } : {}), ms: a.ms });
342
+ }
343
+ else if (a.note) {
344
+ records.push({ hook: hook.name, event: "tool.call", did: "note", tool: call.tool, reason: a.note, ms: a.ms });
345
+ }
346
+ }
347
+ return { decision: "allow", ...(rewritten ? { input } : {}), records };
348
+ }
349
+ /** The trail's line for a record. */
350
+ export function hookLine(r) {
351
+ const what = r.tool ? ` ${r.tool}` : "";
352
+ switch (r.did) {
353
+ case "deny": return `hook ${r.hook} denied${what}: ${r.reason}`;
354
+ case "rewrite": return `hook ${r.hook} rewrote${what}${r.reason ? `: ${r.reason}` : ""}`;
355
+ case "context": return `hook ${r.hook} added context to the step`;
356
+ case "reply": return `hook ${r.hook} rewrote the step's reply`;
357
+ case "note": return `hook ${r.hook}${what}: ${r.reason}`;
358
+ case "error": return `hook ${r.hook} failed on ${r.event}${what}: ${r.reason}`;
359
+ }
360
+ }
361
+ /** For `foldrun check`: every problem with the hooks/ folder of one root. */
362
+ export function checkHooksDir(hooksDir, scope) {
363
+ const problems = [];
364
+ for (const dir of listHookDirs(hooksDir)) {
365
+ const def = readHookDef(dir, scope);
366
+ if ("error" in def)
367
+ problems.push(def.error);
368
+ }
369
+ return problems;
370
+ }
@@ -6,3 +6,17 @@ export declare function hostEnvAllowed(name: string): boolean;
6
6
  * caller that layers secrets on top never writes into the host's own env.
7
7
  */
8
8
  export declare function hostSafeEnv(from?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
9
+ /**
10
+ * Where this install's own API answers, for a step to call: `FOLDRUN_API_URL`
11
+ * when the operator set it, else `FOLDRUN_PUBLIC_URL` + `/api`. Undefined on a
12
+ * laptop with neither.
13
+ *
14
+ * A desk that reads its own run history used to write the host into its
15
+ * files (`https://dev.foldrun.io/api`), so the same workspace pointed at the
16
+ * wrong server the day it moved. Now a script reads `$FOLDRUN_API_URL` and an
17
+ * `apis:` entry writes `base: ${FOLDRUN_API_URL}`.
18
+ */
19
+ export declare function platformApiUrl(from?: NodeJS.ProcessEnv): string | undefined;
20
+ /** The install's own addresses an `apis:` base may name. Not secrets: they
21
+ * are printed in the prompt as the tool's base URL. */
22
+ export declare function expandPlatformVars(value: string, from?: NodeJS.ProcessEnv): string;
@@ -51,3 +51,29 @@ export function hostSafeEnv(from = process.env) {
51
51
  // every spawn. The value is the same either way.
52
52
  return out;
53
53
  }
54
+ /**
55
+ * Where this install's own API answers, for a step to call: `FOLDRUN_API_URL`
56
+ * when the operator set it, else `FOLDRUN_PUBLIC_URL` + `/api`. Undefined on a
57
+ * laptop with neither.
58
+ *
59
+ * A desk that reads its own run history used to write the host into its
60
+ * files (`https://dev.foldrun.io/api`), so the same workspace pointed at the
61
+ * wrong server the day it moved. Now a script reads `$FOLDRUN_API_URL` and an
62
+ * `apis:` entry writes `base: ${FOLDRUN_API_URL}`.
63
+ */
64
+ export function platformApiUrl(from = process.env) {
65
+ const explicit = from.FOLDRUN_API_URL?.trim();
66
+ if (explicit)
67
+ return explicit.replace(/\/+$/, "");
68
+ const origin = from.FOLDRUN_PUBLIC_URL?.trim();
69
+ return origin ? `${origin.replace(/\/+$/, "")}/api` : undefined;
70
+ }
71
+ /** The install's own addresses an `apis:` base may name. Not secrets: they
72
+ * are printed in the prompt as the tool's base URL. */
73
+ export function expandPlatformVars(value, from = process.env) {
74
+ const vars = {
75
+ FOLDRUN_API_URL: platformApiUrl(from),
76
+ FOLDRUN_PUBLIC_URL: from.FOLDRUN_PUBLIC_URL?.trim().replace(/\/+$/, "") || undefined,
77
+ };
78
+ return value.replace(/\$\{(FOLDRUN_API_URL|FOLDRUN_PUBLIC_URL)\}/g, (whole, name) => vars[name] ?? whole);
79
+ }
@@ -0,0 +1,17 @@
1
+ import net from "node:net";
2
+ export interface InstallRequest {
3
+ manager: "apt";
4
+ packages: string[];
5
+ }
6
+ export interface InstallReply {
7
+ ok: boolean;
8
+ /** "snapshot" | "apt" */
9
+ mode?: string;
10
+ seconds?: number;
11
+ error?: string;
12
+ }
13
+ /** Validate one request; null when it may run. Exported for the tests. */
14
+ export declare function refuse(req: unknown): string | null;
15
+ /** Install one validated request with the start-up's own snippet. */
16
+ export declare function installApt(packages: string[], cacheDir: string): Promise<InstallReply>;
17
+ export declare function serve(socket: string, cacheDir: string): net.Server;
@@ -0,0 +1,134 @@
1
+ // The root half of the `install` tool: a tiny server the run container's root
2
+ // start-up leaves running before it drops to the agent user.
3
+ //
4
+ // node install-helper.js <socket> <runtime cache dir>
5
+ //
6
+ // The agent never becomes root. It can only ask this process, over a unix
7
+ // socket, to install Debian packages BY NAME — each name re-checked against
8
+ // Debian's naming rule here, so nothing but a package name reaches apt, and
9
+ // apt is driven by the same snippet the start-up uses (systemInstallSh): the
10
+ // account's .deb cache and snapshots, so a package anyone installed before is
11
+ // restored in about a second. Requests are served one at a time; the process
12
+ // dies with the container.
13
+ //
14
+ // pip and npm need no root and are installed by the tool itself, as the agent.
15
+ import fs from "node:fs";
16
+ import net from "node:net";
17
+ import os from "node:os";
18
+ import path from "node:path";
19
+ import { spawn } from "node:child_process";
20
+ import { DEB_PACKAGE, systemInstallSh, summarizeInstallFailure } from "./runtime.js";
21
+ /** Validate one request; null when it may run. Exported for the tests. */
22
+ export function refuse(req) {
23
+ const r = req;
24
+ if (!r || typeof r !== "object")
25
+ return "not a request";
26
+ if (r.manager !== "apt")
27
+ return "this helper installs Debian packages (manager: apt) only";
28
+ if (!Array.isArray(r.packages) || r.packages.length === 0)
29
+ return "no packages named";
30
+ if (r.packages.length > 50)
31
+ return "at most 50 packages per request";
32
+ const bad = r.packages.filter((p) => typeof p !== "string" || !DEB_PACKAGE.test(p));
33
+ if (bad.length)
34
+ return `not Debian package names: ${bad.map(String).join(", ")}`;
35
+ return null;
36
+ }
37
+ /** Install one validated request with the start-up's own snippet. */
38
+ export function installApt(packages, cacheDir) {
39
+ const logDir = fs.mkdtempSync(path.join(os.tmpdir(), "foldrun-install-"));
40
+ return new Promise((resolve) => {
41
+ const child = spawn("sh", ["-c", systemInstallSh(cacheDir, logDir)], {
42
+ env: { ...process.env, FOLDRUN_SYSTEM_PACKAGES: packages.join(" ") },
43
+ stdio: "ignore",
44
+ });
45
+ child.on("close", () => {
46
+ let status = {};
47
+ try {
48
+ status = JSON.parse(fs.readFileSync(path.join(logDir, "system-install.json"), "utf8"));
49
+ }
50
+ catch {
51
+ status = { ok: false };
52
+ }
53
+ let log = "";
54
+ try {
55
+ log = fs.readFileSync(path.join(logDir, "system-install.log"), "utf8");
56
+ }
57
+ catch {
58
+ log = "";
59
+ }
60
+ fs.rmSync(logDir, { recursive: true, force: true });
61
+ resolve(status.ok
62
+ ? { ok: true, mode: status.mode, seconds: status.seconds }
63
+ : { ok: false, error: summarizeInstallFailure("apt-get install", log || "no output") });
64
+ });
65
+ child.on("error", (err) => {
66
+ fs.rmSync(logDir, { recursive: true, force: true });
67
+ resolve({ ok: false, error: `could not run the installer: ${err.message}` });
68
+ });
69
+ });
70
+ }
71
+ export function serve(socket, cacheDir) {
72
+ try {
73
+ fs.rmSync(socket, { force: true });
74
+ }
75
+ catch {
76
+ /* nothing there */
77
+ }
78
+ let queue = Promise.resolve();
79
+ const server = net.createServer((conn) => {
80
+ let buf = "";
81
+ conn.on("data", (chunk) => {
82
+ buf += chunk.toString("utf8");
83
+ if (buf.length > 64_000) {
84
+ conn.end(JSON.stringify({ ok: false, error: "request too large" }) + "\n");
85
+ return;
86
+ }
87
+ const nl = buf.indexOf("\n");
88
+ if (nl < 0)
89
+ return;
90
+ const line = buf.slice(0, nl);
91
+ buf = "";
92
+ let req;
93
+ try {
94
+ req = JSON.parse(line);
95
+ }
96
+ catch {
97
+ conn.end(JSON.stringify({ ok: false, error: "not JSON" }) + "\n");
98
+ return;
99
+ }
100
+ const why = refuse(req);
101
+ if (why) {
102
+ conn.end(JSON.stringify({ ok: false, error: why }) + "\n");
103
+ return;
104
+ }
105
+ // One install at a time: apt holds a lock, and two dpkg runs in one
106
+ // container would fight over it.
107
+ queue = queue.then(async () => {
108
+ const reply = await installApt(req.packages, cacheDir);
109
+ conn.end(JSON.stringify(reply) + "\n");
110
+ });
111
+ });
112
+ conn.on("error", () => {
113
+ /* the client went away */
114
+ });
115
+ });
116
+ server.listen(socket, () => {
117
+ // The agent user must be able to connect; nothing else runs in here.
118
+ try {
119
+ fs.chmodSync(socket, 0o666);
120
+ }
121
+ catch {
122
+ /* best effort */
123
+ }
124
+ });
125
+ return server;
126
+ }
127
+ if (process.argv[1] && /install-helper\.(js|ts|mjs)$/.test(process.argv[1])) {
128
+ const [socket, cacheDir] = process.argv.slice(2);
129
+ if (!socket || !cacheDir) {
130
+ console.error("usage: install-helper <socket> <runtime cache dir>");
131
+ process.exit(2);
132
+ }
133
+ serve(socket, cacheDir);
134
+ }