@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.
- package/LICENSE +202 -0
- package/README.md +239 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +43 -0
- package/dist/src/agent-tools.d.ts +31 -0
- package/dist/src/agent-tools.js +99 -0
- package/dist/src/agents-md.d.ts +9 -0
- package/dist/src/agents-md.js +83 -0
- package/dist/src/api-tools.d.ts +38 -0
- package/dist/src/api-tools.js +313 -0
- package/dist/src/approvals.d.ts +39 -0
- package/dist/src/approvals.js +103 -0
- package/dist/src/arrange.d.ts +8 -0
- package/dist/src/arrange.js +40 -0
- package/dist/src/catalog.d.ts +108 -0
- package/dist/src/catalog.js +255 -0
- package/dist/src/completions.d.ts +61 -0
- package/dist/src/completions.js +578 -0
- package/dist/src/confine.d.ts +31 -0
- package/dist/src/confine.js +298 -0
- package/dist/src/container.d.ts +30 -0
- package/dist/src/container.js +172 -0
- package/dist/src/context-tools.d.ts +81 -0
- package/dist/src/context-tools.js +347 -0
- package/dist/src/deploy.d.ts +100 -0
- package/dist/src/deploy.js +377 -0
- package/dist/src/egress.d.ts +75 -0
- package/dist/src/egress.js +133 -0
- package/dist/src/evals.d.ts +150 -0
- package/dist/src/evals.js +651 -0
- package/dist/src/flow-lint.d.ts +16 -0
- package/dist/src/flow-lint.js +177 -0
- package/dist/src/git-hook.d.ts +1 -0
- package/dist/src/git-hook.js +70 -0
- package/dist/src/git.d.ts +37 -0
- package/dist/src/git.js +101 -0
- package/dist/src/gitrepo.d.ts +95 -0
- package/dist/src/gitrepo.js +355 -0
- package/dist/src/history.d.ts +82 -0
- package/dist/src/history.js +259 -0
- package/dist/src/kinds.d.ts +95 -0
- package/dist/src/kinds.js +421 -0
- package/dist/src/library.d.ts +94 -0
- package/dist/src/library.js +341 -0
- package/dist/src/linkable.d.ts +20 -0
- package/dist/src/linkable.js +113 -0
- package/dist/src/model-probe.d.ts +21 -0
- package/dist/src/model-probe.js +81 -0
- package/dist/src/notify.d.ts +61 -0
- package/dist/src/notify.js +252 -0
- package/dist/src/oauth-presets.d.ts +13 -0
- package/dist/src/oauth-presets.js +39 -0
- package/dist/src/okf.d.ts +220 -0
- package/dist/src/okf.js +656 -0
- package/dist/src/openapi.d.ts +66 -0
- package/dist/src/openapi.js +342 -0
- package/dist/src/paths.d.ts +33 -0
- package/dist/src/paths.js +78 -0
- package/dist/src/platform.d.ts +32 -0
- package/dist/src/platform.js +48 -0
- package/dist/src/providers.d.ts +47 -0
- package/dist/src/providers.js +114 -0
- package/dist/src/refs.d.ts +20 -0
- package/dist/src/refs.js +78 -0
- package/dist/src/run-container.d.ts +227 -0
- package/dist/src/run-container.js +760 -0
- package/dist/src/runner.d.ts +241 -0
- package/dist/src/runner.js +3215 -0
- package/dist/src/runtime.d.ts +41 -0
- package/dist/src/runtime.js +357 -0
- package/dist/src/script-tools.d.ts +62 -0
- package/dist/src/script-tools.js +296 -0
- package/dist/src/secret-files.d.ts +12 -0
- package/dist/src/secret-files.js +92 -0
- package/dist/src/secrets.d.ts +147 -0
- package/dist/src/secrets.js +626 -0
- package/dist/src/starter.d.ts +21 -0
- package/dist/src/starter.js +399 -0
- package/dist/src/step-exec.d.ts +136 -0
- package/dist/src/step-exec.js +391 -0
- package/dist/src/storage.d.ts +123 -0
- package/dist/src/storage.js +640 -0
- package/dist/src/store.d.ts +1052 -0
- package/dist/src/store.js +2378 -0
- package/dist/src/tar.d.ts +30 -0
- package/dist/src/tar.js +111 -0
- package/dist/src/tool-names.d.ts +39 -0
- package/dist/src/tool-names.js +79 -0
- package/dist/src/tool-programs.d.ts +18 -0
- package/dist/src/tool-programs.js +52 -0
- package/dist/src/tool-test.d.ts +19 -0
- package/dist/src/tool-test.js +335 -0
- package/dist/src/translator.d.ts +159 -0
- package/dist/src/translator.js +986 -0
- package/dist/src/triggers.d.ts +60 -0
- package/dist/src/triggers.js +270 -0
- package/dist/src/webhook.d.ts +47 -0
- package/dist/src/webhook.js +148 -0
- package/index.ts +44 -0
- package/package.json +64 -0
- package/src/agent-tools.ts +138 -0
- package/src/agents-md.ts +81 -0
- package/src/api-tools.ts +389 -0
- package/src/approvals.ts +129 -0
- package/src/arrange.ts +42 -0
- package/src/catalog.ts +321 -0
- package/src/completions.ts +652 -0
- package/src/confine.ts +350 -0
- package/src/container.ts +220 -0
- package/src/context-tools.ts +435 -0
- package/src/deploy.ts +458 -0
- package/src/egress.ts +176 -0
- package/src/evals.ts +812 -0
- package/src/flow-lint.ts +215 -0
- package/src/git-hook.ts +73 -0
- package/src/git.ts +117 -0
- package/src/gitrepo.ts +391 -0
- package/src/history.ts +329 -0
- package/src/kinds.ts +507 -0
- package/src/library.ts +419 -0
- package/src/linkable.ts +123 -0
- package/src/model-probe.ts +112 -0
- package/src/notify.ts +281 -0
- package/src/oauth-presets.ts +54 -0
- package/src/okf.ts +768 -0
- package/src/openapi.ts +383 -0
- package/src/paths.ts +79 -0
- package/src/platform.ts +87 -0
- package/src/providers.ts +147 -0
- package/src/refs.ts +83 -0
- package/src/run-container.ts +883 -0
- package/src/runner.ts +3446 -0
- package/src/runtime.ts +404 -0
- package/src/script-tools.ts +383 -0
- package/src/secret-files.ts +101 -0
- package/src/secrets.ts +799 -0
- package/src/starter.ts +415 -0
- package/src/step-exec.ts +498 -0
- package/src/storage.ts +771 -0
- package/src/store.ts +2985 -0
- package/src/tar.ts +124 -0
- package/src/tool-names.ts +91 -0
- package/src/tool-programs.ts +65 -0
- package/src/tool-test.ts +401 -0
- package/src/translator.ts +1029 -0
- package/src/triggers.ts +287 -0
- package/src/webhook.ts +183 -0
- package/templates/blog-desk/AGENTS.md +13 -0
- package/templates/blog-desk/agents/researcher/agent.md +11 -0
- package/templates/blog-desk/agents/writer/agent.md +8 -0
- package/templates/blog-desk/evals/writer-quality.md +10 -0
- package/templates/blog-desk/flows/publish.md +10 -0
- package/templates/blog-desk/knowledge/house-style.md +9 -0
- package/templates/blog-desk/knowledge/index.md +9 -0
- package/templates/hello/AGENTS.md +18 -0
- package/templates/hello/agents/curator/agent.md +32 -0
- package/templates/hello/agents/notetaker/agent.md +24 -0
- package/templates/hello/evals/house-style.md +16 -0
- package/templates/hello/flows/memory-review.md +7 -0
- package/templates/hello/flows/note.md +7 -0
- package/templates/hello/knowledge/house-style.md +15 -0
- package/templates/hello/knowledge/index.md +9 -0
- package/templates/hello/skills/plain-english/SKILL.md +11 -0
- package/templates/hello/state/publishing.md +7 -0
- package/templates/hello/tools/wordcount/run.py +13 -0
- 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
|
+
}
|