@foldrun/core 4.0.0 → 6.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.
- package/dist/src/bounded-match.d.ts +8 -0
- package/dist/src/bounded-match.js +80 -0
- package/dist/src/deploy.js +4 -0
- package/dist/src/evals.js +27 -5
- package/dist/src/mail.d.ts +2 -6
- package/dist/src/mail.js +50 -0
- package/dist/src/matter.js +18 -2
- package/dist/src/model-env.d.ts +12 -3
- package/dist/src/model-env.js +44 -6
- package/dist/src/platform.d.ts +24 -0
- package/dist/src/platform.js +35 -1
- package/dist/src/runner.d.ts +2 -0
- package/dist/src/runner.js +8 -3
- package/dist/src/tool-test.js +41 -1
- package/dist/src/translator.d.ts +8 -2
- package/dist/src/translator.js +73 -0
- package/dist/src/webhook.js +46 -6
- package/package.json +1 -1
- package/src/bounded-match.ts +70 -0
- package/src/deploy.ts +3 -0
- package/src/evals.ts +24 -5
- package/src/mail.ts +49 -0
- package/src/matter.ts +19 -2
- package/src/model-env.ts +42 -7
- package/src/platform.ts +45 -1
- package/src/runner.ts +7 -5
- package/src/tool-test.ts +40 -1
- package/src/translator.ts +83 -2
- package/src/webhook.ts +44 -5
package/dist/src/translator.js
CHANGED
|
@@ -893,6 +893,8 @@ export async function startTranslator(spec) {
|
|
|
893
893
|
req.on("end", () => resolve(body));
|
|
894
894
|
req.on("error", reject);
|
|
895
895
|
});
|
|
896
|
+
if (spec.wire === "anthropic")
|
|
897
|
+
return await passthrough(spec, base, label, req, url, raw, res, log, started);
|
|
896
898
|
if (req.method === "POST" && url.pathname === "/v1/messages/count_tokens") {
|
|
897
899
|
// No endpoint on the other side counts for us; four characters a
|
|
898
900
|
// token is the estimate every tokenizer lands near for English.
|
|
@@ -1058,6 +1060,77 @@ export async function startTranslator(spec) {
|
|
|
1058
1060
|
}),
|
|
1059
1061
|
};
|
|
1060
1062
|
}
|
|
1063
|
+
/** Request headers the SDK's own call carries that the endpoint is owed:
|
|
1064
|
+
* the content type, and Anthropic's version and beta flags. Its credential
|
|
1065
|
+
* (the translator's key) is not one of them. */
|
|
1066
|
+
const PASSED_HEADERS = /^(content-type|accept|anthropic-[a-z0-9-]+)$/;
|
|
1067
|
+
/** Response headers that describe the bytes as they came off the wire —
|
|
1068
|
+
* fetch has already decoded them, so they would lie on the way back. */
|
|
1069
|
+
const HOP_HEADERS = new Set(["content-encoding", "content-length", "transfer-encoding", "connection", "keep-alive"]);
|
|
1070
|
+
/**
|
|
1071
|
+
* The anthropic wire: the SDK's request, as it is, to the same path under the
|
|
1072
|
+
* tenant's base URL, with the tenant's key put on here — through
|
|
1073
|
+
* fetchUntrusted, so no private or cluster address, no redirect that takes
|
|
1074
|
+
* the key to another origin, and the answer read to UPSTREAM_MAX_BYTES. The
|
|
1075
|
+
* answer streams back as it arrives, status and headers included.
|
|
1076
|
+
*/
|
|
1077
|
+
async function passthrough(spec, base, label, req, url, raw, res, log, started) {
|
|
1078
|
+
// Lowercase keys, so a header named twice in different case is one header,
|
|
1079
|
+
// and the provider's own headers last: one that carries its credential
|
|
1080
|
+
// itself (an Authorization or x-api-key in `headers:`) wins over the key
|
|
1081
|
+
// slot, as on the Chat Completions wire above.
|
|
1082
|
+
const headers = {};
|
|
1083
|
+
for (const [k, v] of Object.entries(req.headers))
|
|
1084
|
+
if (PASSED_HEADERS.test(k) && typeof v === "string")
|
|
1085
|
+
headers[k] = v;
|
|
1086
|
+
if (spec.upstreamKey) {
|
|
1087
|
+
if (spec.upstreamAuth === "x-api-key")
|
|
1088
|
+
headers["x-api-key"] = spec.upstreamKey;
|
|
1089
|
+
else
|
|
1090
|
+
headers.authorization = `Bearer ${spec.upstreamKey}`;
|
|
1091
|
+
}
|
|
1092
|
+
for (const [k, v] of Object.entries(spec.headers ?? {}))
|
|
1093
|
+
headers[k.toLowerCase()] = v;
|
|
1094
|
+
const method = req.method ?? "GET";
|
|
1095
|
+
const target = `${base}${url.pathname}${url.search}`;
|
|
1096
|
+
let upstream;
|
|
1097
|
+
try {
|
|
1098
|
+
upstream = await fetchUntrusted(target, { method, headers, body: method === "GET" || method === "HEAD" ? undefined : raw }, { sameOrigin: true, maxBytes: UPSTREAM_MAX_BYTES });
|
|
1099
|
+
}
|
|
1100
|
+
catch (err) {
|
|
1101
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1102
|
+
log.push(`${method} ${label}${url.pathname} → unreachable: ${message}`);
|
|
1103
|
+
res.writeHead(502, { "content-type": "application/json" });
|
|
1104
|
+
res.end(errorBody(502, `translator: could not reach ${label}: ${message}`));
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
const out = {};
|
|
1108
|
+
upstream.headers.forEach((v, k) => {
|
|
1109
|
+
if (!HOP_HEADERS.has(k))
|
|
1110
|
+
out[k] = v;
|
|
1111
|
+
});
|
|
1112
|
+
res.writeHead(upstream.status, out);
|
|
1113
|
+
log.push(`${method} ${label}${url.pathname} → ${upstream.status} (${Date.now() - started}ms)`);
|
|
1114
|
+
const reader = upstream.body?.getReader();
|
|
1115
|
+
if (!reader)
|
|
1116
|
+
return void res.end();
|
|
1117
|
+
try {
|
|
1118
|
+
for (;;) {
|
|
1119
|
+
const { value, done } = await reader.read();
|
|
1120
|
+
if (done)
|
|
1121
|
+
break;
|
|
1122
|
+
res.write(value);
|
|
1123
|
+
}
|
|
1124
|
+
res.end();
|
|
1125
|
+
}
|
|
1126
|
+
catch (err) {
|
|
1127
|
+
// Mid-answer (the cap, a dropped connection): an Anthropic error event,
|
|
1128
|
+
// so a stream reads as failed, not as a reply that merely stopped.
|
|
1129
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1130
|
+
log.push(`translator error: ${message}`);
|
|
1131
|
+
res.end(`event: error\ndata: ${errorBody(500, `translator: ${message}`)}\n\n`);
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1061
1134
|
/**
|
|
1062
1135
|
* What a provider block needs from the translator, or null when the block
|
|
1063
1136
|
* speaks Anthropic and needs none. Values only — this crosses into the run
|
package/dist/src/webhook.js
CHANGED
|
@@ -115,8 +115,8 @@ export function recordDelivery(tenant, workspace, delivery) {
|
|
|
115
115
|
const file = deliveriesFile(tenant, workspace);
|
|
116
116
|
if (!fs.existsSync(path.dirname(file)))
|
|
117
117
|
return; // no workspace, nothing to log against
|
|
118
|
-
if (UNAUTHENTICATED.includes(delivery.outcome)
|
|
119
|
-
return;
|
|
118
|
+
if (UNAUTHENTICATED.includes(delivery.outcome))
|
|
119
|
+
return recordRefusal(file, tenant, workspace, delivery);
|
|
120
120
|
fs.appendFileSync(file, JSON.stringify(delivery) + "\n");
|
|
121
121
|
// Bounded: compact to the newest KEEP when it doubles. Amortised cheap,
|
|
122
122
|
// and a log that grows forever is a disk-full incident with a delay.
|
|
@@ -132,9 +132,42 @@ export function recordDelivery(tenant, workspace, delivery) {
|
|
|
132
132
|
// compaction is best-effort; the append already landed
|
|
133
133
|
}
|
|
134
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* A refusal — anyone's to send — never changes the workspace's directory: no
|
|
137
|
+
* new file, no tmp file, no rename. The directory's mtime is the workspace's
|
|
138
|
+
* deployedAt (store.ts listWorkspaces), and a stranger posting a bad token
|
|
139
|
+
* moved it (FOL-102). So a refusal goes into a log that already exists, in
|
|
140
|
+
* place (an append, or the last line rewritten), and is never the write that
|
|
141
|
+
* compacts it; a workspace with no log yet gets a server log line instead,
|
|
142
|
+
* at most one a minute per workspace.
|
|
143
|
+
*/
|
|
144
|
+
function recordRefusal(file, tenant, workspace, delivery) {
|
|
145
|
+
if (!fs.existsSync(file))
|
|
146
|
+
return noteRefusal(tenant, workspace, delivery);
|
|
147
|
+
if (collapseInto(file, delivery))
|
|
148
|
+
return;
|
|
149
|
+
fs.appendFileSync(file, JSON.stringify(delivery) + "\n");
|
|
150
|
+
}
|
|
151
|
+
const REFUSAL_NOTE_MS = 60_000;
|
|
152
|
+
const refusalNotes = new Map();
|
|
153
|
+
function noteRefusal(tenant, workspace, delivery) {
|
|
154
|
+
const key = `${tenant}/${workspace}`;
|
|
155
|
+
const now = Date.now();
|
|
156
|
+
const seen = refusalNotes.get(key);
|
|
157
|
+
if (seen && now - seen.at < REFUSAL_NOTE_MS) {
|
|
158
|
+
seen.since++;
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const more = seen?.since ? ` (and ${seen.since} more since the last note)` : "";
|
|
162
|
+
console.warn(`[hooks] refused a delivery to ${key}/${delivery.flow}: ${delivery.outcome}${more}`);
|
|
163
|
+
refusalNotes.set(key, { at: now, since: 0 });
|
|
164
|
+
if (refusalNotes.size > 10_000)
|
|
165
|
+
refusalNotes.clear(); // bounded: a note lost beats memory
|
|
166
|
+
}
|
|
135
167
|
/** Fold a refusal into the log's last line when that line is a refusal
|
|
136
168
|
* too. The flow name is not part of the match: it comes from the URL, so
|
|
137
|
-
* a flood that varies it would otherwise never collapse. True when folded.
|
|
169
|
+
* a flood that varies it would otherwise never collapse. True when folded.
|
|
170
|
+
* Rewritten in place, never through a rename (recordRefusal says why). */
|
|
138
171
|
function collapseInto(file, delivery) {
|
|
139
172
|
let text;
|
|
140
173
|
try {
|
|
@@ -160,9 +193,16 @@ function collapseInto(file, delivery) {
|
|
|
160
193
|
first: last.first ?? last.t,
|
|
161
194
|
...(last.detail === "more than one flow name" || last.flow !== delivery.flow ? { detail: "more than one flow name" } : {}),
|
|
162
195
|
};
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
fs.
|
|
196
|
+
const line = Buffer.from(JSON.stringify(merged) + "\n");
|
|
197
|
+
const at = Buffer.byteLength(end.slice(0, cut));
|
|
198
|
+
const fd = fs.openSync(file, "r+");
|
|
199
|
+
try {
|
|
200
|
+
fs.writeSync(fd, line, 0, line.length, at);
|
|
201
|
+
fs.ftruncateSync(fd, at + line.length);
|
|
202
|
+
}
|
|
203
|
+
finally {
|
|
204
|
+
fs.closeSync(fd);
|
|
205
|
+
}
|
|
166
206
|
return true;
|
|
167
207
|
}
|
|
168
208
|
export function readDeliveries(tenant, workspace, limit = 20) {
|
package/package.json
CHANGED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Matching on the shared worker and web pod against tenant-written patterns.
|
|
2
|
+
//
|
|
3
|
+
// A regular expression or a glob a tenant writes runs here, not in their
|
|
4
|
+
// sandbox (an eval's `matches:`, a gate's `preview:`). A pattern that
|
|
5
|
+
// backtracks — `(a+)+$`, or `**a**a**a**b` turned into `.*a.*a.*a.*b` —
|
|
6
|
+
// holds the one event loop every account shares. So: globs match in linear
|
|
7
|
+
// time with no regex at all, and a regex runs under a time limit
|
|
8
|
+
// (29 Oct 2026, OWASP review).
|
|
9
|
+
|
|
10
|
+
import vm from "node:vm";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Does `text` match `pattern`? `*` matches within one path segment, `**`
|
|
14
|
+
* across segments; every other character is itself. O(pattern × text).
|
|
15
|
+
*/
|
|
16
|
+
export function globMatch(pattern: string, text: string): boolean {
|
|
17
|
+
const toks: string[] = [];
|
|
18
|
+
for (let i = 0; i < pattern.length; ) {
|
|
19
|
+
if (pattern[i] === "*") {
|
|
20
|
+
if (pattern[i + 1] === "*") {
|
|
21
|
+
i += 2;
|
|
22
|
+
while (pattern[i] === "*") i++;
|
|
23
|
+
// "**" swallows a neighbouring "**" or "*": the same set either way.
|
|
24
|
+
if (toks[toks.length - 1] !== "**") toks.push("**");
|
|
25
|
+
} else {
|
|
26
|
+
i++;
|
|
27
|
+
if (toks[toks.length - 1] !== "**" && toks[toks.length - 1] !== "*") toks.push("*");
|
|
28
|
+
}
|
|
29
|
+
} else {
|
|
30
|
+
toks.push(pattern[i]);
|
|
31
|
+
i++;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const n = text.length;
|
|
35
|
+
let cur: boolean[] = new Array(n + 1).fill(false);
|
|
36
|
+
cur[0] = true;
|
|
37
|
+
for (const t of toks) {
|
|
38
|
+
const next: boolean[] = new Array(n + 1).fill(false);
|
|
39
|
+
if (t === "**") {
|
|
40
|
+
let on = false;
|
|
41
|
+
for (let j = 0; j <= n; j++) {
|
|
42
|
+
on = on || cur[j];
|
|
43
|
+
next[j] = on;
|
|
44
|
+
}
|
|
45
|
+
} else if (t === "*") {
|
|
46
|
+
let on = false;
|
|
47
|
+
for (let j = 0; j <= n; j++) {
|
|
48
|
+
if (cur[j]) on = true;
|
|
49
|
+
next[j] = on;
|
|
50
|
+
if (j < n && text[j] === "/") on = false;
|
|
51
|
+
}
|
|
52
|
+
} else {
|
|
53
|
+
for (let j = 0; j < n; j++) if (cur[j] && text[j] === t) next[j + 1] = true;
|
|
54
|
+
}
|
|
55
|
+
cur = next;
|
|
56
|
+
}
|
|
57
|
+
return cur[n];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A regex test that gives up after `ms`: true, false, or "timeout". Throws
|
|
61
|
+
* on a pattern that does not compile, as `new RegExp` would. */
|
|
62
|
+
export function boundedRegexTest(source: string, flags: string, text: string, ms = 250): boolean | "timeout" {
|
|
63
|
+
new RegExp(source, flags); // compile here: a syntax error is the caller's to report
|
|
64
|
+
try {
|
|
65
|
+
return vm.runInNewContext("new RegExp(p, f).test(t)", { p: source, f: flags, t: text }, { timeout: ms }) === true;
|
|
66
|
+
} catch (err) {
|
|
67
|
+
if ((err as { code?: string }).code === "ERR_SCRIPT_EXECUTION_TIMEOUT") return "timeout";
|
|
68
|
+
throw err;
|
|
69
|
+
}
|
|
70
|
+
}
|
package/src/deploy.ts
CHANGED
|
@@ -80,6 +80,9 @@ const IN_WORKSPACE_DIR = new RegExp(`^(${WORKSPACE_DIRS.join("|")})/`);
|
|
|
80
80
|
export function isSourcePath(rel: string): boolean {
|
|
81
81
|
const norm = rel.replaceAll("\\", "/");
|
|
82
82
|
const parts = norm.split("/");
|
|
83
|
+
// A path that climbs, is absolute or has an empty segment is never a
|
|
84
|
+
// workspace file — and `foldrun pull` writes what this accepts to disk.
|
|
85
|
+
if (norm.startsWith("/") || /^[a-zA-Z]:/.test(norm) || parts.some((p) => p === ".." || p === "." || p === "")) return false;
|
|
83
86
|
if (parts.some((p) => NOT_SOURCE.has(p))) return false;
|
|
84
87
|
if (NOT_SOURCE_FILE.has(parts[parts.length - 1])) return false;
|
|
85
88
|
return norm === "AGENTS.md" || norm === "project.md" || IN_WORKSPACE_DIR.test(norm);
|
package/src/evals.ts
CHANGED
|
@@ -31,7 +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, withModelEnv } from "./model-env.ts";
|
|
34
|
+
import { accountModelEnv, withModelEnv, ModelKeyError } from "./model-env.ts";
|
|
35
35
|
import type { TranslatorSpec } from "./translator.ts";
|
|
36
36
|
import { sdkEnv } from "./step-exec.ts";
|
|
37
37
|
import { platformModelEnv } from "./runner.ts";
|
|
@@ -42,6 +42,8 @@ import { deployedCommit } from "./deploy.ts";
|
|
|
42
42
|
import { latestRevisionId } from "./history.ts";
|
|
43
43
|
import matter from "./matter.ts";
|
|
44
44
|
import { spawn } from "node:child_process";
|
|
45
|
+
import { hostSafeEnv } from "./host-env.ts";
|
|
46
|
+
import { boundedRegexTest } from "./bounded-match.ts";
|
|
45
47
|
import { judgeVerdict, JUDGE_VERDICT_INSTRUCTION, parseJudge } from "./judge-verdict.ts";
|
|
46
48
|
import { readAgentsMdKeys } from "./agents-md.ts";
|
|
47
49
|
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
@@ -596,7 +598,8 @@ async function waitForRun(tenant: string, workspace: string, runId: string) {
|
|
|
596
598
|
|
|
597
599
|
function shell(command: string, cwd: string): Promise<{ code: number | null; out: string }> {
|
|
598
600
|
return new Promise((resolve) => {
|
|
599
|
-
|
|
601
|
+
// Never the platform's own environment: keys, database URL, tokens.
|
|
602
|
+
const child = spawn("bash", ["-lc", command], { cwd, env: hostSafeEnv() });
|
|
600
603
|
let out = "";
|
|
601
604
|
const append = (c: Buffer) => {
|
|
602
605
|
if (out.length < 2000) out += c.toString();
|
|
@@ -707,7 +710,9 @@ export async function checkAssertion(
|
|
|
707
710
|
}
|
|
708
711
|
case "matches": {
|
|
709
712
|
try {
|
|
710
|
-
|
|
713
|
+
// Bounded: the pattern is the tenant's, the process is shared.
|
|
714
|
+
const passed = boundedRegexTest(assertion.value, "i", output);
|
|
715
|
+
if (passed === "timeout") return { assertion, passed: false, detail: "the pattern took too long to match — simplify it" };
|
|
711
716
|
return { assertion, passed, detail: passed ? "matched" : "no match" };
|
|
712
717
|
} catch {
|
|
713
718
|
return { assertion, passed: false, detail: "invalid regular expression" };
|
|
@@ -715,13 +720,20 @@ export async function checkAssertion(
|
|
|
715
720
|
}
|
|
716
721
|
case "file": {
|
|
717
722
|
const target = path.resolve(agentDir, assertion.value);
|
|
718
|
-
if (!target.startsWith(path.resolve(agentDir))) {
|
|
723
|
+
if (!target.startsWith(path.resolve(agentDir) + path.sep)) {
|
|
719
724
|
return { assertion, passed: false, detail: "path escapes the agent directory" };
|
|
720
725
|
}
|
|
721
726
|
const exists = fs.existsSync(target) && fs.statSync(target).size > 0;
|
|
722
727
|
return { assertion, passed: exists, detail: exists ? "present and non-empty" : "missing or empty" };
|
|
723
728
|
}
|
|
724
729
|
case "run": {
|
|
730
|
+
// A `run:` is the tenant's own shell command. On an install that
|
|
731
|
+
// isolates runs it would execute here, on the web pod, beside the
|
|
732
|
+
// platform's keys and every account's files — so it is refused there,
|
|
733
|
+
// as a hook's Test is. A laptop runs every step in-process anyway.
|
|
734
|
+
if (process.env.FOLDRUN_RUN_ISOLATION) {
|
|
735
|
+
return { assertion, passed: false, detail: "run: assertions are not executed on this install — check the output with contains:/matches:/judge:, or a file with file:; `foldrun eval` runs them on your own machine" };
|
|
736
|
+
}
|
|
725
737
|
const { code, out } = await shell(assertion.value, agentDir);
|
|
726
738
|
return { assertion, passed: code === 0, detail: `exit ${code ?? "error"}${out ? ` — ${out.slice(0, 200)}` : ""}` };
|
|
727
739
|
}
|
|
@@ -734,7 +746,14 @@ export async function checkAssertion(
|
|
|
734
746
|
} catch (err) {
|
|
735
747
|
return { assertion, passed: false, detail: `judge not run — ${err instanceof Error ? err.message : String(err)}` };
|
|
736
748
|
}
|
|
737
|
-
|
|
749
|
+
let said: Awaited<ReturnType<typeof judgeCall>>;
|
|
750
|
+
try {
|
|
751
|
+
said = await withModelEnv(m, (env) => judgeCall(assertion.value, output, model, effort, env));
|
|
752
|
+
} catch (err) {
|
|
753
|
+
// The provider's URL refused on the host (FOL-95), like a missing key above.
|
|
754
|
+
if (!(err instanceof ModelKeyError)) throw err;
|
|
755
|
+
return { assertion, passed: false, detail: `judge not run — ${err.message}` };
|
|
756
|
+
}
|
|
738
757
|
const { passed, detail } = judgeVerdict(said.text);
|
|
739
758
|
return { assertion, passed, detail, costUsd: said.costUsd };
|
|
740
759
|
}
|
package/src/mail.ts
CHANGED
|
@@ -258,7 +258,45 @@ export async function mailWanted(tenant: string, email: string, category: MailCa
|
|
|
258
258
|
* carries that reader's own unsubscribe token; required mail goes as one.
|
|
259
259
|
* Never throws: a mail that fails is an `ok: false` with the reason.
|
|
260
260
|
*/
|
|
261
|
+
// The platform's own sender is shared by every account, so its reputation
|
|
262
|
+
// is everyone's. Mail an account can make the platform send to addresses it
|
|
263
|
+
// typed (invites, notify:, approvals, low balance) is capped per account per
|
|
264
|
+
// day on the platform's key; mail about the account's own sign-in, billing
|
|
265
|
+
// and health is not. An account sending on its own key is its own business.
|
|
266
|
+
// Counted in this process — a brake, not a ledger (29 Oct 2026: open signup
|
|
267
|
+
// on dev let anyone send ~86k invites a day from hello@foldrun.io).
|
|
268
|
+
const UNCAPPED = new Set<string>(["security", "billing", "account"]);
|
|
269
|
+
const PLATFORM_MAX_RECIPIENTS = 10;
|
|
270
|
+
const platformDayCap = (category: string) =>
|
|
271
|
+
Number(process.env[category === "invites" ? "FOLDRUN_PLATFORM_INVITES_PER_DAY" : "FOLDRUN_PLATFORM_MAIL_PER_DAY"]) ||
|
|
272
|
+
(category === "invites" ? 25 : 200);
|
|
273
|
+
const platformSent = new Map<string, number>();
|
|
274
|
+
|
|
275
|
+
/** Recipients this account may still send to today on the platform's key. */
|
|
276
|
+
export function platformMailAllowance(tenant: string, category: string, key: string, now = new Date()): number {
|
|
277
|
+
const platformKey = process.env.FOLDRUN_RESEND_API_KEY;
|
|
278
|
+
if (!platformKey || key !== platformKey || UNCAPPED.has(category)) return Infinity;
|
|
279
|
+
const used = platformSent.get(`${now.toISOString().slice(0, 10)}|${tenant}|${category === "invites" ? "invites" : "other"}`) ?? 0;
|
|
280
|
+
return Math.max(0, platformDayCap(category) - used);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function notePlatformSent(tenant: string, category: string, n: number, now = new Date()): void {
|
|
284
|
+
const day = now.toISOString().slice(0, 10);
|
|
285
|
+
for (const k of platformSent.keys()) if (!k.startsWith(day)) platformSent.delete(k);
|
|
286
|
+
const k = `${day}|${tenant}|${category === "invites" ? "invites" : "other"}`;
|
|
287
|
+
platformSent.set(k, (platformSent.get(k) ?? 0) + n);
|
|
288
|
+
}
|
|
289
|
+
|
|
261
290
|
export async function sendMail(m: MailMessage): Promise<MailResult> {
|
|
291
|
+
const allowance = platformMailAllowance(m.tenant, m.category, m.mail.key);
|
|
292
|
+
if (allowance !== Infinity) {
|
|
293
|
+
const n = recipients(m.to).length;
|
|
294
|
+
if (n > PLATFORM_MAX_RECIPIENTS) {
|
|
295
|
+
return { ok: false, error: `more than ${PLATFORM_MAX_RECIPIENTS} recipients on the platform's sender — set the account's own RESEND_API_KEY and EMAIL_FROM to send more`, sent: [], suppressed: [] };
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
const capped = allowance !== Infinity;
|
|
299
|
+
const overCap = `this account reached today's limit for ${m.category} mail on the platform's sender — set its own RESEND_API_KEY and EMAIL_FROM, or try tomorrow`;
|
|
262
300
|
const all = recipients(m.to);
|
|
263
301
|
const required = isRequiredCategory(m.category);
|
|
264
302
|
const workspace = m.workspace ?? null;
|
|
@@ -296,6 +334,8 @@ export async function sendMail(m: MailMessage): Promise<MailResult> {
|
|
|
296
334
|
};
|
|
297
335
|
|
|
298
336
|
if (required) {
|
|
337
|
+
if (capped && all.length > allowance) return { ok: false, error: overCap, sent: [], suppressed: [] };
|
|
338
|
+
if (capped) notePlatformSent(m.tenant, m.category, all.length);
|
|
299
339
|
if (all.length) await post(all, {}, "");
|
|
300
340
|
return { ok: all.length > 0 && sent.length === all.length, status, error, sent, suppressed };
|
|
301
341
|
}
|
|
@@ -305,6 +345,15 @@ export async function sendMail(m: MailMessage): Promise<MailResult> {
|
|
|
305
345
|
suppressed.push(to);
|
|
306
346
|
continue;
|
|
307
347
|
}
|
|
348
|
+
// Counted only for someone who takes this mail: an opted-out recipient
|
|
349
|
+
// must not use up the day's allowance for the ones who want it.
|
|
350
|
+
if (capped) {
|
|
351
|
+
if (platformMailAllowance(m.tenant, m.category, m.mail.key) < 1) {
|
|
352
|
+
error = overCap;
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
notePlatformSent(m.tenant, m.category, 1);
|
|
356
|
+
}
|
|
308
357
|
const claim = { tenant: m.tenant, email: to, category: m.category, workspace: mailCategory(m.category)?.perWorkspace ? workspace : null };
|
|
309
358
|
const links = unsubscribeLinks(claim);
|
|
310
359
|
const label = mailCategory(m.category)?.label.toLowerCase() ?? m.category;
|
package/src/matter.ts
CHANGED
|
@@ -79,8 +79,25 @@ function foldJobs(data: Record<string, unknown>): Record<string, unknown> {
|
|
|
79
79
|
return out;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
// gray-matter ships a `javascript` engine that eval()s a block opened with
|
|
83
|
+
// `---js` or `---javascript` — with `process` and `require` in reach. Every
|
|
84
|
+
// file it parses here was written by a tenant (or an agent), on the shared
|
|
85
|
+
// web pod and worker. Only YAML is a language here; any other engine name
|
|
86
|
+
// throws, which the readers already treat as frontmatter that does not parse.
|
|
87
|
+
const refuse = (name: string) => () => {
|
|
88
|
+
throw new Error(`frontmatter opened with "---${name}" — only YAML frontmatter is read`);
|
|
89
|
+
};
|
|
90
|
+
const ENGINES = {
|
|
91
|
+
js: { parse: refuse("js"), stringify: refuse("js") },
|
|
92
|
+
javascript: { parse: refuse("javascript"), stringify: refuse("javascript") },
|
|
93
|
+
coffee: { parse: refuse("coffee"), stringify: refuse("coffee") },
|
|
94
|
+
coffeescript: { parse: refuse("coffeescript"), stringify: refuse("coffeescript") },
|
|
95
|
+
cson: { parse: refuse("cson"), stringify: refuse("cson") },
|
|
96
|
+
};
|
|
97
|
+
const safe = (options?: Options): Options => ({ ...(options ?? {}), engines: { ...(options?.engines ?? {}), ...ENGINES } as Options["engines"] });
|
|
98
|
+
|
|
82
99
|
function matter(input: string, options?: Options) {
|
|
83
|
-
return grayMatter(input, options
|
|
100
|
+
return grayMatter(input, safe(options));
|
|
84
101
|
}
|
|
85
102
|
matter.stringify = grayMatter.stringify;
|
|
86
103
|
matter.test = grayMatter.test;
|
|
@@ -93,7 +110,7 @@ matter.test = grayMatter.test;
|
|
|
93
110
|
* note's own `governance:` is its own (Codex debate, 21 Oct 2026).
|
|
94
111
|
*/
|
|
95
112
|
export function configMatter(input: string, options?: Options) {
|
|
96
|
-
const file = grayMatter(input, options
|
|
113
|
+
const file = grayMatter(input, safe(options));
|
|
97
114
|
if (file.data && typeof file.data === "object" && !Array.isArray(file.data)) {
|
|
98
115
|
(file as { data: Record<string, unknown> }).data = foldJobs(file.data as Record<string, unknown>);
|
|
99
116
|
}
|
package/src/model-env.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { accountDir, workspaceDir, parseProvider, providerEnvFor } from "./store.ts";
|
|
10
10
|
import { readAgentsMd } from "./runner.ts";
|
|
11
11
|
import { getSecret } from "./secrets.ts";
|
|
12
|
-
import { platform } from "./platform.ts";
|
|
12
|
+
import { platform, hostUrlProblem } from "./platform.ts";
|
|
13
13
|
import { accountModelBlock, isClaudeLoginToken } from "./model-credential.ts";
|
|
14
14
|
import { providerPreset } from "./providers.ts";
|
|
15
15
|
import { startTranslator, translatorSpecFor, type TranslatorSpec } from "./translator.ts";
|
|
@@ -53,6 +53,12 @@ export function accountModelEnv(
|
|
|
53
53
|
if (!platform.platformKeyAllowed(tenant)) throw new ModelKeyError(NO_MODEL_KEY);
|
|
54
54
|
return { env: platformEnv(), supply: "platform", format: "anthropic", translator: null };
|
|
55
55
|
}
|
|
56
|
+
// A host-side call to a URL the tenant wrote: on a platform, https only
|
|
57
|
+
// (platform.ts hostUrlProblem); withModelEnv asks the platform's own check
|
|
58
|
+
// too, and the guard sees every byte (FOL-95). A step resolves its
|
|
59
|
+
// provider elsewhere (runner.ts) and is not touched by this.
|
|
60
|
+
const refused = hostUrlProblem(spec.baseUrl);
|
|
61
|
+
if (refused) throw new ModelKeyError(`provider base_url refused: ${refused}`, 422);
|
|
56
62
|
const sub = (text: string) =>
|
|
57
63
|
text.replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name) => getSecret(tenant, name, workspace)?.value ?? whole);
|
|
58
64
|
const token = sub(spec.token);
|
|
@@ -80,19 +86,48 @@ export function accountModelEnv(
|
|
|
80
86
|
}
|
|
81
87
|
|
|
82
88
|
/**
|
|
83
|
-
* Run `fn` with the model env, through a translator on this host
|
|
84
|
-
*
|
|
85
|
-
*
|
|
89
|
+
* Run `fn` with the model env, through a translator on this host — the one a
|
|
90
|
+
* step uses (runner.ts) when the provider speaks Chat Completions or
|
|
91
|
+
* Responses, and a passthrough when it speaks Anthropic's Messages (FOL-95):
|
|
92
|
+
* either way the SDK talks only to loopback, and the tenant's endpoint is
|
|
93
|
+
* reached through fetchUntrusted, after the platform's check of its URL.
|
|
94
|
+
* The platform's own credential (`supply: "platform"`) goes as it always did.
|
|
95
|
+
* Started for this call and closed after it.
|
|
86
96
|
*/
|
|
87
97
|
export async function withModelEnv<T>(
|
|
88
|
-
m: { env: Record<string, string | undefined>; translator?: TranslatorSpec | null },
|
|
98
|
+
m: { env: Record<string, string | undefined>; translator?: TranslatorSpec | null; supply?: "provider" | "platform" },
|
|
89
99
|
fn: (env: Record<string, string | undefined>) => Promise<T>,
|
|
90
100
|
): Promise<T> {
|
|
91
|
-
|
|
92
|
-
|
|
101
|
+
const spec = m.translator ?? (m.supply === "provider" ? anthropicPassthrough(m.env) : null);
|
|
102
|
+
if (!spec) return fn(m.env);
|
|
103
|
+
const why = await platform.checkUntrustedUrl(spec.upstreamBase);
|
|
104
|
+
if (why) throw new ModelKeyError(`provider base_url refused: ${why}`, 422);
|
|
105
|
+
const t = await startTranslator(spec);
|
|
93
106
|
try {
|
|
94
107
|
return await fn({ ...m.env, ...t.env });
|
|
95
108
|
} finally {
|
|
96
109
|
await t.close();
|
|
97
110
|
}
|
|
98
111
|
}
|
|
112
|
+
|
|
113
|
+
/** The passthrough for an Anthropic-format provider, read back from the env
|
|
114
|
+
* providerEnvFor (store.ts) built for it: the base URL, the key in the slot
|
|
115
|
+
* that names its header, and ANTHROPIC_CUSTOM_HEADERS. Null with no base. */
|
|
116
|
+
export function anthropicPassthrough(env: Record<string, string | undefined>): TranslatorSpec | null {
|
|
117
|
+
const base = env.ANTHROPIC_BASE_URL;
|
|
118
|
+
if (!base) return null;
|
|
119
|
+
const apiKey = env.ANTHROPIC_API_KEY ?? "";
|
|
120
|
+
const headers: Record<string, string> = {};
|
|
121
|
+
for (const line of (env.ANTHROPIC_CUSTOM_HEADERS ?? "").split("\n")) {
|
|
122
|
+
const at = line.indexOf(":");
|
|
123
|
+
if (at > 0) headers[line.slice(0, at).trim()] = line.slice(at + 1).trim();
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
wire: "anthropic",
|
|
127
|
+
upstreamBase: base,
|
|
128
|
+
upstreamKey: apiKey || (env.ANTHROPIC_AUTH_TOKEN ?? ""),
|
|
129
|
+
upstreamAuth: apiKey ? "x-api-key" : "bearer",
|
|
130
|
+
headers,
|
|
131
|
+
label: base,
|
|
132
|
+
};
|
|
133
|
+
}
|
package/src/platform.ts
CHANGED
|
@@ -177,6 +177,16 @@ export interface PlatformHooks {
|
|
|
177
177
|
* caps it again, so a laptop's plain fetch may ignore it).
|
|
178
178
|
*/
|
|
179
179
|
fetchUntrusted(url: string, init?: RequestInit, opts?: { maxBytes?: number }): Promise<Response>;
|
|
180
|
+
/**
|
|
181
|
+
* May the host call this URL a tenant chose — a provider's base_url,
|
|
182
|
+
* before an eval's judge or a dashboard draft talks to it? A reason it may
|
|
183
|
+
* not, or null. This is the early refusal people can read; the guarantee
|
|
184
|
+
* is that every byte of such a call goes through fetchUntrusted (FOL-95).
|
|
185
|
+
* Default: hostUrlProblem below — https on an install that guards
|
|
186
|
+
* untrusted fetches, anything on a laptop (its own localhost included).
|
|
187
|
+
* A platform may bind a stricter check (the addresses a name resolves to).
|
|
188
|
+
*/
|
|
189
|
+
checkUntrustedUrl(url: string): Promise<string | null>;
|
|
180
190
|
/**
|
|
181
191
|
* Hand a notify: webhook to whatever delivers it. The platform records
|
|
182
192
|
* the delivery, makes the first attempt now and retries on a backoff from
|
|
@@ -210,6 +220,11 @@ export interface WebhookDeliveryOutcome {
|
|
|
210
220
|
retrying?: boolean;
|
|
211
221
|
}
|
|
212
222
|
|
|
223
|
+
/** Marks the laptop's fetchUntrusted. A symbol from the global registry, not
|
|
224
|
+
* the function itself: every bundle has its own copy of this module (see
|
|
225
|
+
* KEY below), and only the shared object says which one was registered. */
|
|
226
|
+
const LAPTOP_FETCH = Symbol.for("foldrun.laptop-fetch");
|
|
227
|
+
|
|
213
228
|
const local: PlatformHooks = {
|
|
214
229
|
edition: () => "self-hosted",
|
|
215
230
|
async enqueueFlowRun(tenant, workspace, steps, flowName, modelOverride, tags = [], startedBy = null, opts = {}) {
|
|
@@ -237,7 +252,8 @@ const local: PlatformHooks = {
|
|
|
237
252
|
platformKeyAllowed: () => true,
|
|
238
253
|
galleryDir: () => null,
|
|
239
254
|
noteOAuthRefresh: async () => undefined,
|
|
240
|
-
fetchUntrusted: (url, init) => fetch(url, init),
|
|
255
|
+
fetchUntrusted: Object.assign((url: string, init?: RequestInit) => fetch(url, init), { [LAPTOP_FETCH]: true }),
|
|
256
|
+
checkUntrustedUrl: async (url) => hostUrlProblem(url),
|
|
241
257
|
async deliverWebhook(tenant, workspace, delivery) {
|
|
242
258
|
const { attemptWebhook, newDeliveryId } = await import("./notify.ts");
|
|
243
259
|
const deliveryId = newDeliveryId();
|
|
@@ -259,6 +275,34 @@ const KEY = Symbol.for("foldrun.platform");
|
|
|
259
275
|
const g = globalThis as unknown as Record<symbol, PlatformHooks | undefined>;
|
|
260
276
|
export const platform: PlatformHooks = (g[KEY] ??= { ...local });
|
|
261
277
|
|
|
278
|
+
/** True when this process fetches tenant-chosen URLs through a guard: a
|
|
279
|
+
* platform bound fetchUntrusted. A laptop has not, and calls its own LAN on
|
|
280
|
+
* purpose. */
|
|
281
|
+
export function guardsUntrustedUrls(): boolean {
|
|
282
|
+
return !(platform.fetchUntrusted as unknown as Record<symbol, unknown>)[LAPTOP_FETCH];
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Why the host may not call a tenant's model endpoint at this URL, or null.
|
|
287
|
+
* On an install that guards untrusted fetches the endpoint must be https: the
|
|
288
|
+
* key rides in every request, and a plain-http base is also how a tenant
|
|
289
|
+
* names a cluster service. Private and internal addresses are refused by the
|
|
290
|
+
* guard itself, on every hop. FOLDRUN_EGRESS_ALLOW_PRIVATE=1 — the operator's
|
|
291
|
+
* own install calling its own network — lifts this as it lifts the guard. A
|
|
292
|
+
* laptop: always null.
|
|
293
|
+
*/
|
|
294
|
+
export function hostUrlProblem(url: string): string | null {
|
|
295
|
+
if (!guardsUntrustedUrls() || process.env.FOLDRUN_EGRESS_ALLOW_PRIVATE === "1") return null;
|
|
296
|
+
let u: URL;
|
|
297
|
+
try {
|
|
298
|
+
u = new URL(url);
|
|
299
|
+
} catch {
|
|
300
|
+
return `${url} is not a URL`;
|
|
301
|
+
}
|
|
302
|
+
if (u.protocol !== "https:") return `${u.origin} is not https — this platform calls a model endpoint only over https`;
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
|
|
262
306
|
/** Install the platform's implementations. Partial: what is not given keeps its local default. */
|
|
263
307
|
export function registerPlatform(hooks: Partial<PlatformHooks>): void {
|
|
264
308
|
Object.assign(platform, hooks, {
|
package/src/runner.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// fail without failing the flow. A direct agent run is a one-step ad-hoc
|
|
4
4
|
// flow, so there is a single execution path.
|
|
5
5
|
|
|
6
|
+
import { globMatch } from "./bounded-match.ts";
|
|
6
7
|
import { latestRevisionId } from "./history.ts";
|
|
7
8
|
import { recordCallIo, redactionsFor } from "./capture.ts";
|
|
8
9
|
import { AUTH_REFUSAL, platformCause } from "./issues.ts";
|
|
@@ -4308,15 +4309,16 @@ export function startFlowRun(
|
|
|
4308
4309
|
export function resolvePreview(storageDir: string, patterns: string[], cap = 12): string[] {
|
|
4309
4310
|
const out = new Set<string>();
|
|
4310
4311
|
for (const pattern of patterns) {
|
|
4312
|
+
// Inside storage/ only: no climbing, no absolute paths.
|
|
4313
|
+
const parts = pattern.replaceAll("\\", "/").split("/");
|
|
4314
|
+
if (pattern.startsWith("/") || parts.includes("..") || pattern.length > 400) continue;
|
|
4311
4315
|
if (!/[*?]/.test(pattern)) {
|
|
4312
4316
|
if (fs.existsSync(path.join(storageDir, pattern))) out.add(pattern);
|
|
4313
4317
|
continue;
|
|
4314
4318
|
}
|
|
4315
|
-
|
|
4316
|
-
|
|
4317
|
-
|
|
4318
|
-
.join(".*") + "$",
|
|
4319
|
-
);
|
|
4319
|
+
// Matched in linear time: this runs on the worker every account shares,
|
|
4320
|
+
// and a regex built from "**a**a**a**b" backtracks for seconds.
|
|
4321
|
+
const re = { test: (r: string) => globMatch(pattern, r) };
|
|
4320
4322
|
const walk = (dir: string, rel: string) => {
|
|
4321
4323
|
let entries: fs.Dirent[];
|
|
4322
4324
|
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|