@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/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
|
-
|
|
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)
|
|
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
|
|
208
|
-
|
|
209
|
-
fs.
|
|
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
|
|