@foldrun/core 2.4.0 → 2.6.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.
- package/dist/src/agent-activity.d.ts +5 -0
- package/dist/src/agent-activity.js +31 -0
- package/dist/src/agent-import.js +2 -2
- package/dist/src/agent-tools.js +2 -2
- package/dist/src/agents-md.d.ts +5 -1
- package/dist/src/agents-md.js +42 -18
- package/dist/src/alerts.d.ts +60 -0
- package/dist/src/alerts.js +161 -0
- package/dist/src/browser-pod.js +4 -0
- package/dist/src/capture.d.ts +23 -0
- package/dist/src/capture.js +80 -0
- package/dist/src/compare.d.ts +57 -0
- package/dist/src/compare.js +116 -0
- package/dist/src/completions.js +26 -3
- package/dist/src/deploy.d.ts +36 -1
- package/dist/src/deploy.js +237 -13
- package/dist/src/egress.d.ts +16 -1
- package/dist/src/evals.d.ts +27 -2
- package/dist/src/evals.js +62 -19
- package/dist/src/flow-lint.d.ts +14 -0
- package/dist/src/flow-lint.js +35 -0
- package/dist/src/flow-patterns.js +29 -14
- package/dist/src/frontmatter-fields.d.ts +27 -0
- package/dist/src/frontmatter-fields.js +60 -3
- package/dist/src/frontmatter-guard.d.ts +19 -0
- package/dist/src/frontmatter-guard.js +67 -0
- package/dist/src/frontmatter-schema.d.ts +2 -2
- package/dist/src/frontmatter-schema.js +33 -2
- package/dist/src/frontmatter-text.d.ts +31 -0
- package/dist/src/frontmatter-text.js +102 -0
- package/dist/src/history.d.ts +4 -0
- package/dist/src/history.js +15 -0
- package/dist/src/hooks.js +1 -1
- package/dist/src/insights.d.ts +30 -0
- package/dist/src/insights.js +152 -0
- package/dist/src/issues.d.ts +140 -0
- package/dist/src/issues.js +302 -0
- package/dist/src/judge-verdict.d.ts +19 -0
- package/dist/src/judge-verdict.js +54 -0
- package/dist/src/library.js +1 -1
- package/dist/src/limits.d.ts +8 -0
- package/dist/src/limits.js +36 -5
- package/dist/src/linkable.js +1 -1
- package/dist/src/matter.d.ts +42 -0
- package/dist/src/matter.js +132 -0
- package/dist/src/model-credential.d.ts +14 -0
- package/dist/src/model-credential.js +40 -0
- package/dist/src/network.d.ts +12 -1
- package/dist/src/network.js +35 -8
- package/dist/src/notify.d.ts +16 -0
- package/dist/src/notify.js +17 -7
- package/dist/src/okf.js +1 -1
- package/dist/src/online-checks.d.ts +76 -0
- package/dist/src/online-checks.js +432 -0
- package/dist/src/operator.d.ts +277 -0
- package/dist/src/operator.js +176 -2
- package/dist/src/otel-config.d.ts +18 -0
- package/dist/src/otel-config.js +69 -0
- package/dist/src/otel.d.ts +38 -0
- package/dist/src/otel.js +85 -0
- package/dist/src/outcome.d.ts +40 -0
- package/dist/src/outcome.js +77 -0
- package/dist/src/package.js +9 -2
- package/dist/src/platform.d.ts +1 -1
- package/dist/src/prompt-layers.d.ts +30 -0
- package/dist/src/prompt-layers.js +77 -0
- package/dist/src/provider-health.js +2 -2
- package/dist/src/providers.d.ts +5 -0
- package/dist/src/providers.js +2 -2
- package/dist/src/run-container.d.ts +25 -2
- package/dist/src/run-container.js +61 -6
- package/dist/src/runner.d.ts +53 -2
- package/dist/src/runner.js +471 -102
- package/dist/src/runs-filter.d.ts +48 -0
- package/dist/src/runs-filter.js +73 -0
- package/dist/src/scan.d.ts +30 -1
- package/dist/src/scan.js +145 -14
- package/dist/src/starter.js +27 -2
- package/dist/src/step-exec.d.ts +39 -3
- package/dist/src/step-exec.js +118 -15
- package/dist/src/store.d.ts +144 -3
- package/dist/src/store.js +150 -7
- package/dist/src/tokens.d.ts +38 -0
- package/dist/src/tokens.js +57 -0
- package/dist/src/tool-names.js +1 -1
- package/dist/src/tool-programs.js +1 -1
- package/dist/src/trace-tree.d.ts +40 -0
- package/dist/src/trace-tree.js +156 -0
- package/dist/src/translator.d.ts +8 -0
- package/dist/src/translator.js +7 -2
- package/dist/src/trigger-log.d.ts +1 -1
- package/dist/src/workspace-check.d.ts +18 -0
- package/dist/src/workspace-check.js +34 -0
- package/package.json +2 -2
- package/src/agent-activity.ts +32 -0
- package/src/agent-import.ts +2 -2
- package/src/agent-tools.ts +2 -2
- package/src/agents-md.ts +39 -14
- package/src/alerts.ts +190 -0
- package/src/browser-pod.ts +3 -0
- package/src/capture.ts +88 -0
- package/src/compare.ts +146 -0
- package/src/completions.ts +27 -3
- package/src/deploy.ts +238 -12
- package/src/egress.ts +12 -2
- package/src/evals.ts +64 -19
- package/src/flow-lint.ts +36 -0
- package/src/flow-patterns.ts +26 -13
- package/src/frontmatter-fields.ts +83 -3
- package/src/frontmatter-guard.ts +70 -0
- package/src/frontmatter-schema.ts +33 -4
- package/src/frontmatter-text.ts +109 -0
- package/src/history.ts +13 -0
- package/src/hooks.ts +1 -1
- package/src/insights.ts +174 -0
- package/src/issues.ts +396 -0
- package/src/judge-verdict.ts +52 -0
- package/src/library.ts +1 -1
- package/src/limits.ts +38 -5
- package/src/linkable.ts +1 -1
- package/src/matter.ts +145 -0
- package/src/model-credential.ts +45 -0
- package/src/network.ts +35 -7
- package/src/notify.ts +22 -7
- package/src/okf.ts +1 -1
- package/src/online-checks.ts +450 -0
- package/src/operator.ts +213 -3
- package/src/otel-config.ts +67 -0
- package/src/otel.ts +100 -0
- package/src/outcome.ts +90 -0
- package/src/package.ts +8 -2
- package/src/platform.ts +1 -1
- package/src/prompt-layers.ts +90 -0
- package/src/provider-health.ts +2 -2
- package/src/providers.ts +7 -2
- package/src/run-container.ts +77 -8
- package/src/runner.ts +477 -79
- package/src/runs-filter.ts +88 -0
- package/src/scan.ts +153 -14
- package/src/starter.ts +27 -2
- package/src/step-exec.ts +168 -17
- package/src/store.ts +268 -9
- package/src/tokens.ts +68 -0
- package/src/tool-names.ts +1 -1
- package/src/tool-programs.ts +1 -1
- package/src/trace-tree.ts +187 -0
- package/src/translator.ts +13 -2
- package/src/trigger-log.ts +1 -0
- package/src/workspace-check.ts +41 -0
package/src/compare.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Two runs of one flow side by side (FOL-72 step 6): "why did this get
|
|
2
|
+
// worse" read off the records — which files changed between the revisions
|
|
3
|
+
// they ran, and step by step what differs: status, the instruction, the
|
|
4
|
+
// model, time, tokens, model calls, the tools it used and the errors it hit.
|
|
5
|
+
// Pure; the caller hands in the changed files (history.ts changedPathsBetween).
|
|
6
|
+
|
|
7
|
+
import type { RunRecord, StepRecord } from "./store.ts";
|
|
8
|
+
|
|
9
|
+
export interface StepSide {
|
|
10
|
+
status: string;
|
|
11
|
+
instruction: string;
|
|
12
|
+
model?: string;
|
|
13
|
+
secs: number | null;
|
|
14
|
+
tokensIn: number;
|
|
15
|
+
tokensOut: number;
|
|
16
|
+
modelCalls: number;
|
|
17
|
+
/** Tool name → how many calls. */
|
|
18
|
+
tools: Record<string, number>;
|
|
19
|
+
toolErrors: number;
|
|
20
|
+
/** The first errors it wrote, each one line. */
|
|
21
|
+
errors: string[];
|
|
22
|
+
/** The first line of what it concluded. */
|
|
23
|
+
said: string | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface StepDiff {
|
|
27
|
+
/** 1-based, as the trace numbers steps. */
|
|
28
|
+
step: number;
|
|
29
|
+
agent: string;
|
|
30
|
+
base: StepSide | null;
|
|
31
|
+
run: StepSide | null;
|
|
32
|
+
/** What differs, in words, most telling first; [] when nothing does. */
|
|
33
|
+
changes: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface RunComparison {
|
|
37
|
+
base: { id: string; status: string; startedAt: string; revision: string | null };
|
|
38
|
+
run: { id: string; status: string; startedAt: string; revision: string | null };
|
|
39
|
+
revisionChanged: boolean;
|
|
40
|
+
/** Files changed between the two revisions; null when that cannot be said. */
|
|
41
|
+
changedFiles: string[] | null;
|
|
42
|
+
steps: StepDiff[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const firstLine = (s: string | null | undefined, n = 200) => {
|
|
46
|
+
const l = (s ?? "").split("\n").map((x) => x.trim()).find(Boolean);
|
|
47
|
+
return l ? l.slice(0, n) : null;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
function side(s: StepRecord): StepSide {
|
|
51
|
+
const tools: Record<string, number> = {};
|
|
52
|
+
let toolErrors = 0;
|
|
53
|
+
for (const e of s.events) {
|
|
54
|
+
if (e.type !== "tool") continue;
|
|
55
|
+
// A pair is one call: count the call, and its completion's error.
|
|
56
|
+
if (e.ms == null) tools[e.text] = (tools[e.text] ?? 0) + 1;
|
|
57
|
+
else if (e.err) toolErrors++;
|
|
58
|
+
}
|
|
59
|
+
const start = s.startedAt ? Date.parse(s.startedAt) : NaN;
|
|
60
|
+
const end = s.finishedAt ? Date.parse(s.finishedAt) : NaN;
|
|
61
|
+
return {
|
|
62
|
+
status: s.status,
|
|
63
|
+
instruction: s.instruction,
|
|
64
|
+
...(s.model ? { model: s.model } : {}),
|
|
65
|
+
secs: Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, Math.round((end - start) / 1000)) : null,
|
|
66
|
+
tokensIn: s.tokens?.input ?? 0,
|
|
67
|
+
tokensOut: s.tokens?.output ?? 0,
|
|
68
|
+
modelCalls: s.calls?.length ?? s.turns ?? 0,
|
|
69
|
+
tools,
|
|
70
|
+
toolErrors,
|
|
71
|
+
errors: s.events.filter((e) => e.type === "error").slice(0, 3).map((e) => firstLine(e.text) ?? ""),
|
|
72
|
+
said: firstLine(s.conclusion ?? s.result),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const k = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
|
|
77
|
+
|
|
78
|
+
/** Much more or much less: by a quarter and by a real amount. */
|
|
79
|
+
const moved = (a: number, b: number, floor: number) => Math.abs(b - a) >= floor && (a === 0 ? b > 0 : Math.abs(b - a) / a >= 0.25);
|
|
80
|
+
|
|
81
|
+
function changes(a: StepSide | null, b: StepSide | null): string[] {
|
|
82
|
+
if (!a && b) return ["new step: not in the base run"];
|
|
83
|
+
if (a && !b) return ["not in this run"];
|
|
84
|
+
if (!a || !b) return [];
|
|
85
|
+
const out: string[] = [];
|
|
86
|
+
if (a.status !== b.status) out.push(`status: ${a.status} → ${b.status}`);
|
|
87
|
+
const newErrors = b.errors.filter((e) => !a.errors.includes(e));
|
|
88
|
+
if (newErrors.length) out.push(`new error: ${newErrors[0]}`);
|
|
89
|
+
if (a.instruction.trim() !== b.instruction.trim()) out.push("instruction changed");
|
|
90
|
+
if ((a.model ?? "") !== (b.model ?? "")) out.push(`model: ${a.model ?? "default"} → ${b.model ?? "default"}`);
|
|
91
|
+
if (b.toolErrors > a.toolErrors) out.push(`tool errors: ${a.toolErrors} → ${b.toolErrors}`);
|
|
92
|
+
const names = new Set([...Object.keys(a.tools), ...Object.keys(b.tools)]);
|
|
93
|
+
const toolDiff = [...names]
|
|
94
|
+
.map((n) => ({ n, d: (b.tools[n] ?? 0) - (a.tools[n] ?? 0) }))
|
|
95
|
+
.filter((x) => x.d !== 0)
|
|
96
|
+
.sort((x, y) => Math.abs(y.d) - Math.abs(x.d))
|
|
97
|
+
.slice(0, 4)
|
|
98
|
+
.map((x) => `${x.d > 0 ? "+" : "−"}${x.n} ×${Math.abs(x.d)}`);
|
|
99
|
+
if (toolDiff.length) out.push(`tools: ${toolDiff.join(", ")}`);
|
|
100
|
+
if (a.secs !== null && b.secs !== null && moved(a.secs, b.secs, 10)) out.push(`took ${a.secs}s → ${b.secs}s`);
|
|
101
|
+
if (moved(a.tokensIn, b.tokensIn, 5000)) out.push(`tokens in ${k(a.tokensIn)} → ${k(b.tokensIn)}`);
|
|
102
|
+
if (moved(a.modelCalls, b.modelCalls, 3)) out.push(`model calls ${a.modelCalls} → ${b.modelCalls}`);
|
|
103
|
+
if (a.said !== b.said && b.said) out.push(`said: ${b.said}`);
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Steps lined up: the flow's own steps by position where the agents agree,
|
|
108
|
+
* fan-out instances by agent and order. */
|
|
109
|
+
function align(a: StepRecord[], b: StepRecord[]): [StepRecord | null, StepRecord | null][] {
|
|
110
|
+
const pairs: [StepRecord | null, StepRecord | null][] = [];
|
|
111
|
+
const used = new Set<number>();
|
|
112
|
+
b.forEach((s, i) => {
|
|
113
|
+
let j = a[i] && a[i]!.agent === s.agent && !used.has(i) ? i : -1;
|
|
114
|
+
if (j < 0) j = a.findIndex((x, m) => !used.has(m) && x.agent === s.agent);
|
|
115
|
+
if (j >= 0) used.add(j);
|
|
116
|
+
pairs.push([j >= 0 ? a[j]! : null, s]);
|
|
117
|
+
});
|
|
118
|
+
a.forEach((s, m) => !used.has(m) && pairs.push([s, null]));
|
|
119
|
+
return pairs;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function compareRuns(base: RunRecord, run: RunRecord, changedFiles: string[] | null = null): RunComparison {
|
|
123
|
+
const head = (r: RunRecord) => ({ id: r.id, status: r.status, startedAt: r.startedAt, revision: r.revision ?? null });
|
|
124
|
+
return {
|
|
125
|
+
base: head(base),
|
|
126
|
+
run: head(run),
|
|
127
|
+
revisionChanged: (base.revision ?? null) !== (run.revision ?? null),
|
|
128
|
+
changedFiles,
|
|
129
|
+
steps: align(base.steps, run.steps).map(([a, b], i) => {
|
|
130
|
+
const sa = a ? side(a) : null;
|
|
131
|
+
const sb = b ? side(b) : null;
|
|
132
|
+
return { step: b ? run.steps.indexOf(b) + 1 : i + 1, agent: (b ?? a)!.agent, base: sa, run: sb, changes: changes(sa, sb) };
|
|
133
|
+
}),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The run to compare a run with when none is named: the newest earlier run
|
|
138
|
+
* of the same flow that finished well (completed, not BLOCKED or BAD), or
|
|
139
|
+
* null. `outcomeOf` is the caller's, so this stays free of the verdict reader. */
|
|
140
|
+
export function lastGoodBefore<R extends { id: string; flow: string; startedAt: string }>(runs: R[], run: { id: string; flow: string; startedAt: string }, good: (r: R) => boolean): R | null {
|
|
141
|
+
return (
|
|
142
|
+
runs
|
|
143
|
+
.filter((r) => r.flow === run.flow && r.id !== run.id && r.startedAt < run.startedAt && good(r))
|
|
144
|
+
.sort((a, b) => b.startedAt.localeCompare(a.startedAt))[0] ?? null
|
|
145
|
+
);
|
|
146
|
+
}
|
package/src/completions.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// renders them.
|
|
11
11
|
|
|
12
12
|
import { PROVIDERS } from "./providers.ts";
|
|
13
|
-
import { FRONTMATTER, type SchemaKind } from "./frontmatter-fields.ts";
|
|
13
|
+
import { FRONTMATTER, JOBS, fieldsOfJob, type SchemaKind } from "./frontmatter-fields.ts";
|
|
14
14
|
|
|
15
15
|
export interface Vocabulary {
|
|
16
16
|
agents: string[];
|
|
@@ -77,6 +77,7 @@ const TOOL_GROUPS: Completion[] = [
|
|
|
77
77
|
{ label: "code", hint: "Bash — runs anything in the sandbox" },
|
|
78
78
|
{ label: "web", hint: "search, fetch, browse, crawl, map, extract, answer, monitor — providers under web:" },
|
|
79
79
|
{ label: "ask", hint: "ask_person — ask the person running the desk mid-step, and wait for the answer" },
|
|
80
|
+
{ label: "peers", hint: "peers_list + peers_message — the colleagues running beside it in its parallel group, and a message to one of them" },
|
|
80
81
|
{ label: "install", hint: "install apt, pip or npm packages mid-step — every agent has it; disallowedTools: [install] removes it" },
|
|
81
82
|
];
|
|
82
83
|
|
|
@@ -659,8 +660,31 @@ export function completionsAt(
|
|
|
659
660
|
}
|
|
660
661
|
}
|
|
661
662
|
|
|
662
|
-
//
|
|
663
|
-
|
|
663
|
+
// 5b. An indented line inside one of the four job blocks (governance,
|
|
664
|
+
// guardrail, observability, evaluation): that job's fields for this
|
|
665
|
+
// kind of file — the block takes nothing else.
|
|
666
|
+
const fmClose = text.indexOf("\n---", text.indexOf("---") + 3);
|
|
667
|
+
const insideFrontmatter = text.startsWith("---") && (fmClose === -1 || cursor <= fmClose);
|
|
668
|
+
const nested = line.match(/^(\s+)([a-zA-Z_]*)$/);
|
|
669
|
+
if (insideFrontmatter && nested) {
|
|
670
|
+
const header = before.slice(0, lineStart).split("\n").reverse().find((l) => /^\S/.test(l));
|
|
671
|
+
const job = JOBS.find((j) => new RegExp(`^${j.name}\\s*:\\s*$`).test(header ?? ""));
|
|
672
|
+
if (job) {
|
|
673
|
+
const schemaKind = /(^|\/)AGENTS\.md$/.test(path) || path === "project.md" ? "agents-md" : SCHEMA_OF[kind];
|
|
674
|
+
const keys = new Set(schemaKind ? fieldsOfJob(schemaKind, job.name) : []);
|
|
675
|
+
const partial = nested[2] ?? "";
|
|
676
|
+
return {
|
|
677
|
+
from: cursor - partial.length,
|
|
678
|
+
query: partial,
|
|
679
|
+
items: filter(fieldsFor(path, kind).filter((c) => keys.has(c.label.split(".")[0]!)), partial),
|
|
680
|
+
title: `${job.title} — ${job.what}`,
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// 6. An indented option under a flow step — the body's steps, never a
|
|
686
|
+
// line inside the frontmatter.
|
|
687
|
+
if (kind === "flow" && !insideFrontmatter && /^\s+[a-z]*$/.test(line) && line.trim().length >= 0) {
|
|
664
688
|
const partial = line.trim();
|
|
665
689
|
return {
|
|
666
690
|
from: cursor - partial.length,
|
package/src/deploy.ts
CHANGED
|
@@ -33,20 +33,29 @@ import {
|
|
|
33
33
|
assertSafeName,
|
|
34
34
|
type DeployFile,
|
|
35
35
|
notifyWorkspaceChanged,
|
|
36
|
+
accountDir,
|
|
37
|
+
frontmatterErrorText,
|
|
36
38
|
} from "./store.ts";
|
|
37
|
-
import {
|
|
39
|
+
import { readAgentsMdKeys } from "./agents-md.ts";
|
|
40
|
+
import { libraryTools } from "./library.ts";
|
|
41
|
+
import { lintFlow, regexProblem } from "./flow-lint.ts";
|
|
42
|
+
import { parseEval } from "./evals.ts";
|
|
38
43
|
import { refNames } from "./refs.ts";
|
|
39
|
-
import matter from "
|
|
44
|
+
import matter, { configMatter } from "./matter.ts";
|
|
45
|
+
import { frontmatterProblems, schemaKindOf } from "./frontmatter-schema.ts";
|
|
40
46
|
import { conformanceIssues } from "./okf.ts";
|
|
41
47
|
import { timezoneProblem } from "./clock.ts";
|
|
42
48
|
import { webProblems, webWarnings, runnerEngines } from "./providers.ts";
|
|
43
49
|
import { readLimits } from "./limits.ts";
|
|
50
|
+
import { parseAlerts } from "./alerts.ts";
|
|
51
|
+
import { parseTelemetry } from "./otel-config.ts";
|
|
52
|
+
import { parseJudge } from "./judge-verdict.ts";
|
|
44
53
|
import { networkProblems } from "./network.ts";
|
|
45
54
|
import { actionProblems } from "./web-actions.ts";
|
|
46
55
|
import { languageProblem } from "./language.ts";
|
|
47
56
|
import { localeProblems } from "./locale.ts";
|
|
48
57
|
import { entriesNoFollow } from "./paths.ts";
|
|
49
|
-
import { filterIgnored, findingFingerprint, ruleForWhere, scanFiles, type ScanFinding, type ScanOptions } from "./scan.ts";
|
|
58
|
+
import { filterIgnored, findingFingerprint, ruleForWhere, scanFiles, scanRule, STRICT_FRONTMATTER_PATH, type ScanFinding, type ScanOptions } from "./scan.ts";
|
|
50
59
|
|
|
51
60
|
/** Directories a deploy never reads out of a source tree. */
|
|
52
61
|
// `.cache` is what a run's tools leave in an agent's folder (HOME is that
|
|
@@ -121,9 +130,60 @@ export interface DeployResult extends DeployPlan {
|
|
|
121
130
|
// agree on what counts as an agent.
|
|
122
131
|
export const SUBAGENT_IMPORT_DIRS = [".agents/agents", ".claude/agents"] as const;
|
|
123
132
|
|
|
124
|
-
|
|
133
|
+
/** What agents write as they run, not what anyone authored: state/ and
|
|
134
|
+
* memory/, the workspace's or one agent's. */
|
|
135
|
+
const DATA_PATH = /^(?:agents\/[^/]+\/)?(?:state|memory)\//;
|
|
136
|
+
|
|
137
|
+
/** Bounds for a reader that only looks at the text (the checks, the
|
|
138
|
+
* assistant): on 20 Oct 2026 the daily rescan read one desk's 1.2 GB of
|
|
139
|
+
* state/ into a 2 GB heap and crash-looped the worker. A deploy sets none. */
|
|
140
|
+
export interface ReadTreeOptions {
|
|
141
|
+
/** Data (DATA_PATH) larger than this is left out, unread. */
|
|
142
|
+
maxDataFileBytes?: number;
|
|
143
|
+
/** Data past this many bytes in all, or this many files, is left out. */
|
|
144
|
+
maxDataBytes?: number;
|
|
145
|
+
maxDataFiles?: number;
|
|
146
|
+
/** Everything else past this many bytes in all: readTree throws
|
|
147
|
+
* TreeTooLarge rather than return a part of the definitions, which a
|
|
148
|
+
* check would read as missing agents and flows. */
|
|
149
|
+
maxSourceBytes?: number;
|
|
150
|
+
/** Each data file left out, and its size. */
|
|
151
|
+
onSkip?: (path: string, bytes: number) => void;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export class TreeTooLarge extends Error {
|
|
155
|
+
/** What an HTTP route answers with it. */
|
|
156
|
+
readonly status = 413;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function readTree(dir: string, opts: ReadTreeOptions = {}): DeployFile[] {
|
|
125
160
|
const root = path.resolve(dir);
|
|
126
161
|
const out: DeployFile[] = [];
|
|
162
|
+
let dataBytes = 0;
|
|
163
|
+
let dataFiles = 0;
|
|
164
|
+
let sourceBytes = 0;
|
|
165
|
+
/** May this file be read? Data over its budget is left out; source over
|
|
166
|
+
* its budget ends the read. */
|
|
167
|
+
const admit = (rel: string, bytes: number): boolean => {
|
|
168
|
+
if (DATA_PATH.test(rel)) {
|
|
169
|
+
const over =
|
|
170
|
+
(opts.maxDataFileBytes !== undefined && bytes > opts.maxDataFileBytes) ||
|
|
171
|
+
(opts.maxDataBytes !== undefined && dataBytes + bytes > opts.maxDataBytes) ||
|
|
172
|
+
(opts.maxDataFiles !== undefined && dataFiles >= opts.maxDataFiles);
|
|
173
|
+
if (over) {
|
|
174
|
+
opts.onSkip?.(rel, bytes);
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
dataBytes += bytes;
|
|
178
|
+
dataFiles++;
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
sourceBytes += bytes;
|
|
182
|
+
if (opts.maxSourceBytes !== undefined && sourceBytes > opts.maxSourceBytes) {
|
|
183
|
+
throw new TreeTooLarge(`the workspace's files other than state/ and memory/ are over ${Math.round(opts.maxSourceBytes / 1048576)} MiB — not read`);
|
|
184
|
+
}
|
|
185
|
+
return true;
|
|
186
|
+
};
|
|
127
187
|
|
|
128
188
|
const walk = (abs: string, rel: string) => {
|
|
129
189
|
let entries: string[];
|
|
@@ -148,6 +208,7 @@ export function readTree(dir: string): DeployFile[] {
|
|
|
148
208
|
if (stat.isDirectory()) {
|
|
149
209
|
walk(full, next);
|
|
150
210
|
} else if (next === "AGENTS.md" || next === "project.md" || IN_WORKSPACE_DIR.test(next)) {
|
|
211
|
+
if (!admit(next, stat.size)) continue;
|
|
151
212
|
out.push({ path: next, content: fs.readFileSync(full, "utf8") });
|
|
152
213
|
}
|
|
153
214
|
}
|
|
@@ -188,9 +249,12 @@ export function readTree(dir: string): DeployFile[] {
|
|
|
188
249
|
if (nativeAgent.has(target) || out.some((fEntry) => fEntry.path === target)) continue;
|
|
189
250
|
const full = path.join(root, importDir, entry);
|
|
190
251
|
try {
|
|
191
|
-
|
|
252
|
+
const st = fs.lstatSync(full);
|
|
253
|
+
if (st.isSymbolicLink()) continue;
|
|
254
|
+
if (!admit(target, st.size)) continue;
|
|
192
255
|
out.push({ path: target, content: fs.readFileSync(full, "utf8") });
|
|
193
|
-
} catch {
|
|
256
|
+
} catch (err) {
|
|
257
|
+
if (err instanceof TreeTooLarge) throw err;
|
|
194
258
|
// an unreadable import is skipped, not fatal
|
|
195
259
|
}
|
|
196
260
|
}
|
|
@@ -238,6 +302,90 @@ export function unopenedFrontmatter(raw: string): string | null {
|
|
|
238
302
|
* advice (an agent that could leak, an unpinned package) is for `foldrun
|
|
239
303
|
* check` and the Checks page, not for every deploy's output.
|
|
240
304
|
*/
|
|
305
|
+
/**
|
|
306
|
+
* What `deploy_block:` in AGENTS.md refuses (FOL-58). `none`, the default:
|
|
307
|
+
* the scanner's findings are warnings and the deploy goes ahead, as it always
|
|
308
|
+
* did. `secrets`: a key or password written into a file is refused.
|
|
309
|
+
* `security`: every security finding is — secrets, hidden or injected text,
|
|
310
|
+
* the lethal trifecta, pipe-to-shell, an unpinned MCP server, a step that
|
|
311
|
+
* acts outside with no check or approval. A
|
|
312
|
+
* `foldrun-ignore` with a reason still accepts one.
|
|
313
|
+
*/
|
|
314
|
+
export const DEPLOY_BLOCK_MODES = ["none", "secrets", "security"] as const;
|
|
315
|
+
export type DeployBlockMode = (typeof DEPLOY_BLOCK_MODES)[number];
|
|
316
|
+
|
|
317
|
+
const blockMode = (v: unknown): DeployBlockMode | null =>
|
|
318
|
+
typeof v === "string" && (DEPLOY_BLOCK_MODES as readonly string[]).includes(v.trim()) ? (v.trim() as DeployBlockMode) : null;
|
|
319
|
+
|
|
320
|
+
/** The workspace's own AGENTS.md, else the account's, else none. */
|
|
321
|
+
export function deployBlockMode(files: DeployFile[], accountKeys: Record<string, unknown>): DeployBlockMode {
|
|
322
|
+
// project.md is the older name readAgentsMdKeys still reads (Codex review).
|
|
323
|
+
const own = files.find((f) => f.path === "AGENTS.md") ?? files.find((f) => f.path === "project.md");
|
|
324
|
+
let fromWorkspace: DeployBlockMode | null = null;
|
|
325
|
+
if (own) {
|
|
326
|
+
try {
|
|
327
|
+
fromWorkspace = blockMode((configMatter(own.content).data as Record<string, unknown>).deploy_block);
|
|
328
|
+
} catch {
|
|
329
|
+
// an AGENTS.md that does not parse is reported elsewhere
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return fromWorkspace ?? blockMode(accountKeys.deploy_block) ?? "none";
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function blockersFor(tenant: string, given: DeployFile[]): DeployIssue[] {
|
|
336
|
+
// The tree as it will be written: `./AGENTS.md` is saved as AGENTS.md, so
|
|
337
|
+
// it is read as that here too (Codex review).
|
|
338
|
+
// Two entries for one path: the save keeps the last, so this does too.
|
|
339
|
+
const byPath = new Map<string, DeployFile>();
|
|
340
|
+
for (const f of given) {
|
|
341
|
+
// path.normalize, as saveWorkspace writes it (a backslash is a filename
|
|
342
|
+
// character here, not a separator — Codex review).
|
|
343
|
+
const p = path.normalize(f.path);
|
|
344
|
+
byPath.set(p, { ...f, path: p });
|
|
345
|
+
}
|
|
346
|
+
const files = [...byPath.values()];
|
|
347
|
+
const account = readAgentsMdKeys(accountDir(tenant));
|
|
348
|
+
const mode = deployBlockMode(files, account);
|
|
349
|
+
if (mode === "none") return [];
|
|
350
|
+
// Nearest wins: a workspace tool of the same name replaces the library's,
|
|
351
|
+
// and the scanner adds the workspace's own outward tools itself. Named as
|
|
352
|
+
// the runtime names them — the definition's name:, else the file's.
|
|
353
|
+
const ownTools = new Set<string>();
|
|
354
|
+
for (const f of files) {
|
|
355
|
+
const m = /^tools\/([^/]+?)(?:\/tool)?\.md$/.exec(f.path);
|
|
356
|
+
if (!m) continue;
|
|
357
|
+
let data: Record<string, unknown> = {};
|
|
358
|
+
try {
|
|
359
|
+
data = matter(f.content).data as Record<string, unknown>;
|
|
360
|
+
} catch {
|
|
361
|
+
continue; // unreadable: the runtime skips it and keeps the library's
|
|
362
|
+
}
|
|
363
|
+
// Only a definition the runtime accepts replaces the library's; an
|
|
364
|
+
// invalid one is skipped there, and the library tool stays (Codex review).
|
|
365
|
+
const def = parseToolDef(data, m[1], f.content);
|
|
366
|
+
if (def) ownTools.add(def.name ?? m[1]);
|
|
367
|
+
}
|
|
368
|
+
const outwardTools = Object.entries(libraryTools(tenant))
|
|
369
|
+
.filter(([n, d]) => (d as { outward?: boolean }).outward === true && !ownTools.has(n))
|
|
370
|
+
.map(([n]) => n);
|
|
371
|
+
return deployBlockers(files, mode, { accountNetwork: account.network, outwardTools });
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** The findings `mode` turns into refusals, as deploy issues. */
|
|
375
|
+
export function deployBlockers(files: DeployFile[], mode: DeployBlockMode, options: ScanOptions = {}): DeployIssue[] {
|
|
376
|
+
if (mode === "none") return [];
|
|
377
|
+
// Every finding checkFiles reports — the scanner's and the structural
|
|
378
|
+
// ones, flow/outward-ungated among them (Codex review) — with the same
|
|
379
|
+
// ignores honoured, kept by its rule's category.
|
|
380
|
+
const categories = new Set(mode === "secrets" ? ["secret"] : ["secret", "content", "security", "supply-chain"]);
|
|
381
|
+
return checkFiles(files, options)
|
|
382
|
+
.filter((f) => f.level !== "info" && categories.has(scanRule(f.rule)?.category ?? ""))
|
|
383
|
+
.map((f) => ({
|
|
384
|
+
where: `${f.file}${f.line ? `:${f.line}` : ""}`,
|
|
385
|
+
message: `${f.message} [${f.rule}] — refused: deploy_block: ${mode} in AGENTS.md`,
|
|
386
|
+
}));
|
|
387
|
+
}
|
|
388
|
+
|
|
241
389
|
export function deployWarnings(files: DeployFile[]): DeployIssue[] {
|
|
242
390
|
const scanned = scanFiles(files, { only: ["secret", "content"] })
|
|
243
391
|
.filter((f) => f.level !== "info")
|
|
@@ -261,19 +409,20 @@ export function checkFiles(files: DeployFile[], options: ScanOptions = {}): Scan
|
|
|
261
409
|
};
|
|
262
410
|
const own = [
|
|
263
411
|
...deployIssues(files).map((i) => finding(i, "error")),
|
|
264
|
-
...structuralWarnings(files).map((i) => finding(i, "warn", i.kind)),
|
|
412
|
+
...structuralWarnings(files, options.outwardTools).map((i) => finding(i, "warn", i.kind)),
|
|
265
413
|
];
|
|
266
414
|
return [...filterIgnored(own, files), ...scanFiles(files, options)].sort(
|
|
267
415
|
(a, b) => a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0) || a.rule.localeCompare(b.rule),
|
|
268
416
|
);
|
|
269
417
|
}
|
|
270
418
|
|
|
271
|
-
function structuralWarnings(files: DeployFile[]): (DeployIssue & { kind?: string })[] {
|
|
419
|
+
function structuralWarnings(files: DeployFile[], extraOutward: Iterable<string> = []): (DeployIssue & { kind?: string })[] {
|
|
272
420
|
const front = (content: string): Record<string, unknown> => {
|
|
273
421
|
try { return matter(content).data as Record<string, unknown>; } catch { return {}; }
|
|
274
422
|
};
|
|
275
|
-
//
|
|
276
|
-
|
|
423
|
+
// Tools that change something outside the workspace: the workspace's own,
|
|
424
|
+
// and the library's the caller resolved (nearest wins already applied).
|
|
425
|
+
const outwardTools = new Set<string>(extraOutward);
|
|
277
426
|
for (const f of files) {
|
|
278
427
|
const m = f.path.match(/^tools\/([^/]+?)(?:\/tool)?\.md$/);
|
|
279
428
|
if (!m) continue;
|
|
@@ -282,6 +431,8 @@ function structuralWarnings(files: DeployFile[]): (DeployIssue & { kind?: string
|
|
|
282
431
|
// `outward: true` is read off the frontmatter, the same test the runtime
|
|
283
432
|
// and the CLI use; parseToolDef does not fold it onto the def.
|
|
284
433
|
if (def && data.outward === true) outwardTools.add(def.name ?? m[1]);
|
|
434
|
+
// A workspace tool that is not outward replaces a library one that was.
|
|
435
|
+
else if (def) outwardTools.delete(def.name ?? m[1]);
|
|
285
436
|
}
|
|
286
437
|
// Agents that grant one of them.
|
|
287
438
|
const outwardAgents: string[] = [];
|
|
@@ -302,6 +453,32 @@ function structuralWarnings(files: DeployFile[]): (DeployIssue & { kind?: string
|
|
|
302
453
|
if (refNames(data.tools).some((t: string) => outwardTools.has(t))) outwardAgents.push(m[1]);
|
|
303
454
|
for (const w of webWarnings(data, { chrome, chromium })) out.push({ where: f.path, message: w });
|
|
304
455
|
}
|
|
456
|
+
// An eval's `matches:` is the same JavaScript RegExp as a verify's; one
|
|
457
|
+
// that does not compile fails its case on every run. Read through
|
|
458
|
+
// parseEval, so a block scalar (`matches: |`) or a continued line is
|
|
459
|
+
// tested as the runtime will run it. The line is the item's own: the
|
|
460
|
+
// parsed assertions and the `- matches:` lines come in the same order.
|
|
461
|
+
for (const f of files) {
|
|
462
|
+
if (!/^evals\/[^/]+\.md$/.test(f.path)) continue;
|
|
463
|
+
let info;
|
|
464
|
+
try { info = parseEval(f.path, f.content); } catch { continue; }
|
|
465
|
+
const lines: number[] = [];
|
|
466
|
+
f.content.split("\n").forEach((line, n) => { if (/^\s*-\s*matches:/.test(line)) lines.push(n + 1); });
|
|
467
|
+
// When a `- matches:` line sits outside a case (an example in the
|
|
468
|
+
// prose), the order no longer pairs up: name the file, not a wrong line.
|
|
469
|
+
const parsed = info.cases.reduce((n, c) => n + c.expect.filter((a) => a.type === "matches").length, 0);
|
|
470
|
+
const pairs = lines.length === parsed;
|
|
471
|
+
let k = 0;
|
|
472
|
+
for (const c of info.cases) {
|
|
473
|
+
for (const a of c.expect) {
|
|
474
|
+
if (a.type !== "matches") continue;
|
|
475
|
+
const at = pairs ? lines[k] : undefined;
|
|
476
|
+
k++;
|
|
477
|
+
const problem = regexProblem(a.value);
|
|
478
|
+
if (problem) out.push({ where: at ? `${f.path}:${at}` : f.path, message: `case "${c.name}": matches: ${problem}` });
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
305
482
|
if (!outwardAgents.length) return out;
|
|
306
483
|
for (const f of files) {
|
|
307
484
|
if (!/^flows\/[^/]+\.md$/.test(f.path)) continue;
|
|
@@ -342,6 +519,45 @@ export function deployIssues(files: DeployFile[]): DeployIssue[] {
|
|
|
342
519
|
if (m) agents.add(m[1]);
|
|
343
520
|
}
|
|
344
521
|
|
|
522
|
+
// Frontmatter whose YAML does not parse is a file the runtime cannot read:
|
|
523
|
+
// an agent with no tools and the default model, a flow that never fires,
|
|
524
|
+
// a tool that is skipped. Refused where it was written. Until 20 Oct 2026
|
|
525
|
+
// nothing said so — gray-matter's cache let a second parse pass — and one
|
|
526
|
+
// such agent file kept the worker from booting for every account.
|
|
527
|
+
// Definition files only: a skill is read leniently (an unquoted colon is
|
|
528
|
+
// quoted and retried, matter.ts parseLenient), and the markdown beside a
|
|
529
|
+
// definition — references, a tool's README — is prose, not frontmatter.
|
|
530
|
+
// The scanner warns about skills and library files (config/invalid-frontmatter).
|
|
531
|
+
for (const f of files) {
|
|
532
|
+
if (!STRICT_FRONTMATTER_PATH.test(f.path)) continue;
|
|
533
|
+
if (!/^\uFEFF?---\r?\n/.test(f.content)) continue;
|
|
534
|
+
try {
|
|
535
|
+
matter(f.content);
|
|
536
|
+
} catch (err) {
|
|
537
|
+
at(f.path, frontmatterErrorText(err));
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// The four job blocks (frontmatter-fields.ts JOB_FIELDS): a setting
|
|
542
|
+
// written both at the top and in its block, or in another job's block, is
|
|
543
|
+
// a setting that does not do what the file says — a guardrail under
|
|
544
|
+
// governance: guards nothing — so the deploy refuses it as check does
|
|
545
|
+
// (config/conflict, config/misplaced; Codex debate, 21 Oct 2026).
|
|
546
|
+
for (const f of files) {
|
|
547
|
+
const kind = schemaKindOf(f.path);
|
|
548
|
+
if (kind !== "agent" && kind !== "flow" && kind !== "agents-md") continue;
|
|
549
|
+
if (!/^\uFEFF?---\r?\n/.test(f.content)) continue;
|
|
550
|
+
let front: Record<string, unknown>;
|
|
551
|
+
try {
|
|
552
|
+
front = configMatter(f.content).data as Record<string, unknown>;
|
|
553
|
+
} catch {
|
|
554
|
+
continue; // unparseable frontmatter is its own report
|
|
555
|
+
}
|
|
556
|
+
for (const p of frontmatterProblems(kind, front, () => null)) {
|
|
557
|
+
if (p.level === "error" && (p.rule === "config/conflict" || p.rule === "config/misplaced")) at(f.path, `${p.message} [${p.rule}]`);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
345
561
|
// `timezone:` is read at four levels and a value nothing can parse falls
|
|
346
562
|
// silently through to the level above — so it is refused where it was
|
|
347
563
|
// written, not discovered as a date that is a day out.
|
|
@@ -351,7 +567,7 @@ export function deployIssues(files: DeployFile[]): DeployIssue[] {
|
|
|
351
567
|
if (unopened) at(f.path, unopened);
|
|
352
568
|
let front: Record<string, unknown> = {};
|
|
353
569
|
try {
|
|
354
|
-
front =
|
|
570
|
+
front = configMatter(f.content).data as Record<string, unknown>;
|
|
355
571
|
} catch {
|
|
356
572
|
continue; // unparseable frontmatter is its own report
|
|
357
573
|
}
|
|
@@ -370,6 +586,13 @@ export function deployIssues(files: DeployFile[]): DeployIssue[] {
|
|
|
370
586
|
// `network:` that cannot be read would leave the step on the level
|
|
371
587
|
// above it — wider or narrower than the author wrote.
|
|
372
588
|
for (const w of networkProblems(front.network)) at(f.path, w);
|
|
589
|
+
// `alerts:` that cannot be read would never alert (FOL-72).
|
|
590
|
+
for (const w of parseAlerts(front.alerts).problems) at(f.path, w);
|
|
591
|
+
// `telemetry:` that cannot be read would never send — and a token
|
|
592
|
+
// written into it would ship in the file.
|
|
593
|
+
for (const w of parseTelemetry(front.telemetry).problems) at(f.path, w);
|
|
594
|
+
// `judge:` that cannot be read would leave every judge on the default.
|
|
595
|
+
for (const w of parseJudge(front.judge).problems) at(f.path, w);
|
|
373
596
|
const lang = languageProblem(front.language);
|
|
374
597
|
if (lang) at(f.path, lang);
|
|
375
598
|
for (const w of localeProblems(front)) at(f.path, w);
|
|
@@ -509,7 +732,10 @@ export function planDeploy(tenant: string, workspace: string, files: DeployFile[
|
|
|
509
732
|
added: added.sort(),
|
|
510
733
|
updated: updated.sort(),
|
|
511
734
|
removed: removed.sort(),
|
|
512
|
-
|
|
735
|
+
// deploy_block: (FOL-58) turns the scanner's findings into refusals —
|
|
736
|
+
// scanned with what the Checks page passes too: the account's network:
|
|
737
|
+
// and the library's outward tools (Codex review).
|
|
738
|
+
issues: [...deployIssues(files), ...blockersFor(tenant, files)],
|
|
513
739
|
warnings: deployWarnings(files),
|
|
514
740
|
blockedBy: runsInFlight(tenant, workspace),
|
|
515
741
|
};
|
package/src/egress.ts
CHANGED
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
// platform's (it needs a long-lived process and a Service); the pod side is
|
|
44
44
|
// here in core, because it is what the runner image runs.
|
|
45
45
|
|
|
46
|
+
import type { PeerInfo } from "./operator.ts";
|
|
46
47
|
import { trimSlashes } from "./paths.ts";
|
|
47
48
|
import type { TestEffect } from "./test-mode.ts";
|
|
48
49
|
import type { NetworkLevel } from "./network.ts";
|
|
@@ -150,13 +151,22 @@ export type EgressLogLine = string | { text: string; effect: TestEffect };
|
|
|
150
151
|
* means "no proxy here" and the runner falls back to materialising, exactly
|
|
151
152
|
* as it always did.
|
|
152
153
|
*/
|
|
154
|
+
|
|
153
155
|
export interface EgressHooks {
|
|
154
156
|
/** `test` — the run is a test run: the proxy applies the outward-write
|
|
155
157
|
* policy in test-mode.ts to every request on this lease. Carried on the
|
|
156
158
|
* lease, not the request, because the pod is the party not trusted to
|
|
157
159
|
* say so. */
|
|
158
|
-
/** `step` — the step's index in run.steps; keys that step's operator inbox.
|
|
159
|
-
|
|
160
|
+
/** `step` — the step's index in run.steps; keys that step's operator inbox.
|
|
161
|
+
* `agent` and `peers` — this step's agent and the other steps of its
|
|
162
|
+
* parallel group (tools: [peers]): the proxy serves the roster and
|
|
163
|
+
* addresses a colleague's message to the right inbox. */
|
|
164
|
+
lease(args: { tenant: string; runId: string; grant: EgressGrant; test?: boolean; step?: number; agent?: string; peers?: PeerInfo[] }): Promise<EgressLease | null>;
|
|
165
|
+
/** The step is over — every attempt, however it ended (tools: [peers]).
|
|
166
|
+
* A lease comes and goes per attempt, so it cannot say this: a sibling
|
|
167
|
+
* between retries, or waiting for a slot, holds none and is not done.
|
|
168
|
+
* Optional: a platform without it keeps every sibling reachable. */
|
|
169
|
+
stepEnded?(args: { tenant: string; runId: string; step: number }): Promise<void>;
|
|
160
170
|
}
|
|
161
171
|
|
|
162
172
|
/** Strip a header set of the proxy's own and hop-by-hop headers. */
|