@foldrun/core 0.2.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.
Files changed (166) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +239 -0
  3. package/dist/index.d.ts +32 -0
  4. package/dist/index.js +43 -0
  5. package/dist/src/agent-tools.d.ts +31 -0
  6. package/dist/src/agent-tools.js +99 -0
  7. package/dist/src/agents-md.d.ts +9 -0
  8. package/dist/src/agents-md.js +83 -0
  9. package/dist/src/api-tools.d.ts +38 -0
  10. package/dist/src/api-tools.js +313 -0
  11. package/dist/src/approvals.d.ts +39 -0
  12. package/dist/src/approvals.js +103 -0
  13. package/dist/src/arrange.d.ts +8 -0
  14. package/dist/src/arrange.js +40 -0
  15. package/dist/src/catalog.d.ts +108 -0
  16. package/dist/src/catalog.js +255 -0
  17. package/dist/src/completions.d.ts +61 -0
  18. package/dist/src/completions.js +578 -0
  19. package/dist/src/confine.d.ts +31 -0
  20. package/dist/src/confine.js +298 -0
  21. package/dist/src/container.d.ts +30 -0
  22. package/dist/src/container.js +172 -0
  23. package/dist/src/context-tools.d.ts +81 -0
  24. package/dist/src/context-tools.js +347 -0
  25. package/dist/src/deploy.d.ts +100 -0
  26. package/dist/src/deploy.js +377 -0
  27. package/dist/src/egress.d.ts +75 -0
  28. package/dist/src/egress.js +133 -0
  29. package/dist/src/evals.d.ts +150 -0
  30. package/dist/src/evals.js +651 -0
  31. package/dist/src/flow-lint.d.ts +16 -0
  32. package/dist/src/flow-lint.js +177 -0
  33. package/dist/src/git-hook.d.ts +1 -0
  34. package/dist/src/git-hook.js +70 -0
  35. package/dist/src/git.d.ts +37 -0
  36. package/dist/src/git.js +101 -0
  37. package/dist/src/gitrepo.d.ts +95 -0
  38. package/dist/src/gitrepo.js +355 -0
  39. package/dist/src/history.d.ts +82 -0
  40. package/dist/src/history.js +259 -0
  41. package/dist/src/kinds.d.ts +95 -0
  42. package/dist/src/kinds.js +421 -0
  43. package/dist/src/library.d.ts +94 -0
  44. package/dist/src/library.js +341 -0
  45. package/dist/src/linkable.d.ts +20 -0
  46. package/dist/src/linkable.js +113 -0
  47. package/dist/src/model-probe.d.ts +21 -0
  48. package/dist/src/model-probe.js +81 -0
  49. package/dist/src/notify.d.ts +61 -0
  50. package/dist/src/notify.js +252 -0
  51. package/dist/src/oauth-presets.d.ts +13 -0
  52. package/dist/src/oauth-presets.js +39 -0
  53. package/dist/src/okf.d.ts +220 -0
  54. package/dist/src/okf.js +656 -0
  55. package/dist/src/openapi.d.ts +66 -0
  56. package/dist/src/openapi.js +342 -0
  57. package/dist/src/paths.d.ts +33 -0
  58. package/dist/src/paths.js +78 -0
  59. package/dist/src/platform.d.ts +32 -0
  60. package/dist/src/platform.js +48 -0
  61. package/dist/src/providers.d.ts +47 -0
  62. package/dist/src/providers.js +114 -0
  63. package/dist/src/refs.d.ts +20 -0
  64. package/dist/src/refs.js +78 -0
  65. package/dist/src/run-container.d.ts +227 -0
  66. package/dist/src/run-container.js +760 -0
  67. package/dist/src/runner.d.ts +241 -0
  68. package/dist/src/runner.js +3215 -0
  69. package/dist/src/runtime.d.ts +41 -0
  70. package/dist/src/runtime.js +357 -0
  71. package/dist/src/script-tools.d.ts +62 -0
  72. package/dist/src/script-tools.js +296 -0
  73. package/dist/src/secret-files.d.ts +12 -0
  74. package/dist/src/secret-files.js +92 -0
  75. package/dist/src/secrets.d.ts +147 -0
  76. package/dist/src/secrets.js +626 -0
  77. package/dist/src/starter.d.ts +21 -0
  78. package/dist/src/starter.js +399 -0
  79. package/dist/src/step-exec.d.ts +136 -0
  80. package/dist/src/step-exec.js +391 -0
  81. package/dist/src/storage.d.ts +123 -0
  82. package/dist/src/storage.js +640 -0
  83. package/dist/src/store.d.ts +1052 -0
  84. package/dist/src/store.js +2378 -0
  85. package/dist/src/tar.d.ts +30 -0
  86. package/dist/src/tar.js +111 -0
  87. package/dist/src/tool-names.d.ts +39 -0
  88. package/dist/src/tool-names.js +79 -0
  89. package/dist/src/tool-programs.d.ts +18 -0
  90. package/dist/src/tool-programs.js +52 -0
  91. package/dist/src/tool-test.d.ts +19 -0
  92. package/dist/src/tool-test.js +335 -0
  93. package/dist/src/translator.d.ts +159 -0
  94. package/dist/src/translator.js +986 -0
  95. package/dist/src/triggers.d.ts +60 -0
  96. package/dist/src/triggers.js +270 -0
  97. package/dist/src/webhook.d.ts +47 -0
  98. package/dist/src/webhook.js +148 -0
  99. package/index.ts +44 -0
  100. package/package.json +64 -0
  101. package/src/agent-tools.ts +138 -0
  102. package/src/agents-md.ts +81 -0
  103. package/src/api-tools.ts +389 -0
  104. package/src/approvals.ts +129 -0
  105. package/src/arrange.ts +42 -0
  106. package/src/catalog.ts +321 -0
  107. package/src/completions.ts +652 -0
  108. package/src/confine.ts +350 -0
  109. package/src/container.ts +220 -0
  110. package/src/context-tools.ts +435 -0
  111. package/src/deploy.ts +458 -0
  112. package/src/egress.ts +176 -0
  113. package/src/evals.ts +812 -0
  114. package/src/flow-lint.ts +215 -0
  115. package/src/git-hook.ts +73 -0
  116. package/src/git.ts +117 -0
  117. package/src/gitrepo.ts +391 -0
  118. package/src/history.ts +329 -0
  119. package/src/kinds.ts +507 -0
  120. package/src/library.ts +419 -0
  121. package/src/linkable.ts +123 -0
  122. package/src/model-probe.ts +112 -0
  123. package/src/notify.ts +281 -0
  124. package/src/oauth-presets.ts +54 -0
  125. package/src/okf.ts +768 -0
  126. package/src/openapi.ts +383 -0
  127. package/src/paths.ts +79 -0
  128. package/src/platform.ts +87 -0
  129. package/src/providers.ts +147 -0
  130. package/src/refs.ts +83 -0
  131. package/src/run-container.ts +883 -0
  132. package/src/runner.ts +3446 -0
  133. package/src/runtime.ts +404 -0
  134. package/src/script-tools.ts +383 -0
  135. package/src/secret-files.ts +101 -0
  136. package/src/secrets.ts +799 -0
  137. package/src/starter.ts +415 -0
  138. package/src/step-exec.ts +498 -0
  139. package/src/storage.ts +771 -0
  140. package/src/store.ts +2985 -0
  141. package/src/tar.ts +124 -0
  142. package/src/tool-names.ts +91 -0
  143. package/src/tool-programs.ts +65 -0
  144. package/src/tool-test.ts +401 -0
  145. package/src/translator.ts +1029 -0
  146. package/src/triggers.ts +287 -0
  147. package/src/webhook.ts +183 -0
  148. package/templates/blog-desk/AGENTS.md +13 -0
  149. package/templates/blog-desk/agents/researcher/agent.md +11 -0
  150. package/templates/blog-desk/agents/writer/agent.md +8 -0
  151. package/templates/blog-desk/evals/writer-quality.md +10 -0
  152. package/templates/blog-desk/flows/publish.md +10 -0
  153. package/templates/blog-desk/knowledge/house-style.md +9 -0
  154. package/templates/blog-desk/knowledge/index.md +9 -0
  155. package/templates/hello/AGENTS.md +18 -0
  156. package/templates/hello/agents/curator/agent.md +32 -0
  157. package/templates/hello/agents/notetaker/agent.md +24 -0
  158. package/templates/hello/evals/house-style.md +16 -0
  159. package/templates/hello/flows/memory-review.md +7 -0
  160. package/templates/hello/flows/note.md +7 -0
  161. package/templates/hello/knowledge/house-style.md +15 -0
  162. package/templates/hello/knowledge/index.md +9 -0
  163. package/templates/hello/skills/plain-english/SKILL.md +11 -0
  164. package/templates/hello/state/publishing.md +7 -0
  165. package/templates/hello/tools/wordcount/run.py +13 -0
  166. package/templates/hello/tools/wordcount/tool.md +22 -0
@@ -0,0 +1,60 @@
1
+ import { type FlowInfo, type FlowStep, type RunRecord } from "./store.ts";
2
+ /** Staple a payload to the first step, the way the run route staples a task. */
3
+ export declare function withTask(steps: FlowStep[], tag: string, body: string): FlowStep[];
4
+ /** The flows a finished run should start, by this workspace's own files. */
5
+ export declare function chainedFlows(tenant: string, workspace: string, finished: RunRecord): FlowInfo[];
6
+ /**
7
+ * Start every flow chained on a run that just settled. The finished run's
8
+ * one-line summary and its final result are the task; the run id rides
9
+ * along so the chained flow can name what it is reacting to. Returns the
10
+ * run ids started. Never throws: a chained flow that cannot start is a
11
+ * logged failure of that flow, not of the run that finished.
12
+ */
13
+ export declare function fireChainedFlows(tenant: string, workspace: string, finished: RunRecord): Promise<string[]>;
14
+ /** Does a stored path fall under a flow's `path:` prefix? A prefix names a
15
+ * folder ("leads/") or a file ("leads/latest.csv"); an empty prefix is all
16
+ * of storage/. */
17
+ export declare function underPrefix(rel: string, prefix: string | null): boolean;
18
+ /**
19
+ * Start the flows watching a storage prefix that just gained files. `by`
20
+ * says who wrote them: a run's own copy-back (`run:<id>`) must not restart
21
+ * the flow that produced it, or a flow writing to its own trigger folder is
22
+ * a loop — every other writer counts.
23
+ */
24
+ export declare function fireStorageTriggers(tenant: string, workspace: string, paths: string[], by: string): Promise<string[]>;
25
+ export interface SignatureVerdict {
26
+ ok: boolean;
27
+ reason: string;
28
+ /** Slack's url_verification handshake: answer with this and start nothing. */
29
+ challenge?: string;
30
+ }
31
+ /**
32
+ * Check a webhook delivery against the flow's declared `signature:`. The
33
+ * secret's VALUE is read here, host-side, from the vault; the flow file only
34
+ * ever names it. Each provider signs differently, and the four supported
35
+ * are exactly the four whose scheme is documented and stable:
36
+ *
37
+ * github X-Hub-Signature-256: sha256=HMAC(body)
38
+ * stripe Stripe-Signature: t=<ts>,v1=HMAC(`${ts}.${body}`), ±5 min
39
+ * slack X-Slack-Signature: v0=HMAC(`v0:${ts}:${body}`), ±5 min,
40
+ * plus the url_verification challenge Slack sends when the URL
41
+ * is first saved — answered, not run
42
+ * hmac X-Signature: hex HMAC(body) — for anything you sign yourself
43
+ */
44
+ export declare function verifyWebhookSignature(tenant: string, workspace: string, flow: FlowInfo, rawBody: string, header: (name: string) => string | null, now?: number): SignatureVerdict;
45
+ export interface InboundEmail {
46
+ from: string;
47
+ to: string;
48
+ subject: string;
49
+ text: string;
50
+ }
51
+ /**
52
+ * One shape out of the several inbound-email services post. JSON bodies
53
+ * (Resend, Postmark, Cloudflare Email Workers, anything hand-rolled) and
54
+ * form-encoded ones (Mailgun, SendGrid's inbound parse) both reduce to
55
+ * from / to / subject / text; HTML is stripped to text when that is all
56
+ * there is. Null when nothing in the body looks like a message.
57
+ */
58
+ export declare function normaliseInboundEmail(contentType: string | null, raw: string): InboundEmail | null;
59
+ /** The task an inbound email becomes. */
60
+ export declare function emailTask(mail: InboundEmail): string;
@@ -0,0 +1,270 @@
1
+ // The ways a flow starts that are not a person, a clock or a bare POST.
2
+ //
3
+ // Four triggers live here, and they share one shape: something happened,
4
+ // the flow file said it cares, the happening becomes the first step's task.
5
+ //
6
+ // trigger: flow another flow of this workspace finished (`after:`, `on:`)
7
+ // trigger: storage a file landed under a storage/ prefix (`path:`)
8
+ // trigger: email a message arrived at the flow's inbox URL
9
+ // signature: a webhook delivery must carry a provider's HMAC
10
+ //
11
+ // None of them evaluates anything. A flow that chains on another does not
12
+ // read that run's fields; it receives what the run concluded, as text, and
13
+ // an agent reads it — the same rule as every other handoff in the format.
14
+ import crypto from "node:crypto";
15
+ import { platform } from "./platform.js";
16
+ import { listFlows, readRun } from "./store.js";
17
+ import { getSecret } from "./secrets.js";
18
+ /** The last step's result — the same reading runner.ts makes, kept local so
19
+ * this module never imports the runner (the queue imports both). */
20
+ function runResult(run) {
21
+ for (let i = run.steps.length - 1; i >= 0; i--) {
22
+ const r = run.steps[i].result;
23
+ if (r && r.trim())
24
+ return r;
25
+ }
26
+ return null;
27
+ }
28
+ /** Staple a payload to the first step, the way the run route staples a task. */
29
+ export function withTask(steps, tag, body) {
30
+ const text = body.trim();
31
+ if (!text)
32
+ return steps;
33
+ return steps.map((s, i) => (i === 0 ? { ...s, instruction: `${s.instruction}\n\n<${tag}>\n${text}\n</${tag}>` } : s));
34
+ }
35
+ // ------------------------------------------------------------ trigger: flow
36
+ /** The flows a finished run should start, by this workspace's own files. */
37
+ export function chainedFlows(tenant, workspace, finished) {
38
+ if (finished.status !== "completed" && finished.status !== "failed")
39
+ return [];
40
+ if (finished.flow.startsWith("adhoc:") || finished.flow.startsWith("eval:"))
41
+ return [];
42
+ return listFlows(tenant, workspace).filter((f) => f.trigger === "flow" &&
43
+ f.after === finished.flow &&
44
+ f.steps.length > 0 &&
45
+ // A flow chained on itself is a loop with no bound — refused here and
46
+ // by foldrun check, never started.
47
+ f.name !== finished.flow &&
48
+ (f.on === "any" || f.on === finished.status));
49
+ }
50
+ /**
51
+ * Start every flow chained on a run that just settled. The finished run's
52
+ * one-line summary and its final result are the task; the run id rides
53
+ * along so the chained flow can name what it is reacting to. Returns the
54
+ * run ids started. Never throws: a chained flow that cannot start is a
55
+ * logged failure of that flow, not of the run that finished.
56
+ */
57
+ export async function fireChainedFlows(tenant, workspace, finished) {
58
+ const flows = chainedFlows(tenant, workspace, finished);
59
+ if (flows.length === 0)
60
+ return [];
61
+ const started = [];
62
+ const result = (runResult(finished) ?? "").slice(0, 20_000);
63
+ const body = `run: ${finished.id}\nflow: ${finished.flow}\nstatus: ${finished.status}\n` +
64
+ (finished.summary ? `summary: ${finished.summary}\n` : "") +
65
+ (result ? `\n${result}` : "");
66
+ for (const flow of flows) {
67
+ try {
68
+ const run = await platform.enqueueFlowRun(tenant, workspace, withTask(flow.steps, "previous_run", body), flow.name, flow.model, [
69
+ `after:${finished.flow}`,
70
+ ]);
71
+ started.push(run.id);
72
+ console.log(`[foldrun] trigger: flow — ${tenant}/${workspace}/${flow.name} started after ${finished.flow} ${finished.status}`);
73
+ }
74
+ catch (err) {
75
+ console.error(`[foldrun] trigger: flow — ${tenant}/${workspace}/${flow.name}:`, err instanceof Error ? err.message : err);
76
+ }
77
+ }
78
+ return started;
79
+ }
80
+ // --------------------------------------------------------- trigger: storage
81
+ /** Does a stored path fall under a flow's `path:` prefix? A prefix names a
82
+ * folder ("leads/") or a file ("leads/latest.csv"); an empty prefix is all
83
+ * of storage/. */
84
+ export function underPrefix(rel, prefix) {
85
+ const p = (prefix ?? "").replace(/^\/+/, "");
86
+ if (!p)
87
+ return true;
88
+ return rel === p || rel.startsWith(p.endsWith("/") ? p : `${p}/`) || (p.endsWith("/") && rel.startsWith(p));
89
+ }
90
+ /**
91
+ * Start the flows watching a storage prefix that just gained files. `by`
92
+ * says who wrote them: a run's own copy-back (`run:<id>`) must not restart
93
+ * the flow that produced it, or a flow writing to its own trigger folder is
94
+ * a loop — every other writer counts.
95
+ */
96
+ export async function fireStorageTriggers(tenant, workspace, paths, by) {
97
+ if (paths.length === 0)
98
+ return [];
99
+ let flows;
100
+ try {
101
+ flows = listFlows(tenant, workspace).filter((f) => f.trigger === "storage" && f.steps.length > 0);
102
+ }
103
+ catch {
104
+ return [];
105
+ }
106
+ if (flows.length === 0)
107
+ return [];
108
+ const writerFlow = by.startsWith("run:") ? readRun(tenant, workspace, by.slice(4))?.flow ?? null : null;
109
+ const started = [];
110
+ for (const flow of flows) {
111
+ if (writerFlow === flow.name)
112
+ continue;
113
+ const hits = paths.filter((p) => underPrefix(p, flow.path));
114
+ if (hits.length === 0)
115
+ continue;
116
+ try {
117
+ const body = `by: ${by}\n${hits.map((h) => `- storage/${h}`).join("\n")}`;
118
+ const run = await platform.enqueueFlowRun(tenant, workspace, withTask(flow.steps, "storage_event", body), flow.name, flow.model, [
119
+ "storage",
120
+ ]);
121
+ started.push(run.id);
122
+ }
123
+ catch (err) {
124
+ console.error(`[foldrun] trigger: storage — ${tenant}/${workspace}/${flow.name}:`, err instanceof Error ? err.message : err);
125
+ }
126
+ }
127
+ return started;
128
+ }
129
+ function safeEqual(a, b) {
130
+ const x = Buffer.from(a);
131
+ const y = Buffer.from(b);
132
+ return x.length === y.length && crypto.timingSafeEqual(x, y);
133
+ }
134
+ /**
135
+ * Check a webhook delivery against the flow's declared `signature:`. The
136
+ * secret's VALUE is read here, host-side, from the vault; the flow file only
137
+ * ever names it. Each provider signs differently, and the four supported
138
+ * are exactly the four whose scheme is documented and stable:
139
+ *
140
+ * github X-Hub-Signature-256: sha256=HMAC(body)
141
+ * stripe Stripe-Signature: t=<ts>,v1=HMAC(`${ts}.${body}`), ±5 min
142
+ * slack X-Slack-Signature: v0=HMAC(`v0:${ts}:${body}`), ±5 min,
143
+ * plus the url_verification challenge Slack sends when the URL
144
+ * is first saved — answered, not run
145
+ * hmac X-Signature: hex HMAC(body) — for anything you sign yourself
146
+ */
147
+ export function verifyWebhookSignature(tenant, workspace, flow, rawBody, header, now = Date.now()) {
148
+ if (!flow.signature)
149
+ return { ok: true, reason: "no signature required" };
150
+ if (!flow.signingSecret)
151
+ return { ok: false, reason: `signature: ${flow.signature} needs signing_secret: \${NAME}` };
152
+ const secret = getSecret(tenant, flow.signingSecret, workspace);
153
+ if (!secret)
154
+ return { ok: false, reason: `signing secret ${flow.signingSecret} is not set` };
155
+ const hmac = (data) => crypto.createHmac("sha256", secret.value).update(data).digest("hex");
156
+ const fresh = (ts) => Math.abs(now / 1000 - ts) <= 300;
157
+ switch (flow.signature) {
158
+ case "github": {
159
+ const sig = header("x-hub-signature-256") ?? "";
160
+ const ok = sig.startsWith("sha256=") && safeEqual(sig.slice(7), hmac(rawBody));
161
+ return { ok, reason: ok ? "github signature verified" : "X-Hub-Signature-256 did not match" };
162
+ }
163
+ case "stripe": {
164
+ const sig = header("stripe-signature") ?? "";
165
+ const parts = Object.fromEntries(sig.split(",").map((p) => p.trim().split("=")));
166
+ const ts = Number(parts.t);
167
+ if (!Number.isFinite(ts) || !parts.v1)
168
+ return { ok: false, reason: "Stripe-Signature is missing t= or v1=" };
169
+ if (!fresh(ts))
170
+ return { ok: false, reason: "Stripe-Signature timestamp is outside the 5-minute window" };
171
+ const ok = safeEqual(parts.v1, hmac(`${ts}.${rawBody}`));
172
+ return { ok, reason: ok ? "stripe signature verified" : "Stripe-Signature v1 did not match" };
173
+ }
174
+ case "slack": {
175
+ // The handshake is unsigned in effect — Slack signs it, but a URL that
176
+ // cannot answer it is never saved, so answer before anything else.
177
+ try {
178
+ const parsed = JSON.parse(rawBody);
179
+ if (parsed?.type === "url_verification" && typeof parsed.challenge === "string") {
180
+ return { ok: true, reason: "slack url_verification", challenge: parsed.challenge };
181
+ }
182
+ }
183
+ catch {
184
+ // not JSON — an ordinary signed delivery (form-encoded slash command)
185
+ }
186
+ const ts = Number(header("x-slack-request-timestamp"));
187
+ const sig = header("x-slack-signature") ?? "";
188
+ if (!Number.isFinite(ts))
189
+ return { ok: false, reason: "X-Slack-Request-Timestamp is missing" };
190
+ if (!fresh(ts))
191
+ return { ok: false, reason: "X-Slack-Request-Timestamp is outside the 5-minute window" };
192
+ const ok = sig.startsWith("v0=") && safeEqual(sig.slice(3), hmac(`v0:${ts}:${rawBody}`));
193
+ return { ok, reason: ok ? "slack signature verified" : "X-Slack-Signature did not match" };
194
+ }
195
+ case "hmac": {
196
+ const sig = (header("x-signature") ?? "").replace(/^sha256=/, "");
197
+ const ok = safeEqual(sig, hmac(rawBody));
198
+ return { ok, reason: ok ? "signature verified" : "X-Signature did not match" };
199
+ }
200
+ }
201
+ return { ok: false, reason: `unknown signature scheme ${String(flow.signature)}` };
202
+ }
203
+ /**
204
+ * One shape out of the several inbound-email services post. JSON bodies
205
+ * (Resend, Postmark, Cloudflare Email Workers, anything hand-rolled) and
206
+ * form-encoded ones (Mailgun, SendGrid's inbound parse) both reduce to
207
+ * from / to / subject / text; HTML is stripped to text when that is all
208
+ * there is. Null when nothing in the body looks like a message.
209
+ */
210
+ export function normaliseInboundEmail(contentType, raw) {
211
+ let fields = {};
212
+ const ct = (contentType ?? "").toLowerCase();
213
+ if (ct.includes("application/json") || raw.trim().startsWith("{")) {
214
+ try {
215
+ fields = JSON.parse(raw);
216
+ }
217
+ catch {
218
+ return null;
219
+ }
220
+ // Resend wraps the message in `data`; Postmark and others do not.
221
+ if (fields.data && typeof fields.data === "object")
222
+ fields = { ...fields, ...fields.data };
223
+ }
224
+ else if (ct.includes("application/x-www-form-urlencoded") || ct.includes("multipart/form-data")) {
225
+ if (ct.includes("multipart/form-data")) {
226
+ // SendGrid's inbound parse is multipart; the parts we need are plain
227
+ // text fields, so a minimal reader is enough and avoids a dependency.
228
+ const boundary = ct.match(/boundary=("?)([^";]+)\1/)?.[2];
229
+ if (!boundary)
230
+ return null;
231
+ for (const part of raw.split(`--${boundary}`)) {
232
+ const m = part.match(/name="([^"]+)"\r?\n\r?\n([\s\S]*?)\r?\n$/);
233
+ if (m)
234
+ fields[m[1]] = m[2];
235
+ }
236
+ }
237
+ else {
238
+ for (const [k, v] of new URLSearchParams(raw))
239
+ fields[k] = v;
240
+ }
241
+ }
242
+ else {
243
+ return null;
244
+ }
245
+ const pick = (...names) => {
246
+ for (const n of names) {
247
+ const v = fields[n];
248
+ if (typeof v === "string" && v.trim())
249
+ return v.trim();
250
+ if (Array.isArray(v) && v.length && typeof v[0] === "string")
251
+ return v.join(", ");
252
+ if (v && typeof v === "object" && typeof v.email === "string") {
253
+ return String(v.email);
254
+ }
255
+ }
256
+ return "";
257
+ };
258
+ const from = pick("from", "From", "sender", "FromFull", "envelope_from");
259
+ const subject = pick("subject", "Subject");
260
+ const html = pick("html", "HtmlBody", "body-html");
261
+ const text = pick("text", "TextBody", "body-plain", "stripped-text", "plain") ||
262
+ html.replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
263
+ if (!from && !subject && !text)
264
+ return null;
265
+ return { from, to: pick("to", "To", "recipient", "ToFull"), subject, text };
266
+ }
267
+ /** The task an inbound email becomes. */
268
+ export function emailTask(mail) {
269
+ return `from: ${mail.from}\nto: ${mail.to}\nsubject: ${mail.subject}\n\n${mail.text}`.slice(0, 20_000);
270
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The install's secret key. Every derived token — flow hooks, git secrets —
3
+ * hangs off this one value, so rotating it invalidates all of them together.
4
+ */
5
+ export declare function installKey(): string;
6
+ export declare function hookGeneration(tenant: string, workspace: string, flow: string): number;
7
+ /** Invalidate a hook's URL and mint the next one. Returns the new path. */
8
+ export declare function rotateWebhook(tenant: string, workspace: string, flow: string): string;
9
+ export declare function webhookToken(tenant: string, workspace: string, flow: string): string;
10
+ export declare function webhookPath(tenant: string, workspace: string, flow: string): string;
11
+ /**
12
+ * The token in an emailed approve/reject link. Same shape as a hook token:
13
+ * derived from the run's identity under the install key, never stored, so
14
+ * a link keeps working across restarts and dies with a key rotation. A run
15
+ * id is unique, so there is no generation counter — a leaked link is dealt
16
+ * with by deciding the run, after which the link does nothing.
17
+ *
18
+ * The `approve:` prefix keeps this HMAC family apart from the hook family:
19
+ * without it a run id that happened to spell `tenant/ws/flow` would mint a
20
+ * working hook token.
21
+ */
22
+ export declare function approveToken(tenant: string, workspace: string, runId: string): string;
23
+ /**
24
+ * The token that releases a run parked on `wait: event`. Its own HMAC
25
+ * family, so an approval link cannot double as an event URL or the reverse.
26
+ */
27
+ export declare function eventToken(tenant: string, workspace: string, runId: string): string;
28
+ /** The URL a `wait: event` step is released at — absolute when the install
29
+ * knows its origin, a path otherwise. */
30
+ export declare function eventUrl(tenant: string, workspace: string, runId: string): string;
31
+ /**
32
+ * Where this install can be reached from outside, or null. The platform has
33
+ * never needed to know its own address — every URL it hands out has been a
34
+ * path, completed by whoever pasted it. A link in an email has no such
35
+ * reader, so this is the one place an absolute origin is required.
36
+ */
37
+ export declare function publicUrl(): string | null;
38
+ export interface HookDelivery {
39
+ t: string;
40
+ flow: string;
41
+ outcome: "accepted" | "invalid-token" | "invalid-signature" | "not-webhook" | "no-flow" | "error";
42
+ runId?: string;
43
+ bytes?: number;
44
+ detail?: string;
45
+ }
46
+ export declare function recordDelivery(tenant: string, workspace: string, delivery: HookDelivery): void;
47
+ export declare function readDeliveries(tenant: string, workspace: string, limit?: number): HookDelivery[];
@@ -0,0 +1,148 @@
1
+ // Per-flow webhook tokens, derived rather than stored: HMAC of the flow's
2
+ // identity under the install's secret key. Stable across restarts, unique
3
+ // per flow, and rotating FOLDRUN_SECRET_KEY invalidates every hook at once.
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import { dataRoot } from "./paths.js";
7
+ import { workspaceDir } from "./store.js";
8
+ import crypto from "node:crypto";
9
+ const keyFile = () => path.join(dataRoot(), ".secret-key");
10
+ /**
11
+ * The install's secret key. Every derived token — flow hooks, git secrets —
12
+ * hangs off this one value, so rotating it invalidates all of them together.
13
+ */
14
+ export function installKey() {
15
+ if (process.env.FOLDRUN_SECRET_KEY)
16
+ return process.env.FOLDRUN_SECRET_KEY;
17
+ if (fs.existsSync(keyFile()))
18
+ return fs.readFileSync(keyFile(), "utf8");
19
+ // secrets.ts creates this on first use; fall back to a fixed dev value so
20
+ // hook URLs stay stable before any secret has been set.
21
+ return "foldrun-dev-install";
22
+ }
23
+ function hooksFile(tenant, workspace) {
24
+ return path.join(workspaceDir(tenant, workspace), "hooks.json");
25
+ }
26
+ function readHooks(tenant, workspace) {
27
+ const file = hooksFile(tenant, workspace);
28
+ if (!fs.existsSync(file))
29
+ return {};
30
+ try {
31
+ return JSON.parse(fs.readFileSync(file, "utf8"));
32
+ }
33
+ catch {
34
+ return {};
35
+ }
36
+ }
37
+ export function hookGeneration(tenant, workspace, flow) {
38
+ return readHooks(tenant, workspace)[flow]?.gen ?? 0;
39
+ }
40
+ /** Invalidate a hook's URL and mint the next one. Returns the new path. */
41
+ export function rotateWebhook(tenant, workspace, flow) {
42
+ const hooks = readHooks(tenant, workspace);
43
+ hooks[flow] = { gen: (hooks[flow]?.gen ?? 0) + 1, rotatedAt: new Date().toISOString() };
44
+ const file = hooksFile(tenant, workspace);
45
+ fs.mkdirSync(path.dirname(file), { recursive: true });
46
+ fs.writeFileSync(file, JSON.stringify(hooks, null, 2));
47
+ return webhookPath(tenant, workspace, flow);
48
+ }
49
+ export function webhookToken(tenant, workspace, flow) {
50
+ const gen = hookGeneration(tenant, workspace, flow);
51
+ const identity = gen === 0 ? `${tenant}/${workspace}/${flow}` : `${tenant}/${workspace}/${flow}#${gen}`;
52
+ return crypto.createHmac("sha256", installKey()).update(identity).digest("hex").slice(0, 32);
53
+ }
54
+ export function webhookPath(tenant, workspace, flow) {
55
+ return `/api/hooks/${tenant}/${workspace}/${flow}?token=${webhookToken(tenant, workspace, flow)}`;
56
+ }
57
+ // ---------- one-click approval ----------
58
+ /**
59
+ * The token in an emailed approve/reject link. Same shape as a hook token:
60
+ * derived from the run's identity under the install key, never stored, so
61
+ * a link keeps working across restarts and dies with a key rotation. A run
62
+ * id is unique, so there is no generation counter — a leaked link is dealt
63
+ * with by deciding the run, after which the link does nothing.
64
+ *
65
+ * The `approve:` prefix keeps this HMAC family apart from the hook family:
66
+ * without it a run id that happened to spell `tenant/ws/flow` would mint a
67
+ * working hook token.
68
+ */
69
+ export function approveToken(tenant, workspace, runId) {
70
+ return crypto
71
+ .createHmac("sha256", installKey())
72
+ .update(`approve:${tenant}/${workspace}/${runId}`)
73
+ .digest("hex")
74
+ .slice(0, 32);
75
+ }
76
+ /**
77
+ * The token that releases a run parked on `wait: event`. Its own HMAC
78
+ * family, so an approval link cannot double as an event URL or the reverse.
79
+ */
80
+ export function eventToken(tenant, workspace, runId) {
81
+ return crypto
82
+ .createHmac("sha256", installKey())
83
+ .update(`event:${tenant}/${workspace}/${runId}`)
84
+ .digest("hex")
85
+ .slice(0, 32);
86
+ }
87
+ /** The URL a `wait: event` step is released at — absolute when the install
88
+ * knows its origin, a path otherwise. */
89
+ export function eventUrl(tenant, workspace, runId) {
90
+ const p = `/api/events/${tenant}/${workspace}/${runId}?token=${eventToken(tenant, workspace, runId)}`;
91
+ const base = publicUrl();
92
+ return base ? `${base}${p}` : p;
93
+ }
94
+ /**
95
+ * Where this install can be reached from outside, or null. The platform has
96
+ * never needed to know its own address — every URL it hands out has been a
97
+ * path, completed by whoever pasted it. A link in an email has no such
98
+ * reader, so this is the one place an absolute origin is required.
99
+ */
100
+ export function publicUrl() {
101
+ const raw = process.env.FOLDRUN_PUBLIC_URL?.trim();
102
+ if (!raw)
103
+ return null;
104
+ return raw.replace(/\/+$/, "");
105
+ }
106
+ function deliveriesFile(tenant, workspace) {
107
+ return path.join(workspaceDir(tenant, workspace), "hook-deliveries.jsonl");
108
+ }
109
+ const KEEP_DELIVERIES = 500;
110
+ export function recordDelivery(tenant, workspace, delivery) {
111
+ const file = deliveriesFile(tenant, workspace);
112
+ if (!fs.existsSync(path.dirname(file)))
113
+ return; // no workspace, nothing to log against
114
+ fs.appendFileSync(file, JSON.stringify(delivery) + "\n");
115
+ // Bounded: compact to the newest KEEP when it doubles. Amortised cheap,
116
+ // and a log that grows forever is a disk-full incident with a delay.
117
+ try {
118
+ const lines = fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
119
+ if (lines.length > KEEP_DELIVERIES * 2) {
120
+ const tmp = `${file}.tmp`;
121
+ fs.writeFileSync(tmp, lines.slice(-KEEP_DELIVERIES).join("\n") + "\n");
122
+ fs.renameSync(tmp, file);
123
+ }
124
+ }
125
+ catch {
126
+ // compaction is best-effort; the append already landed
127
+ }
128
+ }
129
+ export function readDeliveries(tenant, workspace, limit = 20) {
130
+ const file = deliveriesFile(tenant, workspace);
131
+ if (!fs.existsSync(file))
132
+ return [];
133
+ return fs
134
+ .readFileSync(file, "utf8")
135
+ .split("\n")
136
+ .filter(Boolean)
137
+ .slice(-limit)
138
+ .reverse()
139
+ .map((line) => {
140
+ try {
141
+ return JSON.parse(line);
142
+ }
143
+ catch {
144
+ return null;
145
+ }
146
+ })
147
+ .filter((d) => d !== null);
148
+ }
package/index.ts ADDED
@@ -0,0 +1,44 @@
1
+ // @foldrun/core — the open-source runtime.
2
+ //
3
+ // Everything needed to read a workspace of markdown files and run the agents
4
+ // in it. No server, no database, no account: point it at a folder.
5
+ //
6
+ // The hosted platform imports these same modules. That is deliberate — if the
7
+ // open version were a reduced copy it would rot, and self-hosting would feel
8
+ // like a downgrade instead of a choice. What the platform adds — a queue and
9
+ // its worker, metering and billing, pods on a cluster, per-account keys in a
10
+ // database, previews, share links, tenancy — lives in its own private package
11
+ // and plugs in through platform.ts. Nothing here imports it.
12
+
13
+ export * from "./src/paths.ts";
14
+ export * from "./src/store.ts";
15
+ export * from "./src/starter.ts";
16
+ export * from "./src/runner.ts";
17
+ export * from "./src/okf.ts";
18
+ export * from "./src/evals.ts";
19
+ export * from "./src/flow-lint.ts";
20
+ export * from "./src/catalog.ts";
21
+ export * from "./src/model-probe.ts";
22
+ export * from "./src/providers.ts";
23
+ export * from "./src/translator.ts";
24
+ export * from "./src/arrange.ts";
25
+ export * from "./src/completions.ts";
26
+ export * from "./src/confine.ts";
27
+ export * from "./src/platform.ts";
28
+ export * from "./src/library.ts";
29
+ export * from "./src/tool-programs.ts";
30
+ export * from "./src/linkable.ts";
31
+ export * from "./src/refs.ts";
32
+ export * from "./src/tool-names.ts";
33
+ export * from "./src/history.ts";
34
+ export * from "./src/gitrepo.ts";
35
+ export * from "./src/storage.ts";
36
+ export * from "./src/secrets.ts";
37
+ export * from "./src/oauth-presets.ts";
38
+ export * from "./src/secret-files.ts";
39
+ export * from "./src/notify.ts";
40
+ export * from "./src/webhook.ts";
41
+ export * from "./src/approvals.ts";
42
+ export * from "./src/deploy.ts";
43
+ export * from "./src/tar.ts";
44
+ export * from "./src/git.ts";
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@foldrun/core",
3
+ "version": "0.2.0",
4
+ "description": "Agents are just folders. The format, the runner, the checks.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ },
12
+ "./git-hook": "./dist/src/git-hook.js",
13
+ "./*": {
14
+ "types": "./dist/src/*.d.ts",
15
+ "default": "./dist/src/*.js"
16
+ }
17
+ },
18
+ "main": "./dist/index.js",
19
+ "types": "./dist/index.d.ts",
20
+ "files": [
21
+ "dist",
22
+ "src",
23
+ "index.ts",
24
+ "templates",
25
+ "README.md"
26
+ ],
27
+ "scripts": {
28
+ "build": "tsc -p tsconfig.json",
29
+ "prepare": "npm run build",
30
+ "prepublishOnly": "npm run build",
31
+ "test": "FOLDRUN_DATA=$(mktemp -d /tmp/foldrun-test-XXXXXX) node --test *.test.ts $(ls tests/*.test.ts | grep -v e2e)",
32
+ "container": "FOLDRUN_CONTAINER_E2E=1 node scripts/ts-test.mjs tests/container-e2e.test.ts --test-timeout=600000"
33
+ },
34
+ "engines": {
35
+ "node": ">=22"
36
+ },
37
+ "dependencies": {
38
+ "@anthropic-ai/claude-agent-sdk": "0.3.220",
39
+ "gray-matter": "^4.0.3",
40
+ "zod": "4.4.3"
41
+ },
42
+ "devDependencies": {
43
+ "@types/node": "^22",
44
+ "typescript": "^5"
45
+ },
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "git+https://github.com/foldrun-io/foldrun-core.git"
49
+ },
50
+ "homepage": "https://foldrun.io",
51
+ "bugs": {
52
+ "url": "https://github.com/foldrun-io/foldrun-core/issues"
53
+ },
54
+ "keywords": [
55
+ "agents",
56
+ "llm",
57
+ "markdown",
58
+ "workflows",
59
+ "claude"
60
+ ],
61
+ "publishConfig": {
62
+ "access": "public"
63
+ }
64
+ }