@foldrun/core 0.3.0 → 0.4.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 (83) hide show
  1. package/README.md +27 -10
  2. package/dist/src/approvals.d.ts +104 -1
  3. package/dist/src/approvals.js +192 -2
  4. package/dist/src/budget.d.ts +32 -0
  5. package/dist/src/budget.js +177 -0
  6. package/dist/src/catalog.js +2 -1
  7. package/dist/src/confine.js +6 -1
  8. package/dist/src/egress.d.ts +19 -2
  9. package/dist/src/egress.js +2 -1
  10. package/dist/src/evals.d.ts +4 -0
  11. package/dist/src/evals.js +20 -11
  12. package/dist/src/flow-lint.d.ts +4 -0
  13. package/dist/src/flow-lint.js +108 -0
  14. package/dist/src/host-env.d.ts +8 -0
  15. package/dist/src/host-env.js +48 -0
  16. package/dist/src/json-schema.d.ts +12 -0
  17. package/dist/src/json-schema.js +202 -0
  18. package/dist/src/library.js +13 -1
  19. package/dist/src/notify.d.ts +79 -1
  20. package/dist/src/notify.js +243 -9
  21. package/dist/src/paths.d.ts +5 -0
  22. package/dist/src/paths.js +32 -0
  23. package/dist/src/platform.d.ts +102 -2
  24. package/dist/src/platform.js +18 -2
  25. package/dist/src/provider-health.d.ts +55 -0
  26. package/dist/src/provider-health.js +232 -0
  27. package/dist/src/run-container.d.ts +57 -4
  28. package/dist/src/run-container.js +147 -41
  29. package/dist/src/runner.d.ts +39 -3
  30. package/dist/src/runner.js +422 -73
  31. package/dist/src/script-tools.d.ts +12 -0
  32. package/dist/src/script-tools.js +10 -2
  33. package/dist/src/secret-health.d.ts +59 -0
  34. package/dist/src/secret-health.js +155 -0
  35. package/dist/src/secrets.d.ts +19 -1
  36. package/dist/src/secrets.js +44 -12
  37. package/dist/src/starter.js +3 -0
  38. package/dist/src/step-exec.d.ts +47 -1
  39. package/dist/src/step-exec.js +155 -60
  40. package/dist/src/store.d.ts +276 -3
  41. package/dist/src/store.js +552 -26
  42. package/dist/src/tar.js +2 -1
  43. package/dist/src/test-mode.d.ts +111 -0
  44. package/dist/src/test-mode.js +285 -0
  45. package/dist/src/tool-test.js +48 -21
  46. package/dist/src/translator.js +2 -1
  47. package/dist/src/trigger-log.d.ts +43 -0
  48. package/dist/src/trigger-log.js +122 -0
  49. package/dist/src/triggers.d.ts +20 -0
  50. package/dist/src/triggers.js +91 -10
  51. package/dist/src/webhook.d.ts +15 -3
  52. package/dist/src/webhook.js +11 -11
  53. package/package.json +4 -4
  54. package/src/approvals.ts +253 -2
  55. package/src/budget.ts +178 -0
  56. package/src/catalog.ts +2 -1
  57. package/src/confine.ts +6 -1
  58. package/src/egress.ts +20 -4
  59. package/src/evals.ts +25 -9
  60. package/src/flow-lint.ts +118 -0
  61. package/src/host-env.ts +51 -0
  62. package/src/json-schema.ts +184 -0
  63. package/src/library.ts +13 -1
  64. package/src/notify.ts +272 -11
  65. package/src/paths.ts +29 -0
  66. package/src/platform.ts +107 -3
  67. package/src/provider-health.ts +274 -0
  68. package/src/run-container.ts +175 -36
  69. package/src/runner.ts +435 -65
  70. package/src/script-tools.ts +18 -2
  71. package/src/secret-health.ts +185 -0
  72. package/src/secrets.ts +44 -10
  73. package/src/starter.ts +3 -0
  74. package/src/step-exec.ts +128 -13
  75. package/src/storage.ts +8 -2
  76. package/src/store.ts +746 -25
  77. package/src/tar.ts +2 -1
  78. package/src/test-mode.ts +311 -0
  79. package/src/tool-test.ts +49 -20
  80. package/src/translator.ts +2 -1
  81. package/src/trigger-log.ts +159 -0
  82. package/src/triggers.ts +92 -10
  83. package/src/webhook.ts +17 -11
@@ -1,3 +1,4 @@
1
+ import type { TestEffect } from "./test-mode.ts";
1
2
  /** `${NAME}` — the shape a secret reference takes everywhere in foldrun. */
2
3
  export declare const PLACEHOLDER: RegExp;
3
4
  /** The env var the pod reads to know it should send through a proxy. */
@@ -30,6 +31,11 @@ export interface EgressGrant {
30
31
  value: string;
31
32
  hosts: string[];
32
33
  }>;
34
+ /** Secret name → the vault entry it came from, as secret-health keys it:
35
+ * "account:NAME" or "workspace:<ws>:NAME". Two workspaces each holding an
36
+ * API_KEY are two credentials, and one's refusal must not paint the
37
+ * other red. Absent for a secret whose origin the runner did not know. */
38
+ healthKeys?: Record<string, string>;
33
39
  }
34
40
  export interface EgressLease {
35
41
  /** The base the pod sends through: `http://…/e/<token>`. */
@@ -39,21 +45,32 @@ export interface EgressLease {
39
45
  * lease URL first), so a store that is not this process's memory has
40
46
  * to be told when the grant is complete. Call before every attempt. */
41
47
  commit(): Promise<void>;
42
- /** One line per request, for the run trace. Drained by the runner. */
43
- drainLog(): Promise<string[]>;
48
+ /** One line per request, for the run trace. Drained by the runner. A
49
+ * line may carry a test-mode effect beside its text — what the proxy
50
+ * refused or redirected on a test lease — for the run page to list. */
51
+ drainLog(): Promise<EgressLogLine[]>;
44
52
  /** The step is over: the lease is void and its values are dropped. */
45
53
  release(): Promise<void>;
46
54
  }
55
+ export type EgressLogLine = string | {
56
+ text: string;
57
+ effect: TestEffect;
58
+ };
47
59
  /**
48
60
  * The seam the platform fills. `null` from `lease` (or no `egress` at all)
49
61
  * means "no proxy here" and the runner falls back to materialising, exactly
50
62
  * as it always did.
51
63
  */
52
64
  export interface EgressHooks {
65
+ /** `test` — the run is a test run: the proxy applies the outward-write
66
+ * policy in test-mode.ts to every request on this lease. Carried on the
67
+ * lease, not the request, because the pod is the party not trusted to
68
+ * say so. */
53
69
  lease(args: {
54
70
  tenant: string;
55
71
  runId: string;
56
72
  grant: EgressGrant;
73
+ test?: boolean;
57
74
  }): Promise<EgressLease | null>;
58
75
  }
59
76
  /** Strip a header set of the proxy's own and hop-by-hop headers. */
@@ -34,6 +34,7 @@
34
34
  // This file is the vocabulary both sides share. The server is the
35
35
  // platform's (it needs a long-lived process and a Service); the pod side is
36
36
  // here in core, because it is what the runner image runs.
37
+ import { trimSlashes } from "./paths.js";
37
38
  /** `${NAME}` — the shape a secret reference takes everywhere in foldrun. */
38
39
  export const PLACEHOLDER = /\$\{([A-Z][A-Z0-9_]*)\}/g;
39
40
  /** The env var the pod reads to know it should send through a proxy. */
@@ -48,7 +49,7 @@ export const MODEL_KEY_NAME = "FOLDRUN_MODEL_KEY";
48
49
  export function viaEgress(egress, target) {
49
50
  if (!egress)
50
51
  return target;
51
- return `${egress.replace(/\/+$/, "")}/${target}`;
52
+ return `${trimSlashes(egress)}/${target}`;
52
53
  }
53
54
  /** The names referenced by placeholders in a string. */
54
55
  export function placeholderNames(text) {
@@ -38,6 +38,10 @@ export interface EvalInfo {
38
38
  * `manual`: only when a person presses Run or the CLI asks — for a flow
39
39
  * eval that costs a whole run and touches real systems every time. */
40
40
  trigger: "deploy" | "manual";
41
+ /** An eval's runs are test runs — nothing outward, state untouched —
42
+ * unless the file says `live: true`. A flow eval that must really send
43
+ * to prove itself opts in; nothing opts in by accident. */
44
+ live: boolean;
41
45
  cases: EvalCase[];
42
46
  }
43
47
  export declare function parseEval(file: string, raw: string): EvalInfo;
package/dist/src/evals.js CHANGED
@@ -39,7 +39,7 @@ import { spawn } from "node:child_process";
39
39
  import { query } from "@anthropic-ai/claude-agent-sdk";
40
40
  import { workspaceDir, readRun, writeWorkspaceFile, resolveModel, resolveEffort, assertSafeName, } from "./store.js";
41
41
  import { startFlowRun, loadFlow } from "./runner.js";
42
- import { platform } from "./platform.js";
42
+ import { platform, platformQueues } from "./platform.js";
43
43
  const ASSERTION_TYPES = ["contains", "not-contains", "matches", "file", "run", "judge"];
44
44
  /**
45
45
  * A scalar that may continue onto following lines.
@@ -138,6 +138,7 @@ export function parseEval(file, raw) {
138
138
  model: resolveModel(data.model ?? "fast"),
139
139
  effort: resolveEffort(data.effort ?? "low"),
140
140
  trigger: data.trigger === "manual" ? "manual" : "deploy",
141
+ live: data.live === true,
141
142
  cases,
142
143
  };
143
144
  }
@@ -226,7 +227,9 @@ export function promoteRunToEval(tenant, workspace, runId, opts = {}) {
226
227
  let expect;
227
228
  if (opts.expect && opts.expect.length > 0) {
228
229
  expect = opts.expect.map((line) => {
229
- const m = oneLine(line).match(/^([a-z-]+):\s*(.*)$/);
230
+ const m = oneLine(line).match(/^([a-z-]+):(.*)$/);
231
+ if (m)
232
+ m[2] = m[2].trim();
230
233
  if (!m || !ASSERTION_TYPES.includes(m[1]) || !m[2]) {
231
234
  throw refuse(`"${line}" is not an assertion — expected one of ${ASSERTION_TYPES.map((t) => `${t}:`).join(", ")} followed by a value`, 400);
232
235
  }
@@ -258,7 +261,7 @@ export function promoteRunToEval(tenant, workspace, runId, opts = {}) {
258
261
  if (points.kind !== target.kind || points.name !== target.name) {
259
262
  throw refuse(`${rel} tests ${points.kind} ${points.name ?? "(nothing)"}, not ${target.kind} ${target.name} — pick another evalName`, 409);
260
263
  }
261
- content = `${existing.replace(/\s+$/, "")}\n\n${caseText}`;
264
+ content = `${existing.trimEnd()}\n\n${caseText}`;
262
265
  }
263
266
  else {
264
267
  // A flow eval costs a whole run and touches real systems, so it waits
@@ -285,19 +288,23 @@ export function promoteRunToEval(tenant, workspace, runId, opts = {}) {
285
288
  });
286
289
  return { file: rel, created: !existing, caseName };
287
290
  }
288
- /** Wait for a run to finish, with a ceiling so a hung agent can't hang an eval. */
289
291
  /**
290
292
  * A web replica never drives a run: it has no route to the cluster API and
291
293
  * no right to create a pod, by design — so an eval started there (a push,
292
294
  * the Run button) queues its run for the worker like any other. From 29 Aug
293
295
  * every eval on the split platform died at "pod create failed: connection
294
296
  * refused" for exactly this reason. A single process — the CLI, a laptop,
295
- * FOLDRUN_ROLE unset — still drives it right here, as it always did.
297
+ * no platform registered — still drives it right here, as it always did.
298
+ *
299
+ * The test is whether a platform owns the queue, not which role this
300
+ * process was started as: on a worker the role read "worker", the eval
301
+ * started its run in-process, and the run never passed through the
302
+ * account cap or the lanes the platform enforces on every other run.
296
303
  */
297
- const runsViaQueue = () => process.env.FOLDRUN_ROLE === "web";
298
- async function beginEvalRun(tenant, workspace, steps, flowName, model, effort) {
304
+ const runsViaQueue = () => platformQueues();
305
+ async function beginEvalRun(tenant, workspace, steps, flowName, model, effort, test = true) {
299
306
  if (!runsViaQueue())
300
- return startFlowRun(tenant, workspace, steps, flowName, model, [], effort);
307
+ return startFlowRun(tenant, workspace, steps, flowName, model, [], effort, null, { test });
301
308
  // The job carries a model override but no effort one, and driveRun cannot
302
309
  // re-read a flow called `eval:<name>`. Put the flow's defaults onto the
303
310
  // steps that have none — which is what nearest-wins resolves to anyway.
@@ -306,7 +313,7 @@ async function beginEvalRun(tenant, workspace, steps, flowName, model, effort) {
306
313
  ...(s.model || !model ? {} : { model }),
307
314
  ...(s.effort || !effort ? {} : { effort }),
308
315
  }));
309
- return platform.enqueueFlowRun(tenant, workspace, withDefaults, flowName, model ?? null, []);
316
+ return platform.enqueueFlowRun(tenant, workspace, withDefaults, flowName, model ?? null, [], null, { test });
310
317
  }
311
318
  // Waits for as long as the run runs. There was a cap here (5 minutes, then
312
319
  // 30) and it marked a flow eval "run disappeared" at the cap while the flow
@@ -428,12 +435,14 @@ export async function runEval(tenant, workspace, info) {
428
435
  const steps = flow.steps.map((s, i) => i === 0
429
436
  ? { ...s, instruction: `${s.instruction}\n\n<run_task>\n${testCase.task}\n</run_task>` }
430
437
  : s);
431
- run = await beginEvalRun(tenant, workspace, steps, `eval:${info.name}`, flow.model, flow.effort);
438
+ // Test unless the eval OR the flow says live: an eval that runs a
439
+ // flow written `live: true` was written by someone who knew.
440
+ run = await beginEvalRun(tenant, workspace, steps, `eval:${info.name}`, flow.model, flow.effort, !(info.live || flow.live));
432
441
  }
433
442
  else {
434
443
  if (!target)
435
444
  throw new Error("this eval names neither an agent nor a flow");
436
- run = await beginEvalRun(tenant, workspace, [{ agent: target, instruction: testCase.task, group: 1, optional: false }], `eval:${info.name}`);
445
+ run = await beginEvalRun(tenant, workspace, [{ agent: target, instruction: testCase.task, group: 1, optional: false }], `eval:${info.name}`, undefined, undefined, !info.live);
437
446
  }
438
447
  runId = run.id;
439
448
  const finished = await waitForRun(tenant, workspace, run.id);
@@ -6,6 +6,10 @@ export interface FlowWarning {
6
6
  line?: number;
7
7
  message: string;
8
8
  detail: string;
9
+ /** "error" for a value the parser could not read — `timeout: abc` — which
10
+ * `foldrun check` must fail on rather than note. Absent means advisory,
11
+ * which every structural warning in this file is. */
12
+ level?: "error" | "warn";
9
13
  }
10
14
  /** What exists in the workspace, when the caller knows. Optional: the lint
11
15
  * is useful without it, and a caller that has the lists gets the reference
@@ -14,6 +14,18 @@
14
14
  // Deliberately conservative — a false warning is worse than a missed one,
15
15
  // because noisy warnings get ignored and then the real one is invisible too.
16
16
  const REFERS_BACK = /\b(both|above|earlier|previous(ly)?|prior|the (changes|draft|research|results|findings|report|article|fixes|work)|what (they|the others?) (found|shipped|wrote|said))\b/i;
17
+ /** One @, something either side, a dot in the domain, no whitespace. Written
18
+ * as string checks rather than a regex: the obvious pattern backtracks
19
+ * polynomially on a domain full of dots, and a lint that can be made slow
20
+ * by a flow file is a lint someone will turn off. */
21
+ function looksLikeAddress(a) {
22
+ const at = a.indexOf("@");
23
+ if (at < 1 || at !== a.lastIndexOf("@") || /\s/.test(a))
24
+ return false;
25
+ const domain = a.slice(at + 1);
26
+ const dot = domain.indexOf(".");
27
+ return dot > 0 && dot < domain.length - 1;
28
+ }
17
29
  export function lintFlow(flow, known) {
18
30
  const warnings = [];
19
31
  const agentNames = known ? new Set(known.agents) : null;
@@ -25,6 +37,68 @@ export function lintFlow(flow, known) {
25
37
  // eleven runs where one was meant. Nothing about the expression looks wrong,
26
38
  // which is exactly why it needs saying here rather than being discovered on
27
39
  // the bill.
40
+ // An approvers list only one word can satisfy. `approvers: [editors]` is
41
+ // a role that is not `admins` and not an address; mayApprove would then
42
+ // refuse every person, and the gate could only ever be answered by an
43
+ // emailed link. Said here rather than discovered at the gate.
44
+ const odd = (flow.approvers ?? []).filter((a) => a !== "admins" && !looksLikeAddress(a));
45
+ if (odd.length) {
46
+ warnings.push({
47
+ step: null,
48
+ message: `approvers: ${odd.join(", ")} — not an email address, and the only role word is admins`,
49
+ detail: "Each entry is an address that may answer this flow's gates, or the word `admins`. Anything else matches nobody, and a list nobody matches locks every gate.",
50
+ });
51
+ }
52
+ // A flow with an approvers list and no gate at all is a rule the author
53
+ // believes is in force and which does nothing — the worst kind of safety
54
+ // control.
55
+ if (flow.approvers?.length && !flow.steps.some((s) => s.approve || s.ask)) {
56
+ warnings.push({
57
+ step: null,
58
+ message: "approvers: is set but no step in this flow is a gate",
59
+ detail: "Nothing here waits for a person, so the list has no effect. Mark the step that needs " +
60
+ "a second pair of eyes with `!` (or `approve: true`), or drop the approvers: line.",
61
+ });
62
+ }
63
+ // A deadline on a gate that does not exist reads as a safety net and is not
64
+ // one. Same reasoning, said separately because the fix is different.
65
+ if (flow.approveWithin && !flow.steps.some((s) => s.approve || s.ask)) {
66
+ warnings.push({
67
+ step: null,
68
+ message: "approve_within: is set but no step in this flow is a gate",
69
+ detail: "Nothing waits for a person, so nothing can expire. Mark a step with `!`, or drop the line.",
70
+ });
71
+ }
72
+ // A throttle no schedule can ever trip: the flow cannot run more often
73
+ // than its cron fires, so a floor below that interval never fires either.
74
+ // Cheap to write, silently pointless, and a reader will believe it.
75
+ if (flow.throttle && flow.trigger === "schedule" && flow.schedule) {
76
+ warnings.push({
77
+ step: null,
78
+ message: `throttle: on a scheduled flow — check it is shorter than the schedule, or it will drop runs you meant to happen`,
79
+ detail: "A schedule already decides how often this runs. A throttle longer than the gap between " +
80
+ "fires silently skips them; if that is what you want, say it in the cron instead.",
81
+ });
82
+ }
83
+ // `idempotency:` only has a delivery to read on a trigger that receives
84
+ // one. On a schedule or a chained flow there is no request and no field,
85
+ // so the key is inert.
86
+ if (flow.idempotency && flow.trigger !== "webhook" && flow.trigger !== "email") {
87
+ warnings.push({
88
+ step: null,
89
+ message: `idempotency: has nothing to read on trigger: ${flow.trigger}`,
90
+ detail: "A delivery id comes off an incoming request, so this only does something for " +
91
+ "trigger: webhook or trigger: email. On any other trigger it is ignored.",
92
+ });
93
+ }
94
+ // `catchup:` is about a fire that was missed, which only a clock can miss.
95
+ if (flow.catchup && flow.trigger !== "schedule") {
96
+ warnings.push({
97
+ step: null,
98
+ message: `catchup: has no effect on trigger: ${flow.trigger}`,
99
+ detail: "Only a schedule can miss a fire. Every other trigger happens when it happens.",
100
+ });
101
+ }
28
102
  if (flow.schedule) {
29
103
  const [, , dom, , dow] = flow.schedule.trim().split(/\s+/);
30
104
  if (dom && dow && dom !== "*" && dow !== "*") {
@@ -39,6 +113,9 @@ export function lintFlow(flow, known) {
39
113
  }
40
114
  // Trigger shapes that can never fire, said at check time rather than
41
115
  // discovered as a flow that "never runs".
116
+ if (flow.budgetProblem) {
117
+ warnings.push({ step: null, message: flow.budgetProblem, detail: "A flow's `budget:` is the most one run may spend — a number in USD, or `unlimited`. A cap over a day, week or month belongs in the workspace's or the account's AGENTS.md, where it means every run put together." });
118
+ }
42
119
  if (flow.trigger === "once" && !flow.at) {
43
120
  warnings.push({ step: null, message: "trigger: once needs an `at:` instant", detail: "Write `at: 2026-09-05T09:00:00+10:00` (ISO 8601). Without a readable instant this flow never fires." });
44
121
  }
@@ -62,6 +139,20 @@ export function lintFlow(flow, known) {
62
139
  const firstGroup = Math.min(...flow.steps.map((s) => s.group));
63
140
  flow.steps.forEach((step, i) => {
64
141
  const inFirstGroup = step.group === firstGroup;
142
+ // A value the parser refused. Not advisory: the run would go ahead on
143
+ // a number the author never wrote — `timeout: 15m` ran as one second
144
+ // for as long as this was a coercion — so the check fails instead.
145
+ for (const problem of step.problems ?? []) {
146
+ warnings.push({
147
+ step: i,
148
+ line: step.line,
149
+ level: "error",
150
+ message: problem,
151
+ detail: "The option is written but its value cannot be read, and a run would not do what the " +
152
+ "line says. Durations are seconds or 90s / 30m / 4h / 3d; counts are whole numbers " +
153
+ "within the key's cap (retry and loop at most 5, max at most 20).",
154
+ });
155
+ }
65
156
  // The first group starts with context = null. An instruction that points
66
157
  // at earlier output therefore points at nothing.
67
158
  if (inFirstGroup && step.instruction && REFERS_BACK.test(step.instruction)) {
@@ -102,6 +193,23 @@ export function lintFlow(flow, known) {
102
193
  });
103
194
  }
104
195
  }
196
+ // Two options that read another option to mean anything.
197
+ if (step.parallel && !step.each) {
198
+ warnings.push({
199
+ step: i,
200
+ line: step.line,
201
+ message: `"${step.subflow ?? step.agent}" has parallel: but no each:`,
202
+ detail: "parallel: caps how many instances of a fan-out run at once. Without each: there is one instance, and the line does nothing.",
203
+ });
204
+ }
205
+ if ((step.schema !== undefined || step.schemaPath) && step.output !== "json") {
206
+ warnings.push({
207
+ step: i,
208
+ line: step.line,
209
+ message: `"${step.subflow ?? step.agent}" has schema: but no output: json`,
210
+ detail: "schema: describes the JSON value an output: json step returns. Without output: json nothing is parsed, so nothing is checked. Add `output: json`.",
211
+ });
212
+ }
105
213
  // A step's OTHER agent references — the ones no parser validates,
106
214
  // because they are options rather than the step's target. Both fail
107
215
  // silently and at the worst possible moment: a misspelled `delegate:`
@@ -0,0 +1,8 @@
1
+ /** Does this name pass the allowlist? Exported so a test can pin the rule
2
+ * rather than the list. */
3
+ export declare function hostEnvAllowed(name: string): boolean;
4
+ /**
5
+ * process.env, reduced to the allowlist. A fresh object every call, so a
6
+ * caller that layers secrets on top never writes into the host's own env.
7
+ */
8
+ export declare function hostSafeEnv(from?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
@@ -0,0 +1,48 @@
1
+ // The environment a step's children start from, on the in-process path.
2
+ //
3
+ // Scripts, `verify:` shells and the SDK's own Bash tool used to inherit the
4
+ // whole of process.env — which on a platform holds FOLDRUN_SECRET_KEY (the
5
+ // key every tenant's vault and hook token derives from), the database URL,
6
+ // the object-store and mail credentials, the Stripe key. `console.log(
7
+ // process.env)` in a script tool was cross-tenant compromise. tool-test.ts
8
+ // built a stripped base for the Test button; this is that base, made the one
9
+ // definition, used everywhere a child is spawned host-side.
10
+ //
11
+ // A run container needs none of this: its process.env IS the boundary,
12
+ // assembled from an allowlist on the host before the container started.
13
+ //
14
+ // What passes is what an interpreter needs to be found and to run, the
15
+ // locale, the clock, the network's proxy settings, and the identifiers the
16
+ // runner stamps on a step (FOLDRUN_RUN_*, FOLDRUN_STEP_*) so a script can
17
+ // say which run made its records. The secrets an agent declares are layered
18
+ // on top by the caller — by name, from the vault, never from the host.
19
+ const EXACT = new Set([
20
+ "PATH", "HOME", "TMPDIR", "TMP", "TEMP", "LANG", "LANGUAGE",
21
+ "TZ", "TERM", "SHELL", "USER", "LOGNAME",
22
+ // The host's way to the network is the child's way to the network.
23
+ "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy",
24
+ "SSL_CERT_FILE", "SSL_CERT_DIR",
25
+ // Windows equivalents, harmless elsewhere.
26
+ "SYSTEMROOT", "COMSPEC", "PATHEXT", "WINDIR",
27
+ ]);
28
+ const PREFIXES = ["LC_", "NODE_", "FOLDRUN_RUN_", "FOLDRUN_STEP_"];
29
+ /** Does this name pass the allowlist? Exported so a test can pin the rule
30
+ * rather than the list. */
31
+ export function hostEnvAllowed(name) {
32
+ return EXACT.has(name) || PREFIXES.some((p) => name.startsWith(p));
33
+ }
34
+ /**
35
+ * process.env, reduced to the allowlist. A fresh object every call, so a
36
+ * caller that layers secrets on top never writes into the host's own env.
37
+ */
38
+ export function hostSafeEnv(from = process.env) {
39
+ const out = {};
40
+ for (const [k, v] of Object.entries(from)) {
41
+ if (typeof v === "string" && hostEnvAllowed(k))
42
+ out[k] = v;
43
+ }
44
+ // Typed as ProcessEnv, not a string map: a host whose type augmentation
45
+ // makes a key required (Next declares NODE_ENV so) refuses a plain map at
46
+ // every spawn. The value is the same either way.
47
+ return out;
48
+ }
@@ -0,0 +1,12 @@
1
+ export interface SchemaError {
2
+ /** JSON pointer to the offending value: "" is the root, "/leads/0/url". */
3
+ path: string;
4
+ message: string;
5
+ }
6
+ /** Is `value` what `schema` describes? Empty means yes. */
7
+ export declare function validateSchema(value: unknown, schema: unknown): SchemaError[];
8
+ /** The errors as one sentence for a trace line. */
9
+ export declare function describeSchemaErrors(errors: SchemaError[]): string;
10
+ /** Is this a schema at all? An object (or a boolean, which JSON Schema
11
+ * allows) — not a string, not a list. */
12
+ export declare function looksLikeSchema(schema: unknown): boolean;
@@ -0,0 +1,202 @@
1
+ // A JSON Schema validator small enough to read, with no dependency.
2
+ //
3
+ // `schema:` beside `output: json` says what shape the reply's value must
4
+ // have, and a value that does not have it fails the step with a sentence
5
+ // naming the field. The subset here is the part of JSON Schema people write
6
+ // by hand for a handoff — types, required fields, enums, ranges, patterns,
7
+ // arrays, the combinators, and `$ref` to a definition in the same document.
8
+ // Formats (`date-time`, `email`) are accepted and not checked; a check that
9
+ // needs one belongs in a `verify:`. Anything the validator does not know is
10
+ // ignored, the way JSON Schema itself says unknown keywords are.
11
+ //
12
+ // Not ajv: it sits in node_modules as a dependency of a dependency, which is
13
+ // nothing to build on, and the whole of it is far more than a step needs.
14
+ const MAX_ERRORS = 8;
15
+ /** Is `value` what `schema` describes? Empty means yes. */
16
+ export function validateSchema(value, schema) {
17
+ const errors = [];
18
+ check(value, schema, "", schema, errors, 0);
19
+ return errors.slice(0, MAX_ERRORS);
20
+ }
21
+ /** The errors as one sentence for a trace line. */
22
+ export function describeSchemaErrors(errors) {
23
+ return errors.map((e) => `${e.path || "the value"}: ${e.message}`).join("; ");
24
+ }
25
+ /** Is this a schema at all? An object (or a boolean, which JSON Schema
26
+ * allows) — not a string, not a list. */
27
+ export function looksLikeSchema(schema) {
28
+ return typeof schema === "boolean" || (typeof schema === "object" && schema !== null && !Array.isArray(schema));
29
+ }
30
+ const typeOf = (v) => v === null ? "null" : Array.isArray(v) ? "array" : typeof v === "number" ? (Number.isInteger(v) ? "integer" : "number") : typeof v;
31
+ const matchesType = (v, t) => {
32
+ const actual = typeOf(v);
33
+ return t === actual || (t === "number" && actual === "integer");
34
+ };
35
+ function resolveRef(ref, root) {
36
+ if (!ref.startsWith("#"))
37
+ return null;
38
+ let node = root;
39
+ for (const raw of ref.slice(1).split("/").filter(Boolean)) {
40
+ const key = raw.replaceAll("~1", "/").replaceAll("~0", "~");
41
+ if (typeof node !== "object" || node === null)
42
+ return null;
43
+ node = node[key];
44
+ }
45
+ return typeof node === "object" && node !== null ? node : typeof node === "boolean" ? node : null;
46
+ }
47
+ function check(value, schema, path, root, errors, depth) {
48
+ if (errors.length >= MAX_ERRORS || depth > 64)
49
+ return;
50
+ if (schema === true)
51
+ return;
52
+ if (schema === false)
53
+ return void errors.push({ path, message: "nothing is allowed here" });
54
+ if (typeof schema !== "object" || schema === null)
55
+ return;
56
+ const s = schema;
57
+ const fail = (message) => errors.push({ path, message });
58
+ if (typeof s.$ref === "string") {
59
+ const target = resolveRef(s.$ref, root);
60
+ if (target === null)
61
+ return fail(`schema: unresolvable $ref ${s.$ref}`);
62
+ check(value, target, path, root, errors, depth + 1);
63
+ return;
64
+ }
65
+ // type — one, or a list of alternatives; "nullable: true" (OpenAPI's
66
+ // spelling) lets null through.
67
+ if (s.type !== undefined) {
68
+ const types = Array.isArray(s.type) ? s.type : [String(s.type)];
69
+ const ok = types.some((t) => matchesType(value, t)) || (s.nullable === true && value === null);
70
+ if (!ok)
71
+ return fail(`expected ${types.join(" or ")}, got ${typeOf(value)}`);
72
+ }
73
+ if (Array.isArray(s.enum) && !s.enum.some((e) => deepEqual(e, value))) {
74
+ return fail(`must be one of ${s.enum.map((e) => JSON.stringify(e)).join(", ")}`);
75
+ }
76
+ if ("const" in s && !deepEqual(s.const, value))
77
+ return fail(`must be ${JSON.stringify(s.const)}`);
78
+ if (typeof value === "string") {
79
+ if (typeof s.minLength === "number" && value.length < s.minLength)
80
+ fail(`shorter than ${s.minLength} characters`);
81
+ if (typeof s.maxLength === "number" && value.length > s.maxLength)
82
+ fail(`longer than ${s.maxLength} characters`);
83
+ if (typeof s.pattern === "string") {
84
+ try {
85
+ if (!new RegExp(s.pattern, "u").test(value))
86
+ fail(`does not match ${s.pattern}`);
87
+ }
88
+ catch {
89
+ fail(`schema: invalid pattern ${s.pattern}`);
90
+ }
91
+ }
92
+ }
93
+ if (typeof value === "number") {
94
+ if (typeof s.minimum === "number" && value < s.minimum)
95
+ fail(`below the minimum ${s.minimum}`);
96
+ if (typeof s.maximum === "number" && value > s.maximum)
97
+ fail(`above the maximum ${s.maximum}`);
98
+ if (typeof s.exclusiveMinimum === "number" && value <= s.exclusiveMinimum)
99
+ fail(`must be above ${s.exclusiveMinimum}`);
100
+ if (typeof s.exclusiveMaximum === "number" && value >= s.exclusiveMaximum)
101
+ fail(`must be below ${s.exclusiveMaximum}`);
102
+ if (typeof s.multipleOf === "number" && s.multipleOf > 0 && Math.abs(value / s.multipleOf - Math.round(value / s.multipleOf)) > 1e-9) {
103
+ fail(`not a multiple of ${s.multipleOf}`);
104
+ }
105
+ }
106
+ if (Array.isArray(value)) {
107
+ if (typeof s.minItems === "number" && value.length < s.minItems)
108
+ fail(`fewer than ${s.minItems} items`);
109
+ if (typeof s.maxItems === "number" && value.length > s.maxItems)
110
+ fail(`more than ${s.maxItems} items`);
111
+ if (s.uniqueItems === true) {
112
+ const seen = new Set(value.map((v) => JSON.stringify(v)));
113
+ if (seen.size !== value.length)
114
+ fail("items are not unique");
115
+ }
116
+ if (s.items !== undefined && !Array.isArray(s.items)) {
117
+ value.forEach((item, i) => check(item, s.items, `${path}/${i}`, root, errors, depth + 1));
118
+ }
119
+ else if (Array.isArray(s.items) || Array.isArray(s.prefixItems)) {
120
+ const tuple = (Array.isArray(s.prefixItems) ? s.prefixItems : s.items);
121
+ tuple.forEach((sub, i) => i < value.length && check(value[i], sub, `${path}/${i}`, root, errors, depth + 1));
122
+ }
123
+ }
124
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
125
+ const obj = value;
126
+ const props = (typeof s.properties === "object" && s.properties !== null ? s.properties : {});
127
+ if (Array.isArray(s.required)) {
128
+ for (const key of s.required)
129
+ if (!(key in obj))
130
+ fail(`missing required field "${key}"`);
131
+ }
132
+ for (const [key, sub] of Object.entries(props)) {
133
+ if (key in obj)
134
+ check(obj[key], sub, `${path}/${key.replaceAll("~", "~0").replaceAll("/", "~1")}`, root, errors, depth + 1);
135
+ }
136
+ const patterns = (typeof s.patternProperties === "object" && s.patternProperties !== null ? s.patternProperties : {});
137
+ for (const key of Object.keys(obj)) {
138
+ if (key in props)
139
+ continue;
140
+ const pattern = Object.entries(patterns).find(([re]) => {
141
+ try {
142
+ return new RegExp(re, "u").test(key);
143
+ }
144
+ catch {
145
+ return false;
146
+ }
147
+ });
148
+ if (pattern) {
149
+ check(obj[key], pattern[1], `${path}/${key}`, root, errors, depth + 1);
150
+ }
151
+ else if (s.additionalProperties === false) {
152
+ fail(`unexpected field "${key}"`);
153
+ }
154
+ else if (typeof s.additionalProperties === "object" && s.additionalProperties !== null) {
155
+ check(obj[key], s.additionalProperties, `${path}/${key}`, root, errors, depth + 1);
156
+ }
157
+ }
158
+ if (typeof s.minProperties === "number" && Object.keys(obj).length < s.minProperties)
159
+ fail(`fewer than ${s.minProperties} fields`);
160
+ if (typeof s.maxProperties === "number" && Object.keys(obj).length > s.maxProperties)
161
+ fail(`more than ${s.maxProperties} fields`);
162
+ }
163
+ if (Array.isArray(s.allOf)) {
164
+ for (const sub of s.allOf)
165
+ check(value, sub, path, root, errors, depth + 1);
166
+ }
167
+ if (Array.isArray(s.anyOf)) {
168
+ const ok = s.anyOf.some((sub) => validateSchema(value, withRoot(sub, root)).length === 0);
169
+ if (!ok)
170
+ fail("matches none of the allowed shapes (anyOf)");
171
+ }
172
+ if (Array.isArray(s.oneOf)) {
173
+ const n = s.oneOf.filter((sub) => validateSchema(value, withRoot(sub, root)).length === 0).length;
174
+ if (n !== 1)
175
+ fail(n === 0 ? "matches none of the allowed shapes (oneOf)" : `matches ${n} of the shapes, must match exactly one (oneOf)`);
176
+ }
177
+ if (s.not !== undefined && validateSchema(value, withRoot(s.not, root)).length === 0)
178
+ fail("matches a shape that is not allowed (not)");
179
+ }
180
+ /** A sub-schema checked on its own still resolves `$ref` against the
181
+ * document it came from: carry the root's definitions along. */
182
+ function withRoot(sub, root) {
183
+ if (typeof sub !== "object" || sub === null || typeof root !== "object" || root === null)
184
+ return sub;
185
+ const defs = {};
186
+ for (const key of ["$defs", "definitions"])
187
+ if (key in root)
188
+ defs[key] = root[key];
189
+ return { ...defs, ...sub };
190
+ }
191
+ function deepEqual(a, b) {
192
+ if (a === b)
193
+ return true;
194
+ if (typeof a !== typeof b || a === null || b === null || typeof a !== "object")
195
+ return false;
196
+ if (Array.isArray(a) !== Array.isArray(b))
197
+ return false;
198
+ const ka = Object.keys(a), kb = Object.keys(b);
199
+ if (ka.length !== kb.length)
200
+ return false;
201
+ return ka.every((k) => deepEqual(a[k], b[k]));
202
+ }
@@ -10,6 +10,7 @@
10
10
  // Resolution is nearest-wins, the same rule OKF uses for knowledge: an agent's
11
11
  // own file beats its workspace's, which beats the account's. So a team can set
12
12
  // a house style once and one agent can still override it.
13
+ import { platform } from "./platform.js";
13
14
  import fs from "node:fs";
14
15
  import path from "node:path";
15
16
  import { dataRoot, singleWorkspace } from "./paths.js";
@@ -213,7 +214,18 @@ export function deleteLibraryPath(tenant, kind, rel, meta = {}) {
213
214
  // ---------- consumed by the runner ----------
214
215
  /** Library tool definitions, keyed by name — http or script, both grantable by name in `tools:`. */
215
216
  export function libraryTools(tenant) {
216
- return readToolDir(libraryDir(tenant, "tools"), "account");
217
+ // The platform's gallery first, the account's own copies over it: a tool
218
+ // the platform ships is granted by name in every account without anyone
219
+ // installing it, and an account that installed (copied) one keeps its
220
+ // copy — that is what "install to customise" means. Both read at account
221
+ // scope, so a folder tool's `run:` resolves to /library/tools/<name>/…
222
+ // in the sandbox either way; the staging step lays the gallery down
223
+ // first and the account library over it for the same reason.
224
+ const gallery = platform.galleryDir();
225
+ return {
226
+ ...(gallery ? readToolDir(path.join(gallery, "tools"), "account") : {}),
227
+ ...readToolDir(libraryDir(tenant, "tools"), "account"),
228
+ };
217
229
  }
218
230
  /** Shared memory index — derived from the files, like every other scope. */
219
231
  export function libraryMemoryIndex(tenant) {