@foldrun/core 5.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/evals.js CHANGED
@@ -30,7 +30,7 @@
30
30
  // Deterministic checks run first and cost nothing. The judge only runs if
31
31
  // they pass — no point paying a model to grade output already known to be
32
32
  // wrong.
33
- import { accountModelEnv, withModelEnv } from "./model-env.js";
33
+ import { accountModelEnv, withModelEnv, ModelKeyError } from "./model-env.js";
34
34
  import { sdkEnv } from "./step-exec.js";
35
35
  import { platformModelEnv } from "./runner.js";
36
36
  import fs from "node:fs";
@@ -582,7 +582,16 @@ export async function checkAssertion(assertion, output, agentDir, model, effort,
582
582
  catch (err) {
583
583
  return { assertion, passed: false, detail: `judge not run — ${err instanceof Error ? err.message : String(err)}` };
584
584
  }
585
- const said = await withModelEnv(m, (env) => judgeCall(assertion.value, output, model, effort, env));
585
+ let said;
586
+ try {
587
+ said = await withModelEnv(m, (env) => judgeCall(assertion.value, output, model, effort, env));
588
+ }
589
+ catch (err) {
590
+ // The provider's URL refused on the host (FOL-95), like a missing key above.
591
+ if (!(err instanceof ModelKeyError))
592
+ throw err;
593
+ return { assertion, passed: false, detail: `judge not run — ${err.message}` };
594
+ }
586
595
  const { passed, detail } = judgeVerdict(said.text);
587
596
  return { assertion, passed, detail, costUsd: said.costUsd };
588
597
  }
@@ -19,11 +19,20 @@ export declare function accountModelEnv(tenant: string, workspace: string, platf
19
19
  translator: TranslatorSpec | null;
20
20
  };
21
21
  /**
22
- * Run `fn` with the model env, through a translator on this host when the
23
- * provider speaks Chat Completions or Responses — the same one a step uses
24
- * (runner.ts), started for this call and closed after it.
22
+ * Run `fn` with the model env, through a translator on this host — the one a
23
+ * step uses (runner.ts) when the provider speaks Chat Completions or
24
+ * Responses, and a passthrough when it speaks Anthropic's Messages (FOL-95):
25
+ * either way the SDK talks only to loopback, and the tenant's endpoint is
26
+ * reached through fetchUntrusted, after the platform's check of its URL.
27
+ * The platform's own credential (`supply: "platform"`) goes as it always did.
28
+ * Started for this call and closed after it.
25
29
  */
26
30
  export declare function withModelEnv<T>(m: {
27
31
  env: Record<string, string | undefined>;
28
32
  translator?: TranslatorSpec | null;
33
+ supply?: "provider" | "platform";
29
34
  }, fn: (env: Record<string, string | undefined>) => Promise<T>): Promise<T>;
35
+ /** The passthrough for an Anthropic-format provider, read back from the env
36
+ * providerEnvFor (store.ts) built for it: the base URL, the key in the slot
37
+ * that names its header, and ANTHROPIC_CUSTOM_HEADERS. Null with no base. */
38
+ export declare function anthropicPassthrough(env: Record<string, string | undefined>): TranslatorSpec | null;
@@ -8,7 +8,7 @@
8
8
  import { accountDir, workspaceDir, parseProvider, providerEnvFor } from "./store.js";
9
9
  import { readAgentsMd } from "./runner.js";
10
10
  import { getSecret } from "./secrets.js";
11
- import { platform } from "./platform.js";
11
+ import { platform, hostUrlProblem } from "./platform.js";
12
12
  import { accountModelBlock, isClaudeLoginToken } from "./model-credential.js";
13
13
  import { providerPreset } from "./providers.js";
14
14
  import { startTranslator, translatorSpecFor } from "./translator.js";
@@ -39,6 +39,13 @@ export function accountModelEnv(tenant, workspace, platformEnv) {
39
39
  throw new ModelKeyError(NO_MODEL_KEY);
40
40
  return { env: platformEnv(), supply: "platform", format: "anthropic", translator: null };
41
41
  }
42
+ // A host-side call to a URL the tenant wrote: on a platform, https only
43
+ // (platform.ts hostUrlProblem); withModelEnv asks the platform's own check
44
+ // too, and the guard sees every byte (FOL-95). A step resolves its
45
+ // provider elsewhere (runner.ts) and is not touched by this.
46
+ const refused = hostUrlProblem(spec.baseUrl);
47
+ if (refused)
48
+ throw new ModelKeyError(`provider base_url refused: ${refused}`, 422);
42
49
  const sub = (text) => text.replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name) => getSecret(tenant, name, workspace)?.value ?? whole);
43
50
  const token = sub(spec.token);
44
51
  if (token.includes("${"))
@@ -65,14 +72,22 @@ export function accountModelEnv(tenant, workspace, platformEnv) {
65
72
  };
66
73
  }
67
74
  /**
68
- * Run `fn` with the model env, through a translator on this host when the
69
- * provider speaks Chat Completions or Responses — the same one a step uses
70
- * (runner.ts), started for this call and closed after it.
75
+ * Run `fn` with the model env, through a translator on this host — the one a
76
+ * step uses (runner.ts) when the provider speaks Chat Completions or
77
+ * Responses, and a passthrough when it speaks Anthropic's Messages (FOL-95):
78
+ * either way the SDK talks only to loopback, and the tenant's endpoint is
79
+ * reached through fetchUntrusted, after the platform's check of its URL.
80
+ * The platform's own credential (`supply: "platform"`) goes as it always did.
81
+ * Started for this call and closed after it.
71
82
  */
72
83
  export async function withModelEnv(m, fn) {
73
- if (!m.translator)
84
+ const spec = m.translator ?? (m.supply === "provider" ? anthropicPassthrough(m.env) : null);
85
+ if (!spec)
74
86
  return fn(m.env);
75
- const t = await startTranslator(m.translator);
87
+ const why = await platform.checkUntrustedUrl(spec.upstreamBase);
88
+ if (why)
89
+ throw new ModelKeyError(`provider base_url refused: ${why}`, 422);
90
+ const t = await startTranslator(spec);
76
91
  try {
77
92
  return await fn({ ...m.env, ...t.env });
78
93
  }
@@ -80,3 +95,26 @@ export async function withModelEnv(m, fn) {
80
95
  await t.close();
81
96
  }
82
97
  }
98
+ /** The passthrough for an Anthropic-format provider, read back from the env
99
+ * providerEnvFor (store.ts) built for it: the base URL, the key in the slot
100
+ * that names its header, and ANTHROPIC_CUSTOM_HEADERS. Null with no base. */
101
+ export function anthropicPassthrough(env) {
102
+ const base = env.ANTHROPIC_BASE_URL;
103
+ if (!base)
104
+ return null;
105
+ const apiKey = env.ANTHROPIC_API_KEY ?? "";
106
+ const headers = {};
107
+ for (const line of (env.ANTHROPIC_CUSTOM_HEADERS ?? "").split("\n")) {
108
+ const at = line.indexOf(":");
109
+ if (at > 0)
110
+ headers[line.slice(0, at).trim()] = line.slice(at + 1).trim();
111
+ }
112
+ return {
113
+ wire: "anthropic",
114
+ upstreamBase: base,
115
+ upstreamKey: apiKey || (env.ANTHROPIC_AUTH_TOKEN ?? ""),
116
+ upstreamAuth: apiKey ? "x-api-key" : "bearer",
117
+ headers,
118
+ label: base,
119
+ };
120
+ }
@@ -175,6 +175,16 @@ export interface PlatformHooks {
175
175
  fetchUntrusted(url: string, init?: RequestInit, opts?: {
176
176
  maxBytes?: number;
177
177
  }): Promise<Response>;
178
+ /**
179
+ * May the host call this URL a tenant chose — a provider's base_url,
180
+ * before an eval's judge or a dashboard draft talks to it? A reason it may
181
+ * not, or null. This is the early refusal people can read; the guarantee
182
+ * is that every byte of such a call goes through fetchUntrusted (FOL-95).
183
+ * Default: hostUrlProblem below — https on an install that guards
184
+ * untrusted fetches, anything on a laptop (its own localhost included).
185
+ * A platform may bind a stricter check (the addresses a name resolves to).
186
+ */
187
+ checkUntrustedUrl(url: string): Promise<string | null>;
178
188
  /**
179
189
  * Hand a notify: webhook to whatever delivers it. The platform records
180
190
  * the delivery, makes the first attempt now and retries on a backoff from
@@ -207,6 +217,20 @@ export interface WebhookDeliveryOutcome {
207
217
  retrying?: boolean;
208
218
  }
209
219
  export declare const platform: PlatformHooks;
220
+ /** True when this process fetches tenant-chosen URLs through a guard: a
221
+ * platform bound fetchUntrusted. A laptop has not, and calls its own LAN on
222
+ * purpose. */
223
+ export declare function guardsUntrustedUrls(): boolean;
224
+ /**
225
+ * Why the host may not call a tenant's model endpoint at this URL, or null.
226
+ * On an install that guards untrusted fetches the endpoint must be https: the
227
+ * key rides in every request, and a plain-http base is also how a tenant
228
+ * names a cluster service. Private and internal addresses are refused by the
229
+ * guard itself, on every hop. FOLDRUN_EGRESS_ALLOW_PRIVATE=1 — the operator's
230
+ * own install calling its own network — lifts this as it lifts the guard. A
231
+ * laptop: always null.
232
+ */
233
+ export declare function hostUrlProblem(url: string): string | null;
210
234
  /** Install the platform's implementations. Partial: what is not given keeps its local default. */
211
235
  export declare function registerPlatform(hooks: Partial<PlatformHooks>): void;
212
236
  /**
@@ -12,6 +12,10 @@
12
12
  // and calls registerPlatform() once at boot. A process that forgets to is a
13
13
  // local install — which is exactly the failure that is safe.
14
14
  import { matchFilter } from "./trigger-filter.js";
15
+ /** Marks the laptop's fetchUntrusted. A symbol from the global registry, not
16
+ * the function itself: every bundle has its own copy of this module (see
17
+ * KEY below), and only the shared object says which one was registered. */
18
+ const LAPTOP_FETCH = Symbol.for("foldrun.laptop-fetch");
15
19
  const local = {
16
20
  edition: () => "self-hosted",
17
21
  async enqueueFlowRun(tenant, workspace, steps, flowName, modelOverride, tags = [], startedBy = null, opts = {}) {
@@ -40,7 +44,8 @@ const local = {
40
44
  platformKeyAllowed: () => true,
41
45
  galleryDir: () => null,
42
46
  noteOAuthRefresh: async () => undefined,
43
- fetchUntrusted: (url, init) => fetch(url, init),
47
+ fetchUntrusted: Object.assign((url, init) => fetch(url, init), { [LAPTOP_FETCH]: true }),
48
+ checkUntrustedUrl: async (url) => hostUrlProblem(url),
44
49
  async deliverWebhook(tenant, workspace, delivery) {
45
50
  const { attemptWebhook, newDeliveryId } = await import("./notify.js");
46
51
  const deliveryId = newDeliveryId();
@@ -60,6 +65,35 @@ const local = {
60
65
  const KEY = Symbol.for("foldrun.platform");
61
66
  const g = globalThis;
62
67
  export const platform = (g[KEY] ??= { ...local });
68
+ /** True when this process fetches tenant-chosen URLs through a guard: a
69
+ * platform bound fetchUntrusted. A laptop has not, and calls its own LAN on
70
+ * purpose. */
71
+ export function guardsUntrustedUrls() {
72
+ return !platform.fetchUntrusted[LAPTOP_FETCH];
73
+ }
74
+ /**
75
+ * Why the host may not call a tenant's model endpoint at this URL, or null.
76
+ * On an install that guards untrusted fetches the endpoint must be https: the
77
+ * key rides in every request, and a plain-http base is also how a tenant
78
+ * names a cluster service. Private and internal addresses are refused by the
79
+ * guard itself, on every hop. FOLDRUN_EGRESS_ALLOW_PRIVATE=1 — the operator's
80
+ * own install calling its own network — lifts this as it lifts the guard. A
81
+ * laptop: always null.
82
+ */
83
+ export function hostUrlProblem(url) {
84
+ if (!guardsUntrustedUrls() || process.env.FOLDRUN_EGRESS_ALLOW_PRIVATE === "1")
85
+ return null;
86
+ let u;
87
+ try {
88
+ u = new URL(url);
89
+ }
90
+ catch {
91
+ return `${url} is not a URL`;
92
+ }
93
+ if (u.protocol !== "https:")
94
+ return `${u.origin} is not https — this platform calls a model endpoint only over https`;
95
+ return null;
96
+ }
63
97
  /** Install the platform's implementations. Partial: what is not given keeps its local default. */
64
98
  export function registerPlatform(hooks) {
65
99
  Object.assign(platform, hooks, {
@@ -387,6 +387,7 @@ grantsOnly?: boolean): {
387
387
  upstreamBase: string;
388
388
  wire?: import("./translator.ts").TranslatorWire;
389
389
  upstreamKey: string;
390
+ upstreamAuth?: "bearer" | "x-api-key";
390
391
  headers?: Record<string, string>;
391
392
  maxTokensParam?: "max_tokens" | "max_completion_tokens";
392
393
  reasoningEffort?: boolean;
@@ -400,6 +401,7 @@ grantsOnly?: boolean): {
400
401
  upstreamBase: string;
401
402
  wire?: import("./translator.ts").TranslatorWire;
402
403
  upstreamKey: string;
404
+ upstreamAuth?: "bearer" | "x-api-key";
403
405
  headers?: Record<string, string>;
404
406
  maxTokensParam?: "max_tokens" | "max_completion_tokens";
405
407
  reasoningEffort?: boolean;
@@ -11,15 +11,21 @@ export declare function anthropicUsage(u: Json | undefined, wire: TranslatorWire
11
11
  cached: number;
12
12
  output: number;
13
13
  };
14
- export type TranslatorWire = "chat" | "responses";
14
+ /** `anthropic` is no translation at all: the host-side passthrough for an
15
+ * endpoint that already speaks Messages, so the SDK on the host talks only
16
+ * to loopback and every byte to the tenant's URL goes through the guard
17
+ * (FOL-95). A step never gets it (translatorSpecFor returns null there). */
18
+ export type TranslatorWire = "chat" | "responses" | "anthropic";
15
19
  export interface TranslatorSpec {
16
20
  /** The provider's base, e.g. https://api.openai.com/v1 — `/chat/completions`
17
21
  * or `/responses` is appended, by `wire`. */
18
22
  upstreamBase: string;
19
23
  /** Which OpenAI shape the endpoint speaks. Default chat. */
20
24
  wire?: TranslatorWire;
21
- /** Already resolved. Sent as `Authorization: Bearer`. */
25
+ /** Already resolved. Sent as `Authorization: Bearer`, or as `x-api-key`
26
+ * where `upstreamAuth` says so (the anthropic wire only). */
22
27
  upstreamKey: string;
28
+ upstreamAuth?: "bearer" | "x-api-key";
23
29
  /** Extra headers the provider wants, already resolved. */
24
30
  headers?: Record<string, string>;
25
31
  maxTokensParam?: "max_tokens" | "max_completion_tokens";
@@ -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
@@ -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) && collapseInto(file, delivery))
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 tmp = `${file}.tmp`;
164
- fs.writeFileSync(tmp, end.slice(0, cut) + JSON.stringify(merged) + "\n");
165
- fs.renameSync(tmp, file);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldrun/core",
3
- "version": "5.0.0",
3
+ "version": "6.0.0",
4
4
  "description": "Agents are just folders. The format, the runner, the checks.",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "type": "module",
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";
@@ -746,7 +746,14 @@ export async function checkAssertion(
746
746
  } catch (err) {
747
747
  return { assertion, passed: false, detail: `judge not run — ${err instanceof Error ? err.message : String(err)}` };
748
748
  }
749
- const said = await withModelEnv(m, (env) => judgeCall(assertion.value, output, model, effort, env));
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
+ }
750
757
  const { passed, detail } = judgeVerdict(said.text);
751
758
  return { assertion, passed, detail, costUsd: said.costUsd };
752
759
  }
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 when the
84
- * provider speaks Chat Completions or Responses — the same one a step uses
85
- * (runner.ts), started for this call and closed after it.
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
- if (!m.translator) return fn(m.env);
92
- const t = await startTranslator(m.translator);
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/translator.ts CHANGED
@@ -68,7 +68,11 @@ function usageBlock(u: { input: number; cached: number; output: number } | null)
68
68
  };
69
69
  }
70
70
 
71
- export type TranslatorWire = "chat" | "responses";
71
+ /** `anthropic` is no translation at all: the host-side passthrough for an
72
+ * endpoint that already speaks Messages, so the SDK on the host talks only
73
+ * to loopback and every byte to the tenant's URL goes through the guard
74
+ * (FOL-95). A step never gets it (translatorSpecFor returns null there). */
75
+ export type TranslatorWire = "chat" | "responses" | "anthropic";
72
76
 
73
77
  export interface TranslatorSpec {
74
78
  /** The provider's base, e.g. https://api.openai.com/v1 — `/chat/completions`
@@ -76,8 +80,10 @@ export interface TranslatorSpec {
76
80
  upstreamBase: string;
77
81
  /** Which OpenAI shape the endpoint speaks. Default chat. */
78
82
  wire?: TranslatorWire;
79
- /** Already resolved. Sent as `Authorization: Bearer`. */
83
+ /** Already resolved. Sent as `Authorization: Bearer`, or as `x-api-key`
84
+ * where `upstreamAuth` says so (the anthropic wire only). */
80
85
  upstreamKey: string;
86
+ upstreamAuth?: "bearer" | "x-api-key";
81
87
  /** Extra headers the provider wants, already resolved. */
82
88
  headers?: Record<string, string>;
83
89
  maxTokensParam?: "max_tokens" | "max_completion_tokens";
@@ -943,6 +949,8 @@ export async function startTranslator(spec: TranslatorSpec): Promise<RunningTran
943
949
  req.on("error", reject);
944
950
  });
945
951
 
952
+ if (spec.wire === "anthropic") return await passthrough(spec, base, label, req, url, raw, res, log, started);
953
+
946
954
  if (req.method === "POST" && url.pathname === "/v1/messages/count_tokens") {
947
955
  // No endpoint on the other side counts for us; four characters a
948
956
  // token is the estimate every tokenizer lands near for English.
@@ -1105,6 +1113,79 @@ export async function startTranslator(spec: TranslatorSpec): Promise<RunningTran
1105
1113
  };
1106
1114
  }
1107
1115
 
1116
+ /** Request headers the SDK's own call carries that the endpoint is owed:
1117
+ * the content type, and Anthropic's version and beta flags. Its credential
1118
+ * (the translator's key) is not one of them. */
1119
+ const PASSED_HEADERS = /^(content-type|accept|anthropic-[a-z0-9-]+)$/;
1120
+ /** Response headers that describe the bytes as they came off the wire —
1121
+ * fetch has already decoded them, so they would lie on the way back. */
1122
+ const HOP_HEADERS = new Set(["content-encoding", "content-length", "transfer-encoding", "connection", "keep-alive"]);
1123
+
1124
+ /**
1125
+ * The anthropic wire: the SDK's request, as it is, to the same path under the
1126
+ * tenant's base URL, with the tenant's key put on here — through
1127
+ * fetchUntrusted, so no private or cluster address, no redirect that takes
1128
+ * the key to another origin, and the answer read to UPSTREAM_MAX_BYTES. The
1129
+ * answer streams back as it arrives, status and headers included.
1130
+ */
1131
+ async function passthrough(
1132
+ spec: TranslatorSpec,
1133
+ base: string,
1134
+ label: string,
1135
+ req: http.IncomingMessage,
1136
+ url: URL,
1137
+ raw: string,
1138
+ res: http.ServerResponse,
1139
+ log: string[],
1140
+ started: number,
1141
+ ): Promise<void> {
1142
+ // Lowercase keys, so a header named twice in different case is one header,
1143
+ // and the provider's own headers last: one that carries its credential
1144
+ // itself (an Authorization or x-api-key in `headers:`) wins over the key
1145
+ // slot, as on the Chat Completions wire above.
1146
+ const headers: Record<string, string> = {};
1147
+ for (const [k, v] of Object.entries(req.headers)) if (PASSED_HEADERS.test(k) && typeof v === "string") headers[k] = v;
1148
+ if (spec.upstreamKey) {
1149
+ if (spec.upstreamAuth === "x-api-key") headers["x-api-key"] = spec.upstreamKey;
1150
+ else headers.authorization = `Bearer ${spec.upstreamKey}`;
1151
+ }
1152
+ for (const [k, v] of Object.entries(spec.headers ?? {})) headers[k.toLowerCase()] = v;
1153
+ const method = req.method ?? "GET";
1154
+ const target = `${base}${url.pathname}${url.search}`;
1155
+ let upstream: Response;
1156
+ try {
1157
+ upstream = await fetchUntrusted(target, { method, headers, body: method === "GET" || method === "HEAD" ? undefined : raw }, { sameOrigin: true, maxBytes: UPSTREAM_MAX_BYTES });
1158
+ } catch (err) {
1159
+ const message = err instanceof Error ? err.message : String(err);
1160
+ log.push(`${method} ${label}${url.pathname} → unreachable: ${message}`);
1161
+ res.writeHead(502, { "content-type": "application/json" });
1162
+ res.end(errorBody(502, `translator: could not reach ${label}: ${message}`));
1163
+ return;
1164
+ }
1165
+ const out: Record<string, string> = {};
1166
+ upstream.headers.forEach((v, k) => {
1167
+ if (!HOP_HEADERS.has(k)) out[k] = v;
1168
+ });
1169
+ res.writeHead(upstream.status, out);
1170
+ log.push(`${method} ${label}${url.pathname} → ${upstream.status} (${Date.now() - started}ms)`);
1171
+ const reader = upstream.body?.getReader();
1172
+ if (!reader) return void res.end();
1173
+ try {
1174
+ for (;;) {
1175
+ const { value, done } = await reader.read();
1176
+ if (done) break;
1177
+ res.write(value);
1178
+ }
1179
+ res.end();
1180
+ } catch (err) {
1181
+ // Mid-answer (the cap, a dropped connection): an Anthropic error event,
1182
+ // so a stream reads as failed, not as a reply that merely stopped.
1183
+ const message = err instanceof Error ? err.message : String(err);
1184
+ log.push(`translator error: ${message}`);
1185
+ res.end(`event: error\ndata: ${errorBody(500, `translator: ${message}`)}\n\n`);
1186
+ }
1187
+ }
1188
+
1108
1189
  /**
1109
1190
  * What a provider block needs from the translator, or null when the block
1110
1191
  * speaks Anthropic and needs none. Values only — this crosses into the run
package/src/webhook.ts CHANGED
@@ -163,7 +163,7 @@ const KEEP_DELIVERIES = 500;
163
163
  export function recordDelivery(tenant: string, workspace: string, delivery: HookDelivery) {
164
164
  const file = deliveriesFile(tenant, workspace);
165
165
  if (!fs.existsSync(path.dirname(file))) return; // no workspace, nothing to log against
166
- if (UNAUTHENTICATED.includes(delivery.outcome) && collapseInto(file, delivery)) return;
166
+ if (UNAUTHENTICATED.includes(delivery.outcome)) return recordRefusal(file, tenant, workspace, delivery);
167
167
  fs.appendFileSync(file, JSON.stringify(delivery) + "\n");
168
168
  // Bounded: compact to the newest KEEP when it doubles. Amortised cheap,
169
169
  // and a log that grows forever is a disk-full incident with a delay.
@@ -179,9 +179,42 @@ export function recordDelivery(tenant: string, workspace: string, delivery: Hook
179
179
  }
180
180
  }
181
181
 
182
+ /**
183
+ * A refusal — anyone's to send — never changes the workspace's directory: no
184
+ * new file, no tmp file, no rename. The directory's mtime is the workspace's
185
+ * deployedAt (store.ts listWorkspaces), and a stranger posting a bad token
186
+ * moved it (FOL-102). So a refusal goes into a log that already exists, in
187
+ * place (an append, or the last line rewritten), and is never the write that
188
+ * compacts it; a workspace with no log yet gets a server log line instead,
189
+ * at most one a minute per workspace.
190
+ */
191
+ function recordRefusal(file: string, tenant: string, workspace: string, delivery: HookDelivery) {
192
+ if (!fs.existsSync(file)) return noteRefusal(tenant, workspace, delivery);
193
+ if (collapseInto(file, delivery)) return;
194
+ fs.appendFileSync(file, JSON.stringify(delivery) + "\n");
195
+ }
196
+
197
+ const REFUSAL_NOTE_MS = 60_000;
198
+ const refusalNotes = new Map<string, { at: number; since: number }>();
199
+
200
+ function noteRefusal(tenant: string, workspace: string, delivery: HookDelivery) {
201
+ const key = `${tenant}/${workspace}`;
202
+ const now = Date.now();
203
+ const seen = refusalNotes.get(key);
204
+ if (seen && now - seen.at < REFUSAL_NOTE_MS) {
205
+ seen.since++;
206
+ return;
207
+ }
208
+ const more = seen?.since ? ` (and ${seen.since} more since the last note)` : "";
209
+ console.warn(`[hooks] refused a delivery to ${key}/${delivery.flow}: ${delivery.outcome}${more}`);
210
+ refusalNotes.set(key, { at: now, since: 0 });
211
+ if (refusalNotes.size > 10_000) refusalNotes.clear(); // bounded: a note lost beats memory
212
+ }
213
+
182
214
  /** Fold a refusal into the log's last line when that line is a refusal
183
215
  * too. The flow name is not part of the match: it comes from the URL, so
184
- * a flood that varies it would otherwise never collapse. True when folded. */
216
+ * a flood that varies it would otherwise never collapse. True when folded.
217
+ * Rewritten in place, never through a rename (recordRefusal says why). */
185
218
  function collapseInto(file: string, delivery: HookDelivery): boolean {
186
219
  let text: string;
187
220
  try {
@@ -204,9 +237,15 @@ function collapseInto(file: string, delivery: HookDelivery): boolean {
204
237
  first: last.first ?? last.t,
205
238
  ...(last.detail === "more than one flow name" || last.flow !== delivery.flow ? { detail: "more than one flow name" } : {}),
206
239
  };
207
- const tmp = `${file}.tmp`;
208
- fs.writeFileSync(tmp, end.slice(0, cut) + JSON.stringify(merged) + "\n");
209
- fs.renameSync(tmp, file);
240
+ const line = Buffer.from(JSON.stringify(merged) + "\n");
241
+ const at = Buffer.byteLength(end.slice(0, cut));
242
+ const fd = fs.openSync(file, "r+");
243
+ try {
244
+ fs.writeSync(fd, line, 0, line.length, at);
245
+ fs.ftruncateSync(fd, at + line.length);
246
+ } finally {
247
+ fs.closeSync(fd);
248
+ }
210
249
  return true;
211
250
  }
212
251