@foldrun/core 0.2.0 → 0.4.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/README.md +31 -10
- package/dist/src/approvals.d.ts +104 -1
- package/dist/src/approvals.js +192 -2
- package/dist/src/budget.d.ts +32 -0
- package/dist/src/budget.js +177 -0
- package/dist/src/catalog.js +2 -1
- package/dist/src/confine.js +6 -1
- package/dist/src/deploy.js +4 -2
- package/dist/src/egress.d.ts +19 -2
- package/dist/src/egress.js +2 -1
- package/dist/src/evals.d.ts +4 -0
- package/dist/src/evals.js +20 -11
- package/dist/src/flow-lint.d.ts +4 -0
- package/dist/src/flow-lint.js +108 -0
- package/dist/src/host-env.d.ts +8 -0
- package/dist/src/host-env.js +48 -0
- package/dist/src/json-schema.d.ts +12 -0
- package/dist/src/json-schema.js +202 -0
- package/dist/src/library.js +13 -1
- package/dist/src/notify.d.ts +79 -1
- package/dist/src/notify.js +243 -9
- package/dist/src/paths.d.ts +5 -0
- package/dist/src/paths.js +32 -0
- package/dist/src/platform.d.ts +112 -2
- package/dist/src/platform.js +20 -2
- package/dist/src/provider-health.d.ts +55 -0
- package/dist/src/provider-health.js +232 -0
- package/dist/src/run-container.d.ts +80 -4
- package/dist/src/run-container.js +176 -37
- package/dist/src/runner.d.ts +41 -5
- package/dist/src/runner.js +513 -81
- package/dist/src/script-tools.d.ts +12 -0
- package/dist/src/script-tools.js +10 -2
- package/dist/src/secret-health.d.ts +59 -0
- package/dist/src/secret-health.js +155 -0
- package/dist/src/secrets.d.ts +19 -1
- package/dist/src/secrets.js +44 -12
- package/dist/src/starter.js +4 -0
- package/dist/src/step-exec.d.ts +62 -6
- package/dist/src/step-exec.js +199 -68
- package/dist/src/store.d.ts +300 -3
- package/dist/src/store.js +574 -26
- package/dist/src/tar.js +2 -1
- package/dist/src/test-mode.d.ts +111 -0
- package/dist/src/test-mode.js +285 -0
- package/dist/src/tool-test.js +48 -21
- package/dist/src/translator.js +2 -1
- package/dist/src/trigger-log.d.ts +43 -0
- package/dist/src/trigger-log.js +122 -0
- package/dist/src/triggers.d.ts +20 -0
- package/dist/src/triggers.js +91 -10
- package/dist/src/webhook.d.ts +15 -3
- package/dist/src/webhook.js +11 -11
- package/package.json +6 -5
- package/src/approvals.ts +253 -2
- package/src/budget.ts +178 -0
- package/src/catalog.ts +2 -1
- package/src/confine.ts +6 -1
- package/src/deploy.ts +4 -1
- package/src/egress.ts +20 -4
- package/src/evals.ts +25 -9
- package/src/flow-lint.ts +118 -0
- package/src/host-env.ts +51 -0
- package/src/json-schema.ts +184 -0
- package/src/library.ts +13 -1
- package/src/notify.ts +272 -11
- package/src/paths.ts +29 -0
- package/src/platform.ts +119 -3
- package/src/provider-health.ts +274 -0
- package/src/run-container.ts +215 -35
- package/src/runner.ts +525 -73
- package/src/script-tools.ts +18 -2
- package/src/secret-health.ts +185 -0
- package/src/secrets.ts +44 -10
- package/src/starter.ts +4 -0
- package/src/step-exec.ts +176 -21
- package/src/storage.ts +8 -2
- package/src/store.ts +785 -25
- package/src/tar.ts +2 -1
- package/src/test-mode.ts +311 -0
- package/src/tool-test.ts +49 -20
- package/src/translator.ts +2 -1
- package/src/trigger-log.ts +159 -0
- package/src/triggers.ts +92 -10
- package/src/webhook.ts +17 -11
package/src/tar.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// argument about which library to add.
|
|
11
11
|
|
|
12
12
|
import zlib from "node:zlib";
|
|
13
|
+
import { trimSlashes } from "./paths.ts";
|
|
13
14
|
|
|
14
15
|
const BLOCK = 512;
|
|
15
16
|
|
|
@@ -100,7 +101,7 @@ export function stripRoot(entries: TarEntry[]): TarEntry[] {
|
|
|
100
101
|
* case, since a repo usually holds more than one thing.
|
|
101
102
|
*/
|
|
102
103
|
export function filesFromTarball(gz: Buffer, subdir = ""): { path: string; content: string }[] {
|
|
103
|
-
const prefix = subdir
|
|
104
|
+
const prefix = trimSlashes(subdir, "both");
|
|
104
105
|
const out: { path: string; content: string }[] = [];
|
|
105
106
|
|
|
106
107
|
for (const entry of stripRoot(readTarGz(gz))) {
|
package/src/test-mode.ts
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
// Test mode: a run that exercises everything and changes nothing outside.
|
|
2
|
+
//
|
|
3
|
+
// Every desk that sends — email, SMS, orders, posts — grew its own test
|
|
4
|
+
// switch inside its own script: a `test_to` address here, a `mode: test`
|
|
5
|
+
// there. On 2026-09-14 a send step edited its own gate file to get past a
|
|
6
|
+
// script's guard and texted 25 customers. A guard the script can reach is a
|
|
7
|
+
// guard the script can move; this one lives where the script cannot: on the
|
|
8
|
+
// run record, at the egress proxy, and in what the sandbox is handed.
|
|
9
|
+
//
|
|
10
|
+
// The rule has one table (below) so it is testable without a server, and
|
|
11
|
+
// three enforcement points that all read it:
|
|
12
|
+
//
|
|
13
|
+
// - the egress proxy: on a test lease, an outward write to a host that is
|
|
14
|
+
// not read-safe or a model provider is refused with 403 and a JSON body
|
|
15
|
+
// naming what would have been sent; Resend's sink address is the one
|
|
16
|
+
// exception, so a desk still gets a real message id back
|
|
17
|
+
// - the runner: send-capable secrets never enter the sandbox as values on
|
|
18
|
+
// a test run; the env var says TEST_MODE_WITHHELD instead, so a script
|
|
19
|
+
// that forgot to check fails at the provider with an auth error
|
|
20
|
+
// - the write-back: state/ and storage/ go INTO the sandbox as usual and
|
|
21
|
+
// come out into runs/<id>/test-writes/ instead of the real directories
|
|
22
|
+
//
|
|
23
|
+
// Nothing here weakens a live run: every function is a no-op unless the run
|
|
24
|
+
// says `test: true`.
|
|
25
|
+
|
|
26
|
+
import fs from "node:fs";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
|
|
29
|
+
/** Where a test run's email actually goes — Resend's delivered sink. */
|
|
30
|
+
export const TEST_SINK_EMAIL = "delivered@resend.dev";
|
|
31
|
+
/** Prefixed to every subject a test run sends, so the sink is readable. */
|
|
32
|
+
export const TEST_SUBJECT_PREFIX = "[TEST] ";
|
|
33
|
+
/** What a withheld secret's env var reads as inside the sandbox. */
|
|
34
|
+
export const WITHHELD = "TEST_MODE_WITHHELD";
|
|
35
|
+
/** Both are set to "1" in a test run's sandbox: the first is what a script
|
|
36
|
+
* checks, the second is the run-level spelling for verifies and flows. */
|
|
37
|
+
export const TEST_MODE_ENV = "FOLDRUN_TEST_MODE";
|
|
38
|
+
export const RUN_TEST_ENV = "FOLDRUN_RUN_TEST";
|
|
39
|
+
/** Under runs/<id>/, where diverted state/ and storage/ writes land. */
|
|
40
|
+
export const TEST_WRITES_DIR = "test-writes";
|
|
41
|
+
/** The workspace directories a test run may not write for real. */
|
|
42
|
+
export const TEST_DIVERTED_DIRS = ["state", "storage"] as const;
|
|
43
|
+
|
|
44
|
+
/** What a test run did instead of the real thing — one per intervention,
|
|
45
|
+
* on the run event that reports it, so the run page can list them. */
|
|
46
|
+
export interface TestEffect {
|
|
47
|
+
kind: "refused" | "redirected" | "diverted" | "withheld";
|
|
48
|
+
host?: string;
|
|
49
|
+
method?: string;
|
|
50
|
+
path?: string;
|
|
51
|
+
summary: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------- secrets
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Secret names that can make something happen outside. Matched by name
|
|
58
|
+
* because the name is all the runner knows before the value is used: the
|
|
59
|
+
* vault does not say what a key can do, and the provider hosts are only
|
|
60
|
+
* known for http tools, not for the scripts these are withheld from.
|
|
61
|
+
*/
|
|
62
|
+
export const SEND_CAPABLE_SECRETS: RegExp[] = [
|
|
63
|
+
/^RESEND_API_KEY$/,
|
|
64
|
+
/^TWILIO_/,
|
|
65
|
+
/^GETREACH_API_KEY$/,
|
|
66
|
+
/^GITHUB_TOKEN$/,
|
|
67
|
+
/^MONDAY_API_TOKEN$/,
|
|
68
|
+
/^OI_CRM_API_KEY$/,
|
|
69
|
+
/^CLOUDFLARE_/,
|
|
70
|
+
/^LINKEDIN_/,
|
|
71
|
+
/^MEDIUM_COOKIES$/,
|
|
72
|
+
/^TIKTOK_/,
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
export function isSendCapableSecret(name: string): boolean {
|
|
76
|
+
return SEND_CAPABLE_SECRETS.some((re) => re.test(name));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The secrets a test-run sandbox is handed, and which were withheld.
|
|
81
|
+
*
|
|
82
|
+
* `outward` — the step grants a script tool whose file says `outward: true`:
|
|
83
|
+
* then every secret is withheld, because a tool that declared itself a
|
|
84
|
+
* sender is not one to hand anything real. `allow` — a script tool said
|
|
85
|
+
* `test_mode: allow` (a reader that happens to use a listed key): the list
|
|
86
|
+
* is not applied. Outward wins over allow, because a step that has both
|
|
87
|
+
* has a sender in it.
|
|
88
|
+
*/
|
|
89
|
+
export function withholdSecrets(
|
|
90
|
+
secrets: Record<string, string>,
|
|
91
|
+
opts: { outward?: boolean; allow?: boolean } = {},
|
|
92
|
+
): { env: Record<string, string>; withheld: string[] } {
|
|
93
|
+
const env: Record<string, string> = {};
|
|
94
|
+
const withheld: string[] = [];
|
|
95
|
+
for (const [name, value] of Object.entries(secrets)) {
|
|
96
|
+
const hold = opts.outward || (!opts.allow && isSendCapableSecret(name));
|
|
97
|
+
env[name] = hold ? WITHHELD : value;
|
|
98
|
+
if (hold) withheld.push(name);
|
|
99
|
+
}
|
|
100
|
+
return { env, withheld };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------- the proxy
|
|
104
|
+
|
|
105
|
+
/** Methods that read. Anything else is a write until the host says otherwise. */
|
|
106
|
+
const READ_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Hosts an outward write may reach on a test run: model providers (the run
|
|
110
|
+
* cannot happen without them, and nothing there is a send), and read-only
|
|
111
|
+
* services whose POSTs are queries. A host is matched exactly, or by a
|
|
112
|
+
* leading `*.` for its subdomains. The list is deliberately short — the
|
|
113
|
+
* default is refusal, and a host earns its place here by having no way to
|
|
114
|
+
* reach a customer.
|
|
115
|
+
*/
|
|
116
|
+
export const READ_SAFE_HOSTS: string[] = [
|
|
117
|
+
// model providers
|
|
118
|
+
"api.anthropic.com",
|
|
119
|
+
"openrouter.ai",
|
|
120
|
+
"api.openai.com",
|
|
121
|
+
"generativelanguage.googleapis.com",
|
|
122
|
+
"api.groq.com",
|
|
123
|
+
"api.mistral.ai",
|
|
124
|
+
"api.together.xyz",
|
|
125
|
+
"api.deepseek.com",
|
|
126
|
+
"api.x.ai",
|
|
127
|
+
"api.fireworks.ai",
|
|
128
|
+
"api.cerebras.ai",
|
|
129
|
+
"api.perplexity.ai",
|
|
130
|
+
"*.openai.azure.com",
|
|
131
|
+
// queries that are POSTs
|
|
132
|
+
"api.dataforseo.com",
|
|
133
|
+
"*.dataforseo.com",
|
|
134
|
+
"*.svc.cluster.local",
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
export function isReadSafeHost(host: string): boolean {
|
|
138
|
+
const h = host.toLowerCase().replace(/:\d+$/, "");
|
|
139
|
+
return READ_SAFE_HOSTS.some((rule) =>
|
|
140
|
+
rule.startsWith("*.") ? h.endsWith(rule.slice(1)) && h !== rule.slice(2) : h === rule,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export type TestPolicy =
|
|
145
|
+
| { action: "allow" }
|
|
146
|
+
| { action: "refuse" }
|
|
147
|
+
| { action: "rewrite"; rewrite: "resend-email" };
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* What the proxy does with one request on a test lease. Pure: method, host
|
|
151
|
+
* and path in, a verdict out. Reads always pass — a test run has to be able
|
|
152
|
+
* to look at the world it is not allowed to change.
|
|
153
|
+
*/
|
|
154
|
+
export function testPolicy(method: string, host: string, path: string): TestPolicy {
|
|
155
|
+
const m = method.toUpperCase();
|
|
156
|
+
if (READ_METHODS.has(m)) return { action: "allow" };
|
|
157
|
+
const h = host.toLowerCase().replace(/:\d+$/, "");
|
|
158
|
+
// Resend's sink: the message really goes out, to an address that keeps
|
|
159
|
+
// it, with the subject marked — so the desk sees a real id and a real
|
|
160
|
+
// 200, and nobody receives anything.
|
|
161
|
+
if (h === "api.resend.com" && /^\/emails(\/|$|\?)/.test(path)) return { action: "rewrite", rewrite: "resend-email" };
|
|
162
|
+
if (isReadSafeHost(h)) return { action: "allow" };
|
|
163
|
+
return { action: "refuse" };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* A Resend `POST /emails` body with its recipients pointed at the sink and
|
|
168
|
+
* the subject marked. Returns the recipients it replaced, for the trace.
|
|
169
|
+
* Null when the body is not a JSON object — then there is nothing safe to
|
|
170
|
+
* rewrite, and the caller refuses instead.
|
|
171
|
+
*/
|
|
172
|
+
export function rewriteResendEmail(body: string): { body: string; to: string[] } | null {
|
|
173
|
+
let parsed: unknown;
|
|
174
|
+
try {
|
|
175
|
+
parsed = JSON.parse(body);
|
|
176
|
+
} catch {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
180
|
+
const mail = parsed as Record<string, unknown>;
|
|
181
|
+
const to: string[] = [];
|
|
182
|
+
for (const field of ["to", "cc", "bcc"] as const) {
|
|
183
|
+
const v = mail[field];
|
|
184
|
+
if (v === undefined || v === null) continue;
|
|
185
|
+
for (const addr of Array.isArray(v) ? v : [v]) to.push(String(addr));
|
|
186
|
+
// One sink address for the lot: two copies of the same test mail teach
|
|
187
|
+
// nobody anything, and cc/bcc to the sink would be exactly that.
|
|
188
|
+
if (field === "to") mail[field] = [TEST_SINK_EMAIL];
|
|
189
|
+
else delete mail[field];
|
|
190
|
+
}
|
|
191
|
+
if (!Array.isArray(mail.to) || mail.to.length === 0) mail.to = [TEST_SINK_EMAIL];
|
|
192
|
+
const subject = typeof mail.subject === "string" ? mail.subject : "";
|
|
193
|
+
mail.subject = subject.startsWith(TEST_SUBJECT_PREFIX) ? subject : `${TEST_SUBJECT_PREFIX}${subject}`;
|
|
194
|
+
return { body: JSON.stringify(mail), to };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The first `max` characters of a body, whitespace collapsed — enough to
|
|
198
|
+
* say what would have gone out, not enough to be the thing itself. The
|
|
199
|
+
* caller redacts; this only cuts. */
|
|
200
|
+
export function bodySummary(body: string, max = 200): string {
|
|
201
|
+
const flat = body.replace(/\s+/g, " ").trim();
|
|
202
|
+
return flat.length > max ? `${flat.slice(0, max)}…` : flat;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The recipient field of a body, if it has one — for `would_have.to`. */
|
|
206
|
+
export function recipientsOf(body: string): string | string[] | null {
|
|
207
|
+
try {
|
|
208
|
+
const parsed = JSON.parse(body) as Record<string, unknown>;
|
|
209
|
+
if (parsed && typeof parsed === "object") {
|
|
210
|
+
for (const key of ["to", "To", "recipient", "phone", "email"]) {
|
|
211
|
+
const v = parsed[key];
|
|
212
|
+
if (typeof v === "string" || Array.isArray(v)) return v as string | string[];
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
} catch {
|
|
216
|
+
// a form body: Twilio's Messages endpoint
|
|
217
|
+
const m = body.match(/(?:^|&)To=([^&]*)/);
|
|
218
|
+
if (m) return decodeURIComponent(m[1].replace(/\+/g, " "));
|
|
219
|
+
}
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The 403 a refused write is answered with. JSON, so a script that parses
|
|
224
|
+
* the error sees `test_mode` and can say so instead of retrying. */
|
|
225
|
+
export function refusalBody(method: string, host: string, path: string, body: string): string {
|
|
226
|
+
return JSON.stringify({
|
|
227
|
+
test_mode: true,
|
|
228
|
+
error: { type: "test_mode", message: `test run: ${method} ${host}${path} refused — nothing was sent` },
|
|
229
|
+
would_have: { method, host, path, to: recipientsOf(body), body: bodySummary(body) },
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---------------------------------------------------------------- write-back
|
|
234
|
+
|
|
235
|
+
/** Is this workspace-relative path one a test run diverts? */
|
|
236
|
+
export function isDivertedPath(rel: string): boolean {
|
|
237
|
+
const norm = rel.replaceAll("\\", "/");
|
|
238
|
+
return TEST_DIVERTED_DIRS.some((d) => norm === d || norm.startsWith(`${d}/`));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The line the trace and the run page show for one diverted write. */
|
|
242
|
+
export function divertedSummary(rel: string, was: Buffer | null, next: Buffer): string {
|
|
243
|
+
if (was === null) return `would have created ${rel} (${next.length} bytes)`;
|
|
244
|
+
const before = was.toString("utf8").split("\n").length;
|
|
245
|
+
const after = next.toString("utf8").split("\n").length;
|
|
246
|
+
const delta = after - before;
|
|
247
|
+
return `would have written ${rel}${delta > 0 ? `, +${delta} line${delta === 1 ? "" : "s"}` : delta < 0 ? `, ${delta} lines` : ""}`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The in-process path has no copy-in and copy-out — a step writes the
|
|
252
|
+
* workspace directly — so a test run there snapshots state/ and storage/
|
|
253
|
+
* before the step and, after it, moves what changed under the run and
|
|
254
|
+
* puts the originals back. Same outcome as the sandbox's divert, reached
|
|
255
|
+
* from the other side. Used by `foldrun run --test`.
|
|
256
|
+
*/
|
|
257
|
+
export function snapshotDivertedDirs(wsRoot: string): Map<string, Buffer> {
|
|
258
|
+
const snap = new Map<string, Buffer>();
|
|
259
|
+
for (const dir of TEST_DIVERTED_DIRS) {
|
|
260
|
+
const root = path.join(wsRoot, dir);
|
|
261
|
+
if (!fs.existsSync(root)) continue;
|
|
262
|
+
for (const entry of fs.readdirSync(root, { recursive: true, withFileTypes: true })) {
|
|
263
|
+
if (!entry.isFile()) continue;
|
|
264
|
+
const abs = path.join(entry.parentPath, entry.name);
|
|
265
|
+
snap.set(path.relative(wsRoot, abs).replaceAll("\\", "/"), fs.readFileSync(abs));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return snap;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Undo a step's writes to the diverted directories, keeping each changed
|
|
272
|
+
* or new file under `to` by its workspace-relative path. Deletions are
|
|
273
|
+
* undone too — the original is put back. Returns what was set aside. */
|
|
274
|
+
export function restoreDivertedDirs(
|
|
275
|
+
wsRoot: string,
|
|
276
|
+
before: Map<string, Buffer>,
|
|
277
|
+
to: string,
|
|
278
|
+
note: (rel: string, summary: string) => void,
|
|
279
|
+
): string[] {
|
|
280
|
+
const now = snapshotDivertedDirs(wsRoot);
|
|
281
|
+
const aside: string[] = [];
|
|
282
|
+
for (const [rel, next] of now) {
|
|
283
|
+
const was = before.get(rel) ?? null;
|
|
284
|
+
if (was && was.equals(next)) continue;
|
|
285
|
+
const dest = path.join(to, rel);
|
|
286
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
287
|
+
fs.writeFileSync(dest, next);
|
|
288
|
+
aside.push(rel);
|
|
289
|
+
note(rel, divertedSummary(rel, was, next));
|
|
290
|
+
if (was) fs.writeFileSync(path.join(wsRoot, rel), was);
|
|
291
|
+
else fs.rmSync(path.join(wsRoot, rel), { force: true });
|
|
292
|
+
}
|
|
293
|
+
for (const [rel, was] of before) {
|
|
294
|
+
if (now.has(rel)) continue;
|
|
295
|
+
fs.mkdirSync(path.dirname(path.join(wsRoot, rel)), { recursive: true });
|
|
296
|
+
fs.writeFileSync(path.join(wsRoot, rel), was);
|
|
297
|
+
note(rel, `would have deleted ${rel}`);
|
|
298
|
+
aside.push(rel);
|
|
299
|
+
}
|
|
300
|
+
return aside;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** `[test] ` in front of a headline, once. */
|
|
304
|
+
export function testHeadline(summary: string): string {
|
|
305
|
+
return summary.startsWith("[test]") ? summary : `[test] ${summary}`;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** The env every test-run sandbox gets, beside its secrets. */
|
|
309
|
+
export function testModeEnv(): Record<string, string> {
|
|
310
|
+
return { [TEST_MODE_ENV]: "1", [RUN_TEST_ENV]: "1" };
|
|
311
|
+
}
|
package/src/tool-test.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { libraryDir } from "./library.ts";
|
|
|
25
25
|
import { secretsUsedByApi } from "./api-tools.ts";
|
|
26
26
|
import { commandFor, resolveRunPath } from "./script-tools.ts";
|
|
27
27
|
import { parseRuntime, prepareRuntime } from "./runtime.ts";
|
|
28
|
+
import { hostSafeEnv } from "./host-env.ts";
|
|
28
29
|
|
|
29
30
|
export interface ToolTestResult {
|
|
30
31
|
ok: boolean;
|
|
@@ -46,24 +47,11 @@ const TIMEOUT_MS = 15_000;
|
|
|
46
47
|
// process.env. That env holds FOLDRUN_SECRET_KEY (the global install key that
|
|
47
48
|
// signs every tenant's webhook tokens and git-push secrets), plus the S3/DB/
|
|
48
49
|
// Stripe/GitHub credentials: handing them to a `console.log(process.env)`
|
|
49
|
-
// script tool is cross-tenant compromise. The child instead starts from
|
|
50
|
-
//
|
|
51
|
-
// and
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
"LC_ALL", "LC_CTYPE", "TZ", "TERM", "SHELL", "USER", "LOGNAME",
|
|
55
|
-
"NODE_ENV", // not a secret; some interpreters/tools read it.
|
|
56
|
-
// Windows equivalents, harmless elsewhere.
|
|
57
|
-
"SYSTEMROOT", "COMSPEC", "PATHEXT", "WINDIR",
|
|
58
|
-
];
|
|
59
|
-
function hostSafeBaseEnv(): NodeJS.ProcessEnv {
|
|
60
|
-
const base: Record<string, string> = {};
|
|
61
|
-
for (const k of HOST_BASE_ENV_KEYS) {
|
|
62
|
-
const v = process.env[k];
|
|
63
|
-
if (v !== undefined) base[k] = v;
|
|
64
|
-
}
|
|
65
|
-
return base as NodeJS.ProcessEnv;
|
|
66
|
-
}
|
|
50
|
+
// script tool is cross-tenant compromise. The child instead starts from the
|
|
51
|
+
// allowlisted host base (host-env.ts — the same one every in-process script,
|
|
52
|
+
// verify shell and SDK Bash tool starts from) and gets the tool's own
|
|
53
|
+
// declared secrets layered on top by the callers.
|
|
54
|
+
const hostSafeBaseEnv = (): NodeJS.ProcessEnv => hostSafeEnv();
|
|
67
55
|
|
|
68
56
|
const clip = (s: string) =>
|
|
69
57
|
s.length > MAX_DETAIL ? `${s.slice(0, MAX_DETAIL)}\n… truncated` : s;
|
|
@@ -144,7 +132,7 @@ export async function testTool(
|
|
|
144
132
|
}
|
|
145
133
|
|
|
146
134
|
if (def.kind === "script") {
|
|
147
|
-
|
|
135
|
+
let run = String(def.spec.run ?? "");
|
|
148
136
|
// A run's working directory is the calling AGENT's folder, not the
|
|
149
137
|
// workspace root — and scripts rely on that. `path.resolve(cwd, "..",
|
|
150
138
|
// "..", "state", f)` is the ordinary way to reach workspace state, and
|
|
@@ -163,7 +151,48 @@ export async function testTool(
|
|
|
163
151
|
// workspace root is wrong but it exists, and the note says so rather
|
|
164
152
|
// than failing a test over a workspace that cannot run anything yet.
|
|
165
153
|
const cwd = caller ? path.join(dir, "agents", caller.name) : dir;
|
|
166
|
-
|
|
154
|
+
|
|
155
|
+
// A single-file tool carries its program in a fenced block rather than
|
|
156
|
+
// in a file: `run:` is empty and `code:` holds it. The RUNTIME writes it
|
|
157
|
+
// out at call time (script-tools.ts) and has always worked; the tester
|
|
158
|
+
// only ever looked for a file, so the Test button failed for every one
|
|
159
|
+
// of them while the same tool ran fine in a flow — which is the worst
|
|
160
|
+
// possible way round, since that button is where a developer goes to
|
|
161
|
+
// find out whether their work is broken.
|
|
162
|
+
//
|
|
163
|
+
// Materialised the same way and in the same place a call would, so the
|
|
164
|
+
// test runs the identical program from the identical directory.
|
|
165
|
+
const inlineCode = typeof def.spec.code === "string" ? def.spec.code : "";
|
|
166
|
+
const inline = !run && !!inlineCode;
|
|
167
|
+
if (inline && !caller) {
|
|
168
|
+
// Nowhere to stand: the file would land at the workspace root, which
|
|
169
|
+
// is not where a run puts it and not somewhere a deploy diff should
|
|
170
|
+
// find it. Say what is missing instead.
|
|
171
|
+
return done({
|
|
172
|
+
ok: false, transport: "script", missingSecrets: [],
|
|
173
|
+
summary: "no agent to test it from",
|
|
174
|
+
detail: "This tool carries its code inline and is materialised inside the calling agent's folder. Grant it to an agent (tools:) and test again.",
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
if (inline) {
|
|
178
|
+
const ext = typeof def.spec.codeExt === "string" ? def.spec.codeExt : ".mjs";
|
|
179
|
+
const codeDir = path.join(cwd, ".tool-code");
|
|
180
|
+
try {
|
|
181
|
+
fs.mkdirSync(codeDir, { recursive: true });
|
|
182
|
+
fs.writeFileSync(path.join(codeDir, `${def.name}${ext}`), inlineCode, { mode: 0o755 });
|
|
183
|
+
run = `.tool-code/${def.name}${ext}`;
|
|
184
|
+
} catch (err) {
|
|
185
|
+
return done({
|
|
186
|
+
ok: false, transport: "script", missingSecrets: [],
|
|
187
|
+
summary: "its inline code could not be written",
|
|
188
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// A materialised file was just written into cwd, so it resolves from
|
|
194
|
+
// there. A declared `run:` keeps resolving exactly as it always has.
|
|
195
|
+
const file = resolveRunPath(inline ? cwd : path.join(dir, "agents", "_probe"), run, libraryDir(tenant, "scripts"));
|
|
167
196
|
|
|
168
197
|
if (!fs.existsSync(file)) {
|
|
169
198
|
return done({
|
package/src/translator.ts
CHANGED
|
@@ -30,6 +30,7 @@ import http from "node:http";
|
|
|
30
30
|
import crypto from "node:crypto";
|
|
31
31
|
import { PROTECTED_PARAMS } from "./providers.ts";
|
|
32
32
|
import { viaEgress } from "./egress.ts";
|
|
33
|
+
import { trimSlashes } from "./paths.ts";
|
|
33
34
|
|
|
34
35
|
// ------------------------------------------------------------------ shapes
|
|
35
36
|
|
|
@@ -845,7 +846,7 @@ function upstreamMessage(text: string, status: number): string {
|
|
|
845
846
|
export async function startTranslator(spec: TranslatorSpec): Promise<RunningTranslator> {
|
|
846
847
|
const key = `fr-${crypto.randomBytes(16).toString("hex")}`;
|
|
847
848
|
const log: string[] = [];
|
|
848
|
-
const base = spec.upstreamBase
|
|
849
|
+
const base = trimSlashes(spec.upstreamBase);
|
|
849
850
|
const label = spec.label ?? base;
|
|
850
851
|
|
|
851
852
|
const server = http.createServer(async (req, res) => {
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Why nothing happened.
|
|
2
|
+
//
|
|
3
|
+
// A run that exists can be read. A run that does not exist leaves no record
|
|
4
|
+
// anywhere, and "the hook fired but nothing ran" is the hardest question the
|
|
5
|
+
// platform gets asked — because every honest answer to it is a thing that
|
|
6
|
+
// deliberately did NOT happen:
|
|
7
|
+
//
|
|
8
|
+
// the delivery was a duplicate idempotency:
|
|
9
|
+
// the burst is still being waited out debounce:
|
|
10
|
+
// it arrived inside the window throttle:
|
|
11
|
+
// the flow is switched off disable_after:
|
|
12
|
+
// yesterday's run is still going overlap: skip
|
|
13
|
+
// the platform was down at 05:00 catchup: none
|
|
14
|
+
//
|
|
15
|
+
// Six different reasons, all of them correct behaviour, none of them visible.
|
|
16
|
+
// Before this they were console lines on a box nobody reads.
|
|
17
|
+
//
|
|
18
|
+
// This is deliberately NOT the hook delivery log next door. That one answers
|
|
19
|
+
// "did the request reach us and was it authentic" — a bad token, a failed
|
|
20
|
+
// signature. This one answers "did an event become a run". A delivery can be
|
|
21
|
+
// perfectly authentic and still not start anything, which is exactly the
|
|
22
|
+
// case that needed writing down.
|
|
23
|
+
//
|
|
24
|
+
// One bounded JSONL per workspace, the same shape and the same compaction as
|
|
25
|
+
// the delivery log: this is diagnostics, not an audit trail, and it must
|
|
26
|
+
// never become a disk-full incident with a delay.
|
|
27
|
+
|
|
28
|
+
import fs from "node:fs";
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
import { workspaceDir } from "./store.ts";
|
|
31
|
+
|
|
32
|
+
/** What became of one trigger firing. `started` is here too: without it the
|
|
33
|
+
* log answers "why did nothing run" and not "how often does anything",
|
|
34
|
+
* and the ratio is the whole point of the page. */
|
|
35
|
+
export type TriggerOutcome =
|
|
36
|
+
| "started"
|
|
37
|
+
| "duplicate"
|
|
38
|
+
| "throttled"
|
|
39
|
+
| "debounced"
|
|
40
|
+
| "quarantined"
|
|
41
|
+
| "overlap-skipped"
|
|
42
|
+
| "missed"
|
|
43
|
+
| "failed";
|
|
44
|
+
|
|
45
|
+
export interface TriggerEvent {
|
|
46
|
+
t: string;
|
|
47
|
+
flow: string;
|
|
48
|
+
/** The flow's `trigger:` — schedule, webhook, storage, watch, flow, email. */
|
|
49
|
+
trigger: string;
|
|
50
|
+
outcome: TriggerOutcome;
|
|
51
|
+
/** The sentence a person reads. Always says which key decided, so the fix
|
|
52
|
+
* is one search away in their own flow file. */
|
|
53
|
+
detail?: string;
|
|
54
|
+
runId?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function logFile(tenant: string, workspace: string) {
|
|
58
|
+
return path.join(workspaceDir(tenant, workspace), "trigger-log.jsonl");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const KEEP = 500;
|
|
62
|
+
|
|
63
|
+
/** Never throws. A diagnostic that can break a trigger is worse than no
|
|
64
|
+
* diagnostic: the whole point is to be reading it on the bad morning. */
|
|
65
|
+
export function recordTriggerEvent(tenant: string, workspace: string, event: TriggerEvent): void {
|
|
66
|
+
try {
|
|
67
|
+
const file = logFile(tenant, workspace);
|
|
68
|
+
if (!fs.existsSync(path.dirname(file))) return;
|
|
69
|
+
fs.appendFileSync(file, JSON.stringify(event) + "\n");
|
|
70
|
+
const lines = fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
|
|
71
|
+
if (lines.length > KEEP * 2) {
|
|
72
|
+
const tmp = `${file}.tmp`;
|
|
73
|
+
fs.writeFileSync(tmp, lines.slice(-KEEP).join("\n") + "\n");
|
|
74
|
+
fs.renameSync(tmp, file);
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
// best effort, always
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function readTriggerEvents(tenant: string, workspace: string, limit = 200): TriggerEvent[] {
|
|
82
|
+
let lines: string[];
|
|
83
|
+
try {
|
|
84
|
+
lines = fs.readFileSync(logFile(tenant, workspace), "utf8").split("\n").filter(Boolean);
|
|
85
|
+
} catch {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
// One line at a time: a torn line — an append racing a compaction from
|
|
89
|
+
// another pod — must lose that line, not the whole log, on exactly the
|
|
90
|
+
// morning the log exists for.
|
|
91
|
+
const out: TriggerEvent[] = [];
|
|
92
|
+
for (const line of lines.slice(-limit).reverse()) {
|
|
93
|
+
try {
|
|
94
|
+
const e = JSON.parse(line) as TriggerEvent;
|
|
95
|
+
if (e && typeof e.t === "string") out.push(e);
|
|
96
|
+
} catch {
|
|
97
|
+
// skip the torn line
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface FlowTriggerSummary {
|
|
104
|
+
flow: string;
|
|
105
|
+
trigger: string;
|
|
106
|
+
fired: number;
|
|
107
|
+
started: number;
|
|
108
|
+
/** Every outcome that was not a start, by reason, biggest first. */
|
|
109
|
+
dropped: { outcome: TriggerOutcome; count: number; detail: string }[];
|
|
110
|
+
lastFiredAt: string | null;
|
|
111
|
+
lastStartedAt: string | null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* One row per flow: how often it fired, how often that became a run, and
|
|
116
|
+
* what happened to the difference.
|
|
117
|
+
*
|
|
118
|
+
* The gap between `fired` and `started` is the number someone is looking
|
|
119
|
+
* for. A flow with forty fires and two runs is either working exactly as
|
|
120
|
+
* configured or badly misconfigured, and the reasons column is what tells
|
|
121
|
+
* those apart without opening the flow file.
|
|
122
|
+
*/
|
|
123
|
+
export function summariseTriggers(
|
|
124
|
+
tenant: string,
|
|
125
|
+
workspace: string,
|
|
126
|
+
sinceMs: number | null = null,
|
|
127
|
+
): FlowTriggerSummary[] {
|
|
128
|
+
const rows = new Map<string, FlowTriggerSummary>();
|
|
129
|
+
const reasons = new Map<string, Map<TriggerOutcome, { count: number; detail: string }>>();
|
|
130
|
+
for (const e of readTriggerEvents(tenant, workspace, 500)) {
|
|
131
|
+
if (sinceMs !== null && new Date(e.t).getTime() < sinceMs) continue;
|
|
132
|
+
let row = rows.get(e.flow);
|
|
133
|
+
if (!row) {
|
|
134
|
+
row = { flow: e.flow, trigger: e.trigger, fired: 0, started: 0, dropped: [], lastFiredAt: null, lastStartedAt: null };
|
|
135
|
+
rows.set(e.flow, row);
|
|
136
|
+
reasons.set(e.flow, new Map());
|
|
137
|
+
}
|
|
138
|
+
row.fired++;
|
|
139
|
+
// Events come back newest first, so the first of each is the latest.
|
|
140
|
+
row.lastFiredAt ??= e.t;
|
|
141
|
+
if (e.outcome === "started") {
|
|
142
|
+
row.started++;
|
|
143
|
+
row.lastStartedAt ??= e.t;
|
|
144
|
+
} else {
|
|
145
|
+
const byReason = reasons.get(e.flow)!;
|
|
146
|
+
const seen = byReason.get(e.outcome);
|
|
147
|
+
// Keep the newest detail: "the last run started 4 minutes ago" is more
|
|
148
|
+
// use than the same sentence from Tuesday.
|
|
149
|
+
if (seen) seen.count++;
|
|
150
|
+
else byReason.set(e.outcome, { count: 1, detail: e.detail ?? "" });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
for (const [flow, byReason] of reasons) {
|
|
154
|
+
rows.get(flow)!.dropped = [...byReason.entries()]
|
|
155
|
+
.map(([outcome, v]) => ({ outcome, ...v }))
|
|
156
|
+
.sort((a, b) => b.count - a.count);
|
|
157
|
+
}
|
|
158
|
+
return [...rows.values()].sort((a, b) => b.fired - a.fired || a.flow.localeCompare(b.flow));
|
|
159
|
+
}
|