@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/src/tool-test.ts CHANGED
@@ -19,7 +19,8 @@ import { spawn } from "node:child_process";
19
19
  import fs from "node:fs";
20
20
  import path from "node:path";
21
21
 
22
- import { resolveSecrets, materializeSecrets } from "./secrets.ts";
22
+ import { resolveSecrets, materializeSecrets, secretSites } from "./secrets.ts";
23
+ import { siteCovers } from "./network.ts";
23
24
  import { listAgents, workspaceDir, type ToolDef } from "./store.ts";
24
25
  import { libraryDir } from "./library.ts";
25
26
  import { secretsUsedByApi } from "./api-tools.ts";
@@ -96,6 +97,27 @@ export interface ToolTestInput {
96
97
  args?: Record<string, string>;
97
98
  }
98
99
 
100
+ /**
101
+ * A script or a command-started MCP server is the tenant's own code. On an
102
+ * install that isolates runs (FOLDRUN_RUN_ISOLATION) that code only ever runs
103
+ * in a step's sandbox — the web pod that answers Test is not one: it holds
104
+ * the platform's keys and every account's files (29 Oct 2026, OWASP review;
105
+ * hooks' Test was already refused the same way).
106
+ */
107
+ function hostCodeRefusal(transport: "script" | "mcp"): Omit<ToolTestResult, "ms"> | null {
108
+ if (!process.env.FOLDRUN_RUN_ISOLATION) return null;
109
+ return {
110
+ ok: false,
111
+ transport,
112
+ missingSecrets: [],
113
+ summary: "this tool runs your own code, which this install runs only inside a step's sandbox — not from here",
114
+ detail:
115
+ "Test it locally with\n foldrun tool test <name>\n" +
116
+ "or with a test run of a flow whose agent has it under tools:, e.g.\n" +
117
+ " foldrun invoke <flow> --to <workspace> --test --wait",
118
+ };
119
+ }
120
+
99
121
  export async function testTool(
100
122
  tenant: string,
101
123
  workspace: string,
@@ -170,6 +192,19 @@ export async function testTool(
170
192
  detail: `base: ${api.base}\npath: ${probePath}`,
171
193
  });
172
194
  }
195
+ // A secret bound to sites is filled in a run only by the egress proxy,
196
+ // only for those sites. The tester fills secrets itself, so it must ask
197
+ // the same question — or any editor could write a tool whose base is
198
+ // their own server and "test" the account's Stripe key out to it.
199
+ const bound = secretSites(tenant, needed, workspace);
200
+ const offSite = Object.entries(bound).filter(([, sites]) => !sites.some((site) => siteCovers(site, url.hostname))).map(([n]) => n);
201
+ if (offSite.length) {
202
+ return done({
203
+ ok: false, transport: "http", missingSecrets: missing,
204
+ summary: `${offSite.join(", ")} ${offSite.length === 1 ? "is" : "are"} bound to other sites than ${url.hostname} — not sent`,
205
+ detail: offSite.map((n) => `${n}: ${bound[n].join(", ")}`).join("\n"),
206
+ });
207
+ }
173
208
  for (const [k, v] of Object.entries(api.query)) url.searchParams.set(k, substitute(v, env));
174
209
 
175
210
  const headers: Record<string, string> = {};
@@ -219,6 +254,8 @@ export async function testTool(
219
254
  }
220
255
 
221
256
  if (def.kind === "script") {
257
+ const refused = hostCodeRefusal("script");
258
+ if (refused) return done(refused);
222
259
  let run = String(def.spec.run ?? "");
223
260
  // A run's working directory is the calling AGENT's folder, not the
224
261
  // workspace root — and scripts rely on that. `path.resolve(cwd, "..",
@@ -404,6 +441,8 @@ export async function testTool(
404
441
  detail: "",
405
442
  });
406
443
  }
444
+ const refusedMcp = hostCodeRefusal("mcp");
445
+ if (refusedMcp) return done(refusedMcp);
407
446
 
408
447
  const named = Object.entries(spec.env ?? {})
409
448
  .map(([, v]) => String(v).match(/^\$\{([A-Z0-9_]+)\}$/)?.[1])
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