@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
package/src/evals.ts CHANGED
@@ -31,6 +31,7 @@
31
31
  // they pass — no point paying a model to grade output already known to be
32
32
  // wrong.
33
33
 
34
+ import { accountModelEnv } from "./model-env.ts";
34
35
  import { sdkEnv } from "./step-exec.ts";
35
36
  import fs from "node:fs";
36
37
  import path from "node:path";
@@ -477,6 +478,7 @@ async function judge(
477
478
  output: string,
478
479
  model: string,
479
480
  effort: Effort | null,
481
+ env: Record<string, string | undefined>,
480
482
  ): Promise<AssertionResult["detail"] & string> {
481
483
  const prompt =
482
484
  `You are grading one piece of output against one criterion. Answer with exactly ` +
@@ -493,7 +495,7 @@ async function judge(
493
495
  allowedTools: [],
494
496
  settingSources: [],
495
497
  strictMcpConfig: true,
496
- env: sdkEnv(undefined),
498
+ env,
497
499
  maxTurns: 1,
498
500
  },
499
501
  });
@@ -513,6 +515,7 @@ async function checkAssertion(
513
515
  agentDir: string,
514
516
  model: string,
515
517
  effort: Effort | null,
518
+ judgeEnv: () => Record<string, string | undefined> = () => sdkEnv(undefined),
516
519
  ): Promise<AssertionResult> {
517
520
  const hay = output.toLowerCase();
518
521
  switch (assertion.type) {
@@ -545,7 +548,15 @@ async function checkAssertion(
545
548
  return { assertion, passed: code === 0, detail: `exit ${code ?? "error"}${out ? ` — ${out.slice(0, 200)}` : ""}` };
546
549
  }
547
550
  case "judge": {
548
- const verdict = await judge(assertion.value, output, model, effort);
551
+ // The judge runs on the account's own model key, never quietly on the
552
+ // platform's: a customer's eval is not the operator's bill.
553
+ let env: Record<string, string | undefined>;
554
+ try {
555
+ env = judgeEnv();
556
+ } catch (err) {
557
+ return { assertion, passed: false, detail: `judge not run — ${err instanceof Error ? err.message : String(err)}` };
558
+ }
559
+ const verdict = await judge(assertion.value, output, model, effort, env);
549
560
  const passed = /^\s*PASS\b/i.test(verdict);
550
561
  return { assertion, passed, detail: verdict.slice(0, 300) || "judge returned nothing" };
551
562
  }
@@ -628,7 +639,14 @@ export async function runEval(
628
639
  const judges = testCase.expect.filter((a) => a.type === "judge");
629
640
  for (const a of cheap) assertions.push(await checkAssertion(a, output, agentDir, info.model, info.effort));
630
641
  if (assertions.every((r) => r.passed)) {
631
- for (const a of judges) assertions.push(await checkAssertion(a, output, agentDir, info.model, info.effort));
642
+ const judgeEnv = () => {
643
+ const m = accountModelEnv(tenant, workspace, () => sdkEnv(undefined));
644
+ if (m.format !== "anthropic") {
645
+ throw new Error(`this account's provider speaks ${m.format}; a judge needs an Anthropic-format provider`);
646
+ }
647
+ return m.env;
648
+ };
649
+ for (const a of judges) assertions.push(await checkAssertion(a, output, agentDir, info.model, info.effort, judgeEnv));
632
650
  } else {
633
651
  for (const a of judges) {
634
652
  assertions.push({ assertion: a, passed: false, detail: "not run — an earlier check failed" });
package/src/hooks.ts ADDED
@@ -0,0 +1,406 @@
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
+
30
+ import fs from "node:fs";
31
+ import path from "node:path";
32
+ import os from "node:os";
33
+ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
34
+ import matter from "gray-matter";
35
+
36
+ export const HOOK_EVENTS = ["tool.call", "tool.result", "step.start", "step.end"] as const;
37
+ export type HookEvent = (typeof HOOK_EVENTS)[number];
38
+
39
+ /** Events that decide something before it happens: a failure refuses. */
40
+ const GATES: ReadonlySet<HookEvent> = new Set(["tool.call", "step.start"]);
41
+
42
+ export const HOOK_TIMEOUT_DEFAULT = { gate: 30, watch: 60 } as const;
43
+ export const HOOK_TIMEOUT_MAX = 120;
44
+ export const HOOK_CODE_MAX = 100 * 1024;
45
+ const NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
46
+
47
+ export interface HookDef {
48
+ name: string;
49
+ /** The folder holding hook.md and run.mjs. */
50
+ dir: string;
51
+ scope: "workspace" | "account";
52
+ on: HookEvent;
53
+ /** `tool:` — which tools a tool.call / tool.result hook sees. Unset is all. */
54
+ tool?: string;
55
+ timeoutSec: number;
56
+ description?: string;
57
+ /** run.mjs as it was when the step started. The hook runs from this copy,
58
+ * so an agent that edits the file mid-step changes nothing that runs. */
59
+ code: string;
60
+ }
61
+
62
+ /** Read hook.md, or say in one sentence why it is not a hook. */
63
+ export function readHookDef(dir: string, scope: HookDef["scope"]): HookDef | { error: string } {
64
+ const name = path.basename(dir);
65
+ if (!NAME.test(name)) return { error: `hooks/${name}: a hook's folder name is lowercase letters, digits and dashes` };
66
+ const md = path.join(dir, "hook.md");
67
+ if (!fs.existsSync(md)) return { error: `hooks/${name}: no hook.md` };
68
+ if (!fs.existsSync(path.join(dir, "run.mjs"))) return { error: `hooks/${name}: no run.mjs beside hook.md` };
69
+ let data: Record<string, unknown>;
70
+ try {
71
+ data = (matter(fs.readFileSync(md, "utf8")).data ?? {}) as Record<string, unknown>;
72
+ } catch (err) {
73
+ return { error: `hooks/${name}/hook.md: frontmatter does not parse (${(err as Error).message.split("\n")[0]})` };
74
+ }
75
+ const on = data.on;
76
+ if (typeof on !== "string" || !(HOOK_EVENTS as readonly string[]).includes(on)) {
77
+ return { error: `hooks/${name}/hook.md: on: must be one of ${HOOK_EVENTS.join(", ")}${on === "run.end" ? " (run.end is not available yet)" : ""}` };
78
+ }
79
+ const event = on as HookEvent;
80
+ if (data.tool !== undefined && (typeof data.tool !== "string" || !data.tool.trim())) {
81
+ return { error: `hooks/${name}/hook.md: tool: is a tool name or pattern, like Bash, Write|Edit or mcp__*` };
82
+ }
83
+ if (data.tool !== undefined && event !== "tool.call" && event !== "tool.result") {
84
+ return { error: `hooks/${name}/hook.md: tool: only means something on tool.call and tool.result` };
85
+ }
86
+ // It runs through `node --eval`, one argument, which Linux caps at 128 KB.
87
+ const code = fs.readFileSync(path.join(dir, "run.mjs"), "utf8");
88
+ if (Buffer.byteLength(code) > HOOK_CODE_MAX) {
89
+ 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` };
90
+ }
91
+ const deflt = GATES.has(event) ? HOOK_TIMEOUT_DEFAULT.gate : HOOK_TIMEOUT_DEFAULT.watch;
92
+ const timeout = data.timeout === undefined ? deflt : Number(data.timeout);
93
+ if (!Number.isFinite(timeout) || timeout <= 0 || timeout > HOOK_TIMEOUT_MAX) {
94
+ return { error: `hooks/${name}/hook.md: timeout: is seconds, 1 to ${HOOK_TIMEOUT_MAX}` };
95
+ }
96
+ return {
97
+ name,
98
+ dir,
99
+ scope,
100
+ on: event,
101
+ ...(typeof data.tool === "string" ? { tool: data.tool.trim() } : {}),
102
+ timeoutSec: timeout,
103
+ ...(typeof data.description === "string" ? { description: data.description } : {}),
104
+ code,
105
+ };
106
+ }
107
+
108
+ /** Every hook folder in a hooks/ directory. */
109
+ export function listHookDirs(hooksDir: string): string[] {
110
+ if (!fs.existsSync(hooksDir)) return [];
111
+ return fs.readdirSync(hooksDir, { withFileTypes: true })
112
+ .filter((d) => d.isDirectory() && !d.name.startsWith("."))
113
+ .map((d) => path.join(hooksDir, d.name))
114
+ .sort();
115
+ }
116
+
117
+ /** The names a frontmatter `hooks:` holds — a list, or one name. */
118
+ export function hookNames(raw: unknown): string[] {
119
+ const list = Array.isArray(raw) ? raw : typeof raw === "string" ? raw.split(",") : [];
120
+ return list.map((x) => String(x).trim()).filter(Boolean);
121
+ }
122
+
123
+ /**
124
+ * The hooks one agent runs, in order: what the account turned on, then the
125
+ * workspace, then the agent itself, each name once. A name resolves to the
126
+ * workspace's own hooks/ first, then the library's. Names that resolve to
127
+ * nothing come back in `missing` — `foldrun check` errors on them, and a run
128
+ * refuses to start the step rather than run unguarded.
129
+ */
130
+ export function resolveHooks(args: {
131
+ workspaceRoot: string;
132
+ libraryRoot: string;
133
+ names: string[];
134
+ }): { hooks: HookDef[]; missing: string[]; errors: string[] } {
135
+ const hooks: HookDef[] = [];
136
+ const missing: string[] = [];
137
+ const errors: string[] = [];
138
+ const seen = new Set<string>();
139
+ for (const name of args.names) {
140
+ if (seen.has(name)) continue;
141
+ seen.add(name);
142
+ const own = path.join(args.workspaceRoot, "hooks", name);
143
+ const shared = path.join(args.libraryRoot, "hooks", name);
144
+ const dir = fs.existsSync(own) ? own : fs.existsSync(shared) ? shared : null;
145
+ if (!dir) { missing.push(name); continue; }
146
+ const def = readHookDef(dir, dir === own ? "workspace" : "account");
147
+ if ("error" in def) errors.push(def.error);
148
+ else hooks.push(def);
149
+ }
150
+ return { hooks, missing, errors };
151
+ }
152
+
153
+ /**
154
+ * Does `pattern` name this tool? Alternatives split on `|`, `*` matches any
155
+ * run of characters. A pattern without `__` also matches a tool's short
156
+ * name, so `web` catches `mcp__foldrun_scripts__web` and `Bash` catches Bash.
157
+ */
158
+ export function matchesTool(pattern: string | undefined, tool: string): boolean {
159
+ if (!pattern) return true;
160
+ const short = tool.includes("__") ? tool.slice(tool.lastIndexOf("__") + 2) : tool;
161
+ return pattern.split("|").map((p) => p.trim()).filter(Boolean).some((p) => {
162
+ const re = new RegExp(`^${p.replace(/[.+?^${}()[\]\\]/g, "\\$&").replace(/\*/g, ".*")}$`);
163
+ return re.test(tool) || (!p.includes("__") && re.test(short));
164
+ });
165
+ }
166
+
167
+ /** What one hook said, normalised. */
168
+ export interface HookAnswer {
169
+ ok: boolean;
170
+ /** tool.call */
171
+ decision?: "allow" | "deny";
172
+ reason?: string;
173
+ input?: Record<string, unknown>;
174
+ /** step.start */
175
+ context?: string;
176
+ /** step.end */
177
+ reply?: string;
178
+ /** tool.result / anything: a line for the trail */
179
+ note?: string;
180
+ /** Why it failed — threw, timed out, wrong shape. */
181
+ failure?: string;
182
+ ms: number;
183
+ }
184
+
185
+ /** Only these reach a hook's process: enough to run node, nothing to act with. */
186
+ const PASSED_ENV = ["PATH", "LANG", "LC_ALL", "TZ", "TMPDIR", "NODE_PATH"];
187
+
188
+ /** One empty home per hook name, made once: a hook reads no dotfile the
189
+ * step's own home holds (a CLI login, a cached token). */
190
+ function hookHome(hook: HookDef): string {
191
+ const dir = path.join(os.tmpdir(), "foldrun-hook-home", hook.name);
192
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
193
+ return dir;
194
+ }
195
+
196
+ export function hookEnv(from: Record<string, string | undefined>, hook: HookDef): Record<string, string> {
197
+ const env: Record<string, string> = {};
198
+ for (const k of PASSED_ENV) if (from[k]) env[k] = from[k]!;
199
+ env.HOME = hookHome(hook);
200
+ env.FOLDRUN_HOOK = hook.name;
201
+ env.FOLDRUN_HOOK_EVENT = hook.on;
202
+ return env;
203
+ }
204
+
205
+ /** Run one hook on one event. Never throws: a failure is an answer. */
206
+ export async function runHook(
207
+ hook: HookDef,
208
+ event: Record<string, unknown>,
209
+ opts: { cwd: string; env: Record<string, string | undefined>; node?: string },
210
+ ): Promise<HookAnswer> {
211
+ const started = Date.now();
212
+ const done = (a: Omit<HookAnswer, "ms">): HookAnswer => ({ ...a, ms: Date.now() - started });
213
+ return new Promise((resolve) => {
214
+ let out = "";
215
+ let err = "";
216
+ let settled = false;
217
+ const finish = (a: Omit<HookAnswer, "ms">) => { if (!settled) { settled = true; resolve(done(a)); } };
218
+ // Typed: untyped, a stricter tsc (foldrun-web compiles this file) narrows
219
+ // it to never after the catch that returns.
220
+ let child: ChildProcessWithoutNullStreams;
221
+ try {
222
+ // From the copy taken at the start of the step, never the file: what
223
+ // guards the step cannot be edited by the step. One self-contained
224
+ // module — Node's built-ins only, no relative imports.
225
+ child = spawn(opts.node ?? process.execPath, ["--input-type=module", "--eval", hook.code], {
226
+ cwd: opts.cwd,
227
+ // Cast: Next's typings (foldrun-web compiles this file) make NODE_ENV
228
+ // required on ProcessEnv; a hook deliberately gets only hookEnv's keys.
229
+ env: hookEnv(opts.env, hook) as NodeJS.ProcessEnv,
230
+ stdio: ["pipe", "pipe", "pipe"],
231
+ // Its own process group, so a timeout can kill what the hook started
232
+ // too: SIGKILL to node alone left a `sleep` or a curl it spawned
233
+ // running in the sandbox after the call had been refused.
234
+ detached: process.platform !== "win32",
235
+ });
236
+ } catch (e) {
237
+ return finish({ ok: false, failure: `could not start: ${(e as Error).message}` });
238
+ }
239
+ const killAll = () => {
240
+ try { if (child.pid && process.platform !== "win32") process.kill(-child.pid, "SIGKILL"); else child.kill("SIGKILL"); }
241
+ catch { try { child.kill("SIGKILL"); } catch { /* already gone */ } }
242
+ };
243
+ const timer = setTimeout(() => {
244
+ killAll();
245
+ finish({ ok: false, failure: `timed out after ${hook.timeoutSec}s` });
246
+ }, hook.timeoutSec * 1000);
247
+ // Keep the TAIL of each stream, not the head: the answer is the last
248
+ // line of stdout and the reason the last lines of stderr, so a hook that
249
+ // logged a megabyte first lost its answer under a head cap and failed.
250
+ // Decoded by the stream, so a character split across two chunks is not
251
+ // turned into replacement characters inside the JSON answer.
252
+ child.stdout.setEncoding("utf8");
253
+ child.stderr.setEncoding("utf8");
254
+ child.stdout.on("data", (b: string) => { out += b; if (out.length > 1_000_000) out = out.slice(-1_000_000); });
255
+ child.stderr.on("data", (b: string) => { err += b; if (err.length > 20_000) err = err.slice(-20_000); });
256
+ child.on("error", (e: Error) => { clearTimeout(timer); finish({ ok: false, failure: `could not start: ${e.message}` }); });
257
+ child.on("close", (code: number | null) => {
258
+ clearTimeout(timer);
259
+ // A hook is one shot: whatever it left running in the background goes
260
+ // with it, rather than outlive the call it was asked about.
261
+ if (!settled) killAll();
262
+ const stderr = err.trim().split("\n").slice(-3).join(" ").slice(0, 500);
263
+ if (code === 2) return finish({ ok: true, decision: "deny", reason: stderr || `${hook.name} refused it` });
264
+ if (code !== 0) {
265
+ // An uncaught throw ends stderr with stack frames and "Node.js vN":
266
+ // the last three lines named neither the error nor its message.
267
+ const lines = err.split("\n").map((l) => l.trim()).filter((l) => l && !/^Node\.js v\d/.test(l) && !/^at /.test(l));
268
+ const thrown = [...lines].reverse().find((l) => /^[A-Za-z]*Error\b.*:/.test(l));
269
+ const why = (thrown ?? lines.slice(-3).join(" ")).slice(0, 500);
270
+ return finish({ ok: false, failure: `exited ${code ?? "on a signal"}${why ? `: ${why}` : ""}` });
271
+ }
272
+ finish(parseAnswer(hook.on, out));
273
+ });
274
+ child.stdin.on("error", () => {});
275
+ child.stdin.end(JSON.stringify(event));
276
+ });
277
+ }
278
+
279
+ /** The last JSON object a hook printed, checked against its event's shape. */
280
+ export function parseAnswer(on: HookEvent, stdout: string): Omit<HookAnswer, "ms"> {
281
+ const lines = stdout.trim().split("\n").map((l) => l.trim()).filter(Boolean);
282
+ if (!lines.length) return { ok: true, decision: on === "tool.call" ? "allow" : undefined };
283
+ let a: unknown;
284
+ try { a = JSON.parse(lines[lines.length - 1]); } catch { return { ok: false, failure: "its last line of output is not JSON" }; }
285
+ if (!a || typeof a !== "object" || Array.isArray(a)) return { ok: false, failure: "it answered with something other than a JSON object" };
286
+ const o = a as Record<string, unknown>;
287
+ const str = (k: string) => (o[k] === undefined ? undefined : typeof o[k] === "string" ? (o[k] as string) : null);
288
+ const note = str("note");
289
+ if (note === null) return { ok: false, failure: "note must be text" };
290
+ if (on === "tool.call") {
291
+ const decision = o.decision ?? "allow";
292
+ if (decision !== "allow" && decision !== "deny") return { ok: false, failure: `decision must be allow or deny, not ${JSON.stringify(decision)}` };
293
+ const reason = str("reason");
294
+ if (reason === null) return { ok: false, failure: "reason must be text" };
295
+ if (decision === "deny" && !reason) return { ok: false, failure: "a deny needs a reason — the model reads it" };
296
+ if (o.input !== undefined && (!o.input || typeof o.input !== "object" || Array.isArray(o.input))) return { ok: false, failure: "input must be the tool's whole input, as an object" };
297
+ return { ok: true, decision, ...(reason ? { reason } : {}), ...(o.input ? { input: o.input as Record<string, unknown> } : {}), ...(note ? { note } : {}) };
298
+ }
299
+ if (on === "step.start") {
300
+ const context = str("context");
301
+ if (context === null) return { ok: false, failure: "context must be text" };
302
+ return { ok: true, ...(context ? { context } : {}), ...(note ? { note } : {}) };
303
+ }
304
+ if (on === "step.end") {
305
+ const reply = str("reply");
306
+ if (reply === null) return { ok: false, failure: "reply must be text" };
307
+ return { ok: true, ...(reply !== undefined ? { reply } : {}), ...(note ? { note } : {}) };
308
+ }
309
+ return { ok: true, ...(note ? { note } : {}) };
310
+ }
311
+
312
+ /** What a run of tool.call hooks decided for one call. */
313
+ export interface ToolCallVerdict {
314
+ decision: "allow" | "deny";
315
+ reason?: string;
316
+ /** The input after every rewrite, when any hook rewrote it. */
317
+ input?: Record<string, unknown>;
318
+ /** One line per hook that did something, for the trail. */
319
+ records: HookRecord[];
320
+ }
321
+
322
+ /** One hook's effect, as the trail keeps it. */
323
+ export interface HookRecord {
324
+ hook: string;
325
+ event: HookEvent;
326
+ /** deny | rewrite | context | reply | note | error */
327
+ did: "deny" | "rewrite" | "context" | "reply" | "note" | "error";
328
+ tool?: string;
329
+ reason?: string;
330
+ ms: number;
331
+ }
332
+
333
+ /** A hook record as it crosses from the sandbox: checked, never trusted. */
334
+ export function isHookRecord(x: unknown): x is HookRecord {
335
+ if (!x || typeof x !== "object") return false;
336
+ const r = x as Record<string, unknown>;
337
+ return typeof r.hook === "string" && NAME.test(r.hook)
338
+ && typeof r.event === "string" && (HOOK_EVENTS as readonly string[]).includes(r.event)
339
+ && typeof r.did === "string" && ["deny", "rewrite", "context", "reply", "note", "error"].includes(r.did)
340
+ && typeof r.ms === "number"
341
+ && (r.tool === undefined || typeof r.tool === "string")
342
+ && (r.reason === undefined || typeof r.reason === "string");
343
+ }
344
+
345
+ export function isGate(on: HookEvent) {
346
+ return GATES.has(on);
347
+ }
348
+
349
+ /**
350
+ * tool.call: every matching hook in order. The first deny ends it — the most
351
+ * restrictive answer wins; a rewrite is handed on to the next hook, so they
352
+ * chain in the order they were turned on. A hook that fails denies.
353
+ */
354
+ export async function runToolCallHooks(
355
+ hooks: HookDef[],
356
+ call: { tool: string; input: Record<string, unknown>; base: Record<string, unknown> },
357
+ run: (hook: HookDef, event: Record<string, unknown>) => Promise<HookAnswer>,
358
+ ): Promise<ToolCallVerdict> {
359
+ const records: HookRecord[] = [];
360
+ let input = call.input;
361
+ let rewritten = false;
362
+ for (const hook of hooks) {
363
+ if (hook.on !== "tool.call" || !matchesTool(hook.tool, call.tool)) continue;
364
+ const a = await run(hook, { ...call.base, event: "tool.call", hook: hook.name, tool: { name: call.tool, input } });
365
+ if (!a.ok) {
366
+ const reason = `hook ${hook.name} failed (${a.failure}), so the call is refused`;
367
+ records.push({ hook: hook.name, event: "tool.call", did: "error", tool: call.tool, reason, ms: a.ms });
368
+ return { decision: "deny", reason, records };
369
+ }
370
+ if (a.decision === "deny") {
371
+ records.push({ hook: hook.name, event: "tool.call", did: "deny", tool: call.tool, reason: a.reason, ms: a.ms });
372
+ return { decision: "deny", reason: `hook ${hook.name}: ${a.reason}`, records };
373
+ }
374
+ if (a.input) {
375
+ input = a.input;
376
+ rewritten = true;
377
+ records.push({ hook: hook.name, event: "tool.call", did: "rewrite", tool: call.tool, ...(a.reason ? { reason: a.reason } : {}), ms: a.ms });
378
+ } else if (a.note) {
379
+ records.push({ hook: hook.name, event: "tool.call", did: "note", tool: call.tool, reason: a.note, ms: a.ms });
380
+ }
381
+ }
382
+ return { decision: "allow", ...(rewritten ? { input } : {}), records };
383
+ }
384
+
385
+ /** The trail's line for a record. */
386
+ export function hookLine(r: HookRecord): string {
387
+ const what = r.tool ? ` ${r.tool}` : "";
388
+ switch (r.did) {
389
+ case "deny": return `hook ${r.hook} denied${what}: ${r.reason}`;
390
+ case "rewrite": return `hook ${r.hook} rewrote${what}${r.reason ? `: ${r.reason}` : ""}`;
391
+ case "context": return `hook ${r.hook} added context to the step`;
392
+ case "reply": return `hook ${r.hook} rewrote the step's reply`;
393
+ case "note": return `hook ${r.hook}${what}: ${r.reason}`;
394
+ case "error": return `hook ${r.hook} failed on ${r.event}${what}: ${r.reason}`;
395
+ }
396
+ }
397
+
398
+ /** For `foldrun check`: every problem with the hooks/ folder of one root. */
399
+ export function checkHooksDir(hooksDir: string, scope: HookDef["scope"]): string[] {
400
+ const problems: string[] = [];
401
+ for (const dir of listHookDirs(hooksDir)) {
402
+ const def = readHookDef(dir, scope);
403
+ if ("error" in def) problems.push(def.error);
404
+ }
405
+ return problems;
406
+ }
package/src/host-env.ts CHANGED
@@ -54,3 +54,30 @@ export function hostSafeEnv(from: NodeJS.ProcessEnv = process.env): NodeJS.Proce
54
54
  // every spawn. The value is the same either way.
55
55
  return out as NodeJS.ProcessEnv;
56
56
  }
57
+
58
+ /**
59
+ * Where this install's own API answers, for a step to call: `FOLDRUN_API_URL`
60
+ * when the operator set it, else `FOLDRUN_PUBLIC_URL` + `/api`. Undefined on a
61
+ * laptop with neither.
62
+ *
63
+ * A desk that reads its own run history used to write the host into its
64
+ * files (`https://dev.foldrun.io/api`), so the same workspace pointed at the
65
+ * wrong server the day it moved. Now a script reads `$FOLDRUN_API_URL` and an
66
+ * `apis:` entry writes `base: ${FOLDRUN_API_URL}`.
67
+ */
68
+ export function platformApiUrl(from: NodeJS.ProcessEnv = process.env): string | undefined {
69
+ const explicit = from.FOLDRUN_API_URL?.trim();
70
+ if (explicit) return explicit.replace(/\/+$/, "");
71
+ const origin = from.FOLDRUN_PUBLIC_URL?.trim();
72
+ return origin ? `${origin.replace(/\/+$/, "")}/api` : undefined;
73
+ }
74
+
75
+ /** The install's own addresses an `apis:` base may name. Not secrets: they
76
+ * are printed in the prompt as the tool's base URL. */
77
+ export function expandPlatformVars(value: string, from: NodeJS.ProcessEnv = process.env): string {
78
+ const vars: Record<string, string | undefined> = {
79
+ FOLDRUN_API_URL: platformApiUrl(from),
80
+ FOLDRUN_PUBLIC_URL: from.FOLDRUN_PUBLIC_URL?.trim().replace(/\/+$/, "") || undefined,
81
+ };
82
+ return value.replace(/\$\{(FOLDRUN_API_URL|FOLDRUN_PUBLIC_URL)\}/g, (whole, name: string) => vars[name] ?? whole);
83
+ }
@@ -0,0 +1,143 @@
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
+
16
+ import fs from "node:fs";
17
+ import net from "node:net";
18
+ import os from "node:os";
19
+ import path from "node:path";
20
+ import { spawn } from "node:child_process";
21
+ import { DEB_PACKAGE, systemInstallSh, summarizeInstallFailure } from "./runtime.ts";
22
+
23
+ export interface InstallRequest {
24
+ manager: "apt";
25
+ packages: string[];
26
+ }
27
+
28
+ export interface InstallReply {
29
+ ok: boolean;
30
+ /** "snapshot" | "apt" */
31
+ mode?: string;
32
+ seconds?: number;
33
+ error?: string;
34
+ }
35
+
36
+ /** Validate one request; null when it may run. Exported for the tests. */
37
+ export function refuse(req: unknown): string | null {
38
+ const r = req as Partial<InstallRequest> | null;
39
+ if (!r || typeof r !== "object") return "not a request";
40
+ if (r.manager !== "apt") return "this helper installs Debian packages (manager: apt) only";
41
+ if (!Array.isArray(r.packages) || r.packages.length === 0) return "no packages named";
42
+ if (r.packages.length > 50) return "at most 50 packages per request";
43
+ const bad = r.packages.filter((p) => typeof p !== "string" || !DEB_PACKAGE.test(p));
44
+ if (bad.length) return `not Debian package names: ${bad.map(String).join(", ")}`;
45
+ return null;
46
+ }
47
+
48
+ /** Install one validated request with the start-up's own snippet. */
49
+ export function installApt(packages: string[], cacheDir: string): Promise<InstallReply> {
50
+ const logDir = fs.mkdtempSync(path.join(os.tmpdir(), "foldrun-install-"));
51
+ return new Promise((resolve) => {
52
+ const child = spawn("sh", ["-c", systemInstallSh(cacheDir, logDir)], {
53
+ env: { ...process.env, FOLDRUN_SYSTEM_PACKAGES: packages.join(" ") },
54
+ stdio: "ignore",
55
+ });
56
+ child.on("close", () => {
57
+ let status: { ok?: boolean; mode?: string; seconds?: number } = {};
58
+ try {
59
+ status = JSON.parse(fs.readFileSync(path.join(logDir, "system-install.json"), "utf8"));
60
+ } catch {
61
+ status = { ok: false };
62
+ }
63
+ let log = "";
64
+ try {
65
+ log = fs.readFileSync(path.join(logDir, "system-install.log"), "utf8");
66
+ } catch {
67
+ log = "";
68
+ }
69
+ fs.rmSync(logDir, { recursive: true, force: true });
70
+ resolve(
71
+ status.ok
72
+ ? { ok: true, mode: status.mode, seconds: status.seconds }
73
+ : { ok: false, error: summarizeInstallFailure("apt-get install", log || "no output") },
74
+ );
75
+ });
76
+ child.on("error", (err) => {
77
+ fs.rmSync(logDir, { recursive: true, force: true });
78
+ resolve({ ok: false, error: `could not run the installer: ${err.message}` });
79
+ });
80
+ });
81
+ }
82
+
83
+ export function serve(socket: string, cacheDir: string): net.Server {
84
+ try {
85
+ fs.rmSync(socket, { force: true });
86
+ } catch {
87
+ /* nothing there */
88
+ }
89
+ let queue: Promise<unknown> = Promise.resolve();
90
+ const server = net.createServer((conn) => {
91
+ let buf = "";
92
+ conn.on("data", (chunk) => {
93
+ buf += chunk.toString("utf8");
94
+ if (buf.length > 64_000) {
95
+ conn.end(JSON.stringify({ ok: false, error: "request too large" }) + "\n");
96
+ return;
97
+ }
98
+ const nl = buf.indexOf("\n");
99
+ if (nl < 0) return;
100
+ const line = buf.slice(0, nl);
101
+ buf = "";
102
+ let req: unknown;
103
+ try {
104
+ req = JSON.parse(line);
105
+ } catch {
106
+ conn.end(JSON.stringify({ ok: false, error: "not JSON" }) + "\n");
107
+ return;
108
+ }
109
+ const why = refuse(req);
110
+ if (why) {
111
+ conn.end(JSON.stringify({ ok: false, error: why }) + "\n");
112
+ return;
113
+ }
114
+ // One install at a time: apt holds a lock, and two dpkg runs in one
115
+ // container would fight over it.
116
+ queue = queue.then(async () => {
117
+ const reply = await installApt((req as InstallRequest).packages, cacheDir);
118
+ conn.end(JSON.stringify(reply) + "\n");
119
+ });
120
+ });
121
+ conn.on("error", () => {
122
+ /* the client went away */
123
+ });
124
+ });
125
+ server.listen(socket, () => {
126
+ // The agent user must be able to connect; nothing else runs in here.
127
+ try {
128
+ fs.chmodSync(socket, 0o666);
129
+ } catch {
130
+ /* best effort */
131
+ }
132
+ });
133
+ return server;
134
+ }
135
+
136
+ if (process.argv[1] && /install-helper\.(js|ts|mjs)$/.test(process.argv[1])) {
137
+ const [socket, cacheDir] = process.argv.slice(2);
138
+ if (!socket || !cacheDir) {
139
+ console.error("usage: install-helper <socket> <runtime cache dir>");
140
+ process.exit(2);
141
+ }
142
+ serve(socket, cacheDir);
143
+ }