@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
package/src/storage.ts ADDED
@@ -0,0 +1,771 @@
1
+ // Files: the bytes a workspace holds that are not its source.
2
+ //
3
+ // Everything else in a workspace is markdown you diff and commit — agents,
4
+ // flows, skills, memory. This is the other half: a price list somebody
5
+ // uploaded, a PDF an agent produced, a CSV it scraped. Opaque bytes, often
6
+ // large, with no frontmatter and nothing to review in a pull request.
7
+ //
8
+ // Keeping the two apart is the whole design. `saveWorkspace` already refuses
9
+ // anything outside WORKSPACE_DIRS, so a deploy cannot carry a 40MB PNG into
10
+ // the source tree; and `storage/` is not in WORKSPACE_DIRS, so the file tree,
11
+ // the graph and the git export never see one. A workspace stays reviewable.
12
+ //
13
+ // Agents never learn a storage API. A run gets `storage/` materialised into
14
+ // its workspace copy and writes there with plain Read/Write/Bash; what it
15
+ // leaves behind is harvested back into the store afterwards, stamped with
16
+ // the run that made it. The model's mental model is a folder, because the
17
+ // rest of this runtime's is too.
18
+ //
19
+ // Two drivers behind one interface:
20
+ //
21
+ // fs the default. Blobs under data/<tenant>/storage/<workspace>/blobs/.
22
+ // No account, no credentials, no network — the CLI on a laptop gets
23
+ // the same feature the hosted platform has.
24
+ //
25
+ // s3 any S3-compatible endpoint; Cloudflare R2 is what the hosted
26
+ // platform runs. Chosen over a self-hosted store for one blunt
27
+ // reason: run pods are denied RFC1918 egress by NetworkPolicy, so an
28
+ // in-cluster MinIO/Garage would need a hole punched in the one rule
29
+ // that makes the sandbox true, while a public S3 endpoint is already
30
+ // reachable and needs no credentials inside the pod — a presigned URL
31
+ // is a URL. R2 specifically because agents and people both download,
32
+ // and R2 does not bill egress.
33
+ //
34
+ // The index is always local, whichever driver holds the bytes. It is small,
35
+ // it is what the dashboard lists, and it is already inside the backup that
36
+ // covers data/ — paying an S3 round trip to render a table would be a worse
37
+ // version of a file read.
38
+ //
39
+ // Config (s3 driver):
40
+ //
41
+ // FOLDRUN_STORAGE_DRIVER fs | s3 default fs
42
+ // FOLDRUN_S3_ENDPOINT https://<account>.r2.cloudflarestorage.com
43
+ // FOLDRUN_S3_BUCKET bucket name
44
+ // FOLDRUN_S3_REGION default auto (R2's region)
45
+ // FOLDRUN_S3_ACCESS_KEY_ID
46
+ // FOLDRUN_S3_SECRET_ACCESS_KEY
47
+ // FOLDRUN_S3_PATH_STYLE default true — R2 speaks path style
48
+ // FOLDRUN_STORAGE_MAX_MB per object, default 512
49
+ // FOLDRUN_STORAGE_QUOTA_MB per workspace, default 5120
50
+
51
+ import fs from "node:fs";
52
+ import path from "node:path";
53
+ import crypto from "node:crypto";
54
+ import { dataRoot, singleWorkspace } from "./paths.ts";
55
+ import { assertSafeName, workspaceDir, STORAGE_DIR, adoptLegacyFilesDir } from "./store.ts";
56
+
57
+ /** The directory a run sees, and the one this module mirrors into it.
58
+ * Declared in store.ts, where saveWorkspace also needs it; re-exported so
59
+ * callers of this module do not need to know that. */
60
+ export { STORAGE_DIR };
61
+
62
+ export interface FileRecord {
63
+ /** Path under storage/, forward-slashed. The primary key: it is also exactly
64
+ * what the agent typed, which is why the record is keyed by it and not by
65
+ * a generated id nobody could reach from inside a run. */
66
+ path: string;
67
+ /** Content hash. Blobs are stored under it, so re-uploading the same bytes
68
+ * under a second name costs nothing and a rename moves no data. */
69
+ sha: string;
70
+ size: number;
71
+ mime: string;
72
+ updatedAt: string;
73
+ /** Provenance: `user:<email>` or `run:<runId>:<agent>`. The reason the
74
+ * dashboard can answer "which run produced this?" without a join table. */
75
+ by: string;
76
+ }
77
+
78
+ interface Index {
79
+ version: 1;
80
+ files: FileRecord[];
81
+ }
82
+
83
+ // ---------- limits ----------
84
+
85
+ const maxBytes = () => Number(process.env.FOLDRUN_STORAGE_MAX_MB ?? 512) * 1024 * 1024;
86
+ const quotaBytes = () => Number(process.env.FOLDRUN_STORAGE_QUOTA_MB ?? 5120) * 1024 * 1024;
87
+
88
+ // ---------- paths ----------
89
+
90
+ /**
91
+ * Hosted only. On a laptop `storage/` is just a folder in the user's own
92
+ * project — they can see it, open it, and put a PDF in it with Finder — and
93
+ * an index plus a second hardlinked copy under data/ would be bookkeeping
94
+ * for a problem nobody has. The store exists to give many tenants a shared
95
+ * bucket and a provenance trail; one person with one directory has neither
96
+ * question to answer.
97
+ *
98
+ * Everything public here checks this first and does nothing, so the CLI runs
99
+ * the same driveRun the platform does and simply skips the mirror.
100
+ */
101
+ export function fileStoreEnabled(): boolean {
102
+ return singleWorkspace() === null;
103
+ }
104
+
105
+ function storeDir(tenant: string, workspace: string) {
106
+ if (!fileStoreEnabled()) {
107
+ throw new Error("the file store is not used in single-workspace mode — storage/ is a plain folder");
108
+ }
109
+ assertSafeName(tenant, "tenant");
110
+ assertSafeName(workspace, "workspace");
111
+ // Upgrade an install that predates the rename, the first time the store is
112
+ // resolved. data/<tenant>/files/ becomes data/<tenant>/storage/.
113
+ adoptLegacyFilesDir(path.join(dataRoot(), tenant));
114
+ return path.join(dataRoot(), tenant, STORAGE_DIR, workspace);
115
+ }
116
+
117
+ function indexPath(tenant: string, workspace: string) {
118
+ return path.join(storeDir(tenant, workspace), "index.json");
119
+ }
120
+
121
+ /** The object key. Tenant-first so a bucket policy can be written per tenant,
122
+ * and content-addressed so objects are immutable — no cache invalidation,
123
+ * no partially-overwritten object, and a repeated upload is a no-op. */
124
+ export function blobKey(tenant: string, workspace: string, sha: string) {
125
+ return `t/${tenant}/w/${workspace}/blobs/${sha}`;
126
+ }
127
+
128
+ /**
129
+ * Reject anything that isn't a plain relative path under storage/.
130
+ *
131
+ * This runs on names that came from an upload form *and* on paths harvested
132
+ * out of a container, so it is the boundary for both directions. `..` is the
133
+ * obvious one; a leading slash and a Windows drive letter are the two that
134
+ * path.normalize alone will happily keep.
135
+ */
136
+ export function assertFilePath(rel: string): string {
137
+ const norm = rel.replaceAll("\\", "/").replace(/^\.\//, "");
138
+ if (!norm || norm.length > 400) throw new Error(`illegal file path: ${rel}`);
139
+ if (path.isAbsolute(norm) || /^[a-zA-Z]:/.test(norm)) throw new Error(`illegal file path: ${rel}`);
140
+ if (norm.split("/").some((s) => s === "" || s === "." || s === "..")) {
141
+ throw new Error(`illegal file path: ${rel}`);
142
+ }
143
+ // Control characters survive a normalize and break every header they touch.
144
+ if (/[\u0000-\u001f\u007f]/.test(norm)) throw new Error(`illegal file path: ${rel}`);
145
+ return norm;
146
+ }
147
+
148
+ // ---------- mime ----------
149
+
150
+ const MIME: Record<string, string> = {
151
+ ".pdf": "application/pdf",
152
+ ".png": "image/png",
153
+ ".jpg": "image/jpeg",
154
+ ".jpeg": "image/jpeg",
155
+ ".gif": "image/gif",
156
+ ".webp": "image/webp",
157
+ ".svg": "image/svg+xml",
158
+ ".csv": "text/csv",
159
+ ".json": "application/json",
160
+ ".md": "text/markdown",
161
+ ".txt": "text/plain",
162
+ ".html": "text/html",
163
+ ".xml": "application/xml",
164
+ ".yaml": "text/yaml",
165
+ ".yml": "text/yaml",
166
+ ".zip": "application/zip",
167
+ ".gz": "application/gzip",
168
+ ".tar": "application/x-tar",
169
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
170
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
171
+ ".mp3": "audio/mpeg",
172
+ ".mp4": "video/mp4",
173
+ ".wav": "audio/wav",
174
+ };
175
+
176
+ export function mimeFor(rel: string): string {
177
+ return MIME[path.extname(rel).toLowerCase()] ?? "application/octet-stream";
178
+ }
179
+
180
+ // ---------- the index ----------
181
+
182
+ export function readIndex(tenant: string, workspace: string): Index {
183
+ if (!fileStoreEnabled()) return { version: 1, files: [] };
184
+ try {
185
+ const raw = JSON.parse(fs.readFileSync(indexPath(tenant, workspace), "utf8")) as Index;
186
+ if (!Array.isArray(raw?.files)) return { version: 1, files: [] };
187
+ return { version: 1, files: raw.files };
188
+ } catch {
189
+ return { version: 1, files: [] };
190
+ }
191
+ }
192
+
193
+ /** Write-and-rename. A half-written index is a workspace that has lost every
194
+ * file it owns, which is not a state worth risking to save a syscall. */
195
+ function writeIndex(tenant: string, workspace: string, index: Index) {
196
+ const file = indexPath(tenant, workspace);
197
+ fs.mkdirSync(path.dirname(file), { recursive: true });
198
+ const tmp = `${file}.${process.pid}.tmp`;
199
+ fs.writeFileSync(tmp, JSON.stringify(index, null, 2));
200
+ fs.renameSync(tmp, file);
201
+ }
202
+
203
+ export function listStorage(tenant: string, workspace: string): FileRecord[] {
204
+ return readIndex(tenant, workspace).files.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
205
+ }
206
+
207
+ export function getFile(tenant: string, workspace: string, rel: string): FileRecord | null {
208
+ const norm = assertFilePath(rel);
209
+ return readIndex(tenant, workspace).files.find((f) => f.path === norm) ?? null;
210
+ }
211
+
212
+ export function usedBytes(tenant: string, workspace: string): number {
213
+ // Distinct blobs, not records: two names for identical bytes occupy one
214
+ // object, and billing the tenant twice for it would be a lie.
215
+ const seen = new Map<string, number>();
216
+ for (const f of readIndex(tenant, workspace).files) seen.set(f.sha, f.size);
217
+ return [...seen.values()].reduce((a, b) => a + b, 0);
218
+ }
219
+
220
+ // ---------- the driver interface ----------
221
+
222
+ export interface Driver {
223
+ kind: "fs" | "s3";
224
+ put(key: string, body: Buffer, mime: string): Promise<void>;
225
+ get(key: string): Promise<Buffer>;
226
+ has(key: string): Promise<boolean>;
227
+ remove(key: string): Promise<void>;
228
+ /** A URL a browser can GET directly, or null when the driver has no such
229
+ * thing (fs) and the route must stream the bytes itself. */
230
+ presignGet(key: string, filename: string, mime: string, ttlSec?: number): Promise<string | null>;
231
+ /** A URL a browser can PUT directly to, so upload bytes never transit the
232
+ * dashboard process. Null on fs, where the route takes the body. */
233
+ presignPut(key: string, mime: string, ttlSec?: number): Promise<string | null>;
234
+ }
235
+
236
+ // ---------- fs driver ----------
237
+
238
+ /** Where the fs driver keeps a blob. Exported because the run mirror
239
+ * hardlinks straight to it rather than reading and rewriting the bytes. */
240
+ export function blobPath(tenant: string, workspace: string, sha: string) {
241
+ return path.join(storeDir(tenant, workspace), "blobs", sha);
242
+ }
243
+
244
+ function fsDriver(tenant: string, workspace: string): Driver {
245
+ const blobs = path.join(storeDir(tenant, workspace), "blobs");
246
+ const at = (key: string) => path.join(blobs, key.split("/").pop()!);
247
+ return {
248
+ kind: "fs",
249
+ async put(key, body) {
250
+ fs.mkdirSync(blobs, { recursive: true });
251
+ const target = at(key);
252
+ // Content-addressed: identical bytes are already there. Skipping the
253
+ // write also keeps mtime stable, which the hardlink mirror relies on.
254
+ if (fs.existsSync(target)) return;
255
+ const tmp = `${target}.${process.pid}.tmp`;
256
+ fs.writeFileSync(tmp, body);
257
+ fs.renameSync(tmp, target);
258
+ },
259
+ async get(key) {
260
+ return fs.readFileSync(at(key));
261
+ },
262
+ async has(key) {
263
+ return fs.existsSync(at(key));
264
+ },
265
+ async remove(key) {
266
+ fs.rmSync(at(key), { force: true });
267
+ },
268
+ async presignGet() {
269
+ return null;
270
+ },
271
+ async presignPut() {
272
+ return null;
273
+ },
274
+ };
275
+ }
276
+
277
+ // ---------- s3 driver ----------
278
+
279
+ interface S3Config {
280
+ endpoint: string;
281
+ bucket: string;
282
+ region: string;
283
+ accessKeyId: string;
284
+ secretAccessKey: string;
285
+ pathStyle: boolean;
286
+ }
287
+
288
+ export function s3Config(): S3Config | null {
289
+ const endpoint = process.env.FOLDRUN_S3_ENDPOINT;
290
+ const bucket = process.env.FOLDRUN_S3_BUCKET;
291
+ const accessKeyId = process.env.FOLDRUN_S3_ACCESS_KEY_ID;
292
+ const secretAccessKey = process.env.FOLDRUN_S3_SECRET_ACCESS_KEY;
293
+ if (!endpoint || !bucket || !accessKeyId || !secretAccessKey) return null;
294
+ return {
295
+ endpoint: endpoint.replace(/\/+$/, ""),
296
+ bucket,
297
+ // R2 has one region and calls it `auto`. AWS callers set their own.
298
+ region: process.env.FOLDRUN_S3_REGION ?? "auto",
299
+ accessKeyId,
300
+ secretAccessKey,
301
+ pathStyle: process.env.FOLDRUN_S3_PATH_STYLE !== "false",
302
+ };
303
+ }
304
+
305
+ const sha256 = (b: crypto.BinaryLike) => crypto.createHash("sha256").update(b).digest("hex");
306
+ const hmac = (key: crypto.BinaryLike, data: string) =>
307
+ crypto.createHmac("sha256", key).update(data, "utf8").digest();
308
+
309
+ /** RFC 3986. encodeURIComponent leaves !'()* alone and SigV4 does not. */
310
+ function uriEncode(s: string, keepSlash = false) {
311
+ const out = encodeURIComponent(s).replace(
312
+ /[!'()*]/g,
313
+ (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
314
+ );
315
+ return keepSlash ? out.replaceAll("%2F", "/") : out;
316
+ }
317
+
318
+ function stamps(now: Date) {
319
+ const amzDate = now.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
320
+ return { amzDate, dateStamp: amzDate.slice(0, 8) };
321
+ }
322
+
323
+ function signingKey(cfg: S3Config, dateStamp: string) {
324
+ const kDate = hmac(`AWS4${cfg.secretAccessKey}`, dateStamp);
325
+ const kRegion = hmac(kDate, cfg.region);
326
+ const kService = hmac(kRegion, "s3");
327
+ return hmac(kService, "aws4_request");
328
+ }
329
+
330
+ /** Where a key lives, split into the host and the canonical path SigV4 signs. */
331
+ function target(cfg: S3Config, key: string) {
332
+ const url = new URL(cfg.endpoint);
333
+ const encoded = uriEncode(key, true);
334
+ if (cfg.pathStyle) {
335
+ return { host: url.host, origin: url.origin, canonicalUri: `/${cfg.bucket}/${encoded}` };
336
+ }
337
+ const host = `${cfg.bucket}.${url.host}`;
338
+ return { host, origin: `${url.protocol}//${host}`, canonicalUri: `/${encoded}` };
339
+ }
340
+
341
+ /**
342
+ * A presigned URL — SigV4 in the query string, so the browser (or a run pod)
343
+ * needs no credentials and no SDK, just the link.
344
+ *
345
+ * `extraQuery` carries the response-header overrides. That is where the one
346
+ * security property that matters here comes from: every download is signed
347
+ * with `response-content-disposition: attachment`, so an HTML file an agent
348
+ * wrote can never render as a page on any origin. Without it, "the agent
349
+ * produced a report.html" and "stored XSS" are the same event.
350
+ */
351
+ function presign(
352
+ cfg: S3Config,
353
+ method: "GET" | "PUT",
354
+ key: string,
355
+ ttlSec: number,
356
+ extraQuery: Record<string, string> = {},
357
+ now = new Date(),
358
+ ): string {
359
+ const { host, origin, canonicalUri } = target(cfg, key);
360
+ const { amzDate, dateStamp } = stamps(now);
361
+ const scope = `${dateStamp}/${cfg.region}/s3/aws4_request`;
362
+
363
+ const query: Record<string, string> = {
364
+ ...extraQuery,
365
+ "X-Amz-Algorithm": "AWS4-HMAC-SHA256",
366
+ "X-Amz-Credential": `${cfg.accessKeyId}/${scope}`,
367
+ "X-Amz-Date": amzDate,
368
+ "X-Amz-Expires": String(ttlSec),
369
+ "X-Amz-SignedHeaders": "host",
370
+ };
371
+ const canonicalQuery = Object.keys(query)
372
+ .sort()
373
+ .map((k) => `${uriEncode(k)}=${uriEncode(query[k])}`)
374
+ .join("&");
375
+
376
+ const canonicalRequest = [
377
+ method,
378
+ canonicalUri,
379
+ canonicalQuery,
380
+ `host:${host}\n`,
381
+ "host",
382
+ "UNSIGNED-PAYLOAD",
383
+ ].join("\n");
384
+
385
+ const stringToSign = [
386
+ "AWS4-HMAC-SHA256",
387
+ amzDate,
388
+ scope,
389
+ sha256(canonicalRequest),
390
+ ].join("\n");
391
+
392
+ const signature = hmac(signingKey(cfg, dateStamp), stringToSign).toString("hex");
393
+ return `${origin}${canonicalUri}?${canonicalQuery}&X-Amz-Signature=${signature}`;
394
+ }
395
+
396
+ /** The header-signed form, for the calls the platform itself makes. */
397
+ async function s3Request(
398
+ cfg: S3Config,
399
+ method: "GET" | "PUT" | "DELETE" | "HEAD",
400
+ key: string,
401
+ body?: Buffer,
402
+ mime?: string,
403
+ ): Promise<Response> {
404
+ const { host, origin, canonicalUri } = target(cfg, key);
405
+ const { amzDate, dateStamp } = stamps(new Date());
406
+ const scope = `${dateStamp}/${cfg.region}/s3/aws4_request`;
407
+ const payloadHash = sha256(body ?? Buffer.alloc(0));
408
+
409
+ const headers: Record<string, string> = {
410
+ host,
411
+ "x-amz-content-sha256": payloadHash,
412
+ "x-amz-date": amzDate,
413
+ ...(mime ? { "content-type": mime } : {}),
414
+ };
415
+ const signed = Object.keys(headers).sort();
416
+ const canonicalHeaders = signed.map((h) => `${h}:${headers[h].trim()}\n`).join("");
417
+ const signedHeaders = signed.join(";");
418
+
419
+ const canonicalRequest = [
420
+ method,
421
+ canonicalUri,
422
+ "",
423
+ canonicalHeaders,
424
+ signedHeaders,
425
+ payloadHash,
426
+ ].join("\n");
427
+
428
+ const stringToSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha256(canonicalRequest)].join("\n");
429
+ const signature = hmac(signingKey(cfg, dateStamp), stringToSign).toString("hex");
430
+
431
+ const res = await fetch(`${origin}${canonicalUri}`, {
432
+ method,
433
+ body: body as BodyInit | undefined,
434
+ headers: {
435
+ ...headers,
436
+ Authorization:
437
+ `AWS4-HMAC-SHA256 Credential=${cfg.accessKeyId}/${scope}, ` +
438
+ `SignedHeaders=${signedHeaders}, Signature=${signature}`,
439
+ },
440
+ });
441
+ return res;
442
+ }
443
+
444
+ function s3Driver(cfg: S3Config): Driver {
445
+ const fail = async (res: Response, what: string) => {
446
+ throw new Error(`s3 ${what} failed: ${res.status} ${(await res.text()).slice(0, 300)}`);
447
+ };
448
+ return {
449
+ kind: "s3",
450
+ async put(key, body, mime) {
451
+ const res = await s3Request(cfg, "PUT", key, body, mime);
452
+ if (!res.ok) await fail(res, "put");
453
+ },
454
+ async get(key) {
455
+ const res = await s3Request(cfg, "GET", key);
456
+ if (!res.ok) await fail(res, "get");
457
+ return Buffer.from(await res.arrayBuffer());
458
+ },
459
+ async has(key) {
460
+ const res = await s3Request(cfg, "HEAD", key);
461
+ return res.ok;
462
+ },
463
+ async remove(key) {
464
+ const res = await s3Request(cfg, "DELETE", key);
465
+ // 404 on delete is the desired end state, not an error.
466
+ if (!res.ok && res.status !== 404) await fail(res, "delete");
467
+ },
468
+ async presignGet(key, filename, mime, ttlSec = 300) {
469
+ return presign(cfg, "GET", key, ttlSec, {
470
+ // Never inline. See presign()'s note — this is the XSS boundary.
471
+ "response-content-disposition": `attachment; filename="${filename.replace(/["\\]/g, "")}"`,
472
+ "response-content-type": mime,
473
+ });
474
+ },
475
+ async presignPut(key, _mime, ttlSec = 600) {
476
+ return presign(cfg, "PUT", key, ttlSec);
477
+ },
478
+ };
479
+ }
480
+
481
+ /**
482
+ * The driver in force. `fs` unless S3 is both selected and configured —
483
+ * a half-set environment falls back rather than failing at the first upload,
484
+ * because a dashboard that boots and stores locally beats one that 500s.
485
+ */
486
+ export function driverFor(tenant: string, workspace: string): Driver {
487
+ if (process.env.FOLDRUN_STORAGE_DRIVER === "s3") {
488
+ const cfg = s3Config();
489
+ if (cfg) return s3Driver(cfg);
490
+ }
491
+ return fsDriver(tenant, workspace);
492
+ }
493
+
494
+ // ---------- the store ----------
495
+
496
+ /**
497
+ * Record bytes the platform already holds. Used by the upload route on the fs
498
+ * driver and by the run harvest on both.
499
+ */
500
+ /**
501
+ * trigger: storage — tell the flows watching a prefix that files landed.
502
+ * Called after an index write, never before: a flow that starts on a file
503
+ * must find the file there. Dynamic import, because the queue imports the
504
+ * runner, which imports this module.
505
+ */
506
+ async function announceFiles(tenant: string, workspace: string, paths: string[], by: string): Promise<void> {
507
+ try {
508
+ const { fireStorageTriggers } = await import("./triggers.ts");
509
+ await fireStorageTriggers(tenant, workspace, paths, by);
510
+ } catch (err) {
511
+ console.error(`[foldrun] storage trigger ${tenant}/${workspace}:`, err instanceof Error ? err.message : err);
512
+ }
513
+ }
514
+
515
+ export async function putFile(
516
+ tenant: string,
517
+ workspace: string,
518
+ rel: string,
519
+ body: Buffer,
520
+ by: string,
521
+ ): Promise<FileRecord> {
522
+ const record = await putFileQuietly(tenant, workspace, rel, body, by);
523
+ // A run's copy-back announces once, for all its files, from harvestFiles.
524
+ if (!by.startsWith("run:")) await announceFiles(tenant, workspace, [record.path], by);
525
+ return record;
526
+ }
527
+
528
+ async function putFileQuietly(
529
+ tenant: string,
530
+ workspace: string,
531
+ rel: string,
532
+ body: Buffer,
533
+ by: string,
534
+ ): Promise<FileRecord> {
535
+ const norm = assertFilePath(rel);
536
+ if (body.length > maxBytes()) {
537
+ throw new Error(`${norm} is ${Math.round(body.length / 1e6)}MB — the limit is ${maxBytes() / 1e6}MB`);
538
+ }
539
+ const sha = sha256(body);
540
+ const index = readIndex(tenant, workspace);
541
+ const previous = index.files.find((f) => f.path === norm);
542
+ if (previous?.sha === sha) return previous; // same bytes, same name: nothing happened
543
+
544
+ const projected = usedBytes(tenant, workspace) - (previous?.size ?? 0) + body.length;
545
+ if (projected > quotaBytes()) {
546
+ throw new Error(`workspace file quota exceeded (${quotaBytes() / 1e6}MB)`);
547
+ }
548
+
549
+ const driver = driverFor(tenant, workspace);
550
+ await driver.put(blobKey(tenant, workspace, sha), body, mimeFor(norm));
551
+
552
+ const record: FileRecord = {
553
+ path: norm,
554
+ sha,
555
+ size: body.length,
556
+ mime: mimeFor(norm),
557
+ updatedAt: new Date().toISOString(),
558
+ by,
559
+ };
560
+ writeIndex(tenant, workspace, {
561
+ version: 1,
562
+ files: [...index.files.filter((f) => f.path !== norm), record],
563
+ });
564
+ if (previous) await collect(tenant, workspace, previous.sha);
565
+ return record;
566
+ }
567
+
568
+ /** Register bytes a browser PUT straight to the bucket. Same bookkeeping as
569
+ * putFile, minus the bytes — the platform never saw them. */
570
+ export async function registerUpload(
571
+ tenant: string,
572
+ workspace: string,
573
+ rel: string,
574
+ sha: string,
575
+ size: number,
576
+ by: string,
577
+ ): Promise<FileRecord> {
578
+ const norm = assertFilePath(rel);
579
+ if (!/^[0-9a-f]{64}$/.test(sha)) throw new Error("expected a sha256");
580
+ const driver = driverFor(tenant, workspace);
581
+ // Trust nothing the browser said about whether the PUT landed.
582
+ if (!(await driver.has(blobKey(tenant, workspace, sha)))) {
583
+ throw new Error("no object at that key — the upload did not complete");
584
+ }
585
+ const index = readIndex(tenant, workspace);
586
+ const previous = index.files.find((f) => f.path === norm);
587
+ const record: FileRecord = {
588
+ path: norm,
589
+ sha,
590
+ size,
591
+ mime: mimeFor(norm),
592
+ updatedAt: new Date().toISOString(),
593
+ by,
594
+ };
595
+ writeIndex(tenant, workspace, {
596
+ version: 1,
597
+ files: [...index.files.filter((f) => f.path !== norm), record],
598
+ });
599
+ if (previous && previous.sha !== sha) await collect(tenant, workspace, previous.sha);
600
+ await announceFiles(tenant, workspace, [record.path], by);
601
+ return record;
602
+ }
603
+
604
+ export async function readFileBytes(
605
+ tenant: string,
606
+ workspace: string,
607
+ rel: string,
608
+ ): Promise<Buffer | null> {
609
+ const record = getFile(tenant, workspace, rel);
610
+ if (!record) return null;
611
+ return driverFor(tenant, workspace).get(blobKey(tenant, workspace, record.sha));
612
+ }
613
+
614
+ export async function deleteFile(tenant: string, workspace: string, rel: string): Promise<boolean> {
615
+ const norm = assertFilePath(rel);
616
+ const index = readIndex(tenant, workspace);
617
+ const record = index.files.find((f) => f.path === norm);
618
+ if (!record) return false;
619
+ writeIndex(tenant, workspace, { version: 1, files: index.files.filter((f) => f.path !== norm) });
620
+ await collect(tenant, workspace, record.sha);
621
+ // The run mirror is a cache of the index, so it follows the index.
622
+ fs.rmSync(path.join(workspaceDir(tenant, workspace), STORAGE_DIR, norm), { force: true });
623
+ return true;
624
+ }
625
+
626
+ /** Drop a blob once the last record naming it is gone. */
627
+ async function collect(tenant: string, workspace: string, sha: string) {
628
+ if (readIndex(tenant, workspace).files.some((f) => f.sha === sha)) return;
629
+ try {
630
+ await driverFor(tenant, workspace).remove(blobKey(tenant, workspace, sha));
631
+ } catch {
632
+ // A leaked blob costs storage; a delete that throws mid-request costs the
633
+ // user their action. The index is already correct, so this is the safe
634
+ // side to fail on.
635
+ }
636
+ }
637
+
638
+ /** A link the browser can follow, or null when the route must stream it. */
639
+ export async function downloadUrl(
640
+ tenant: string,
641
+ workspace: string,
642
+ record: FileRecord,
643
+ ): Promise<string | null> {
644
+ return driverFor(tenant, workspace).presignGet(
645
+ blobKey(tenant, workspace, record.sha),
646
+ path.basename(record.path),
647
+ record.mime,
648
+ );
649
+ }
650
+
651
+ /** A link the browser can PUT to, plus the key it will land at. Null on fs. */
652
+ export async function uploadUrl(
653
+ tenant: string,
654
+ workspace: string,
655
+ rel: string,
656
+ sha: string,
657
+ ): Promise<string | null> {
658
+ assertFilePath(rel);
659
+ if (!/^[0-9a-f]{64}$/.test(sha)) throw new Error("expected a sha256");
660
+ return driverFor(tenant, workspace).presignPut(blobKey(tenant, workspace, sha), mimeFor(rel));
661
+ }
662
+
663
+ // ---------- the run mirror ----------
664
+
665
+ /**
666
+ * Put the workspace's files on disk under `<workspace>/storage/`, where a run
667
+ * copies them into its container like any other directory.
668
+ *
669
+ * On the fs driver this is a hardlink: the blob and the mirror are the same
670
+ * inode, so a 2GB corpus costs 2GB once, not twice. On s3 it is a download,
671
+ * and the mirror is a cache — a second run over the same files fetches
672
+ * nothing.
673
+ *
674
+ * Not pruned. A workspace that churns hundreds of large files will hold a
675
+ * copy of each on the PVC until someone deletes the record, and the quota is
676
+ * what stops that becoming a full disk. Pruning wants an LRU that knows no
677
+ * run is currently reading the file it is about to remove, and that bookkeeping
678
+ * is worth writing when a workspace actually outgrows its quota, not before.
679
+ */
680
+ export async function materializeFiles(tenant: string, workspace: string): Promise<string[]> {
681
+ if (!fileStoreEnabled()) return [];
682
+ adoptLegacyFilesDir(workspaceDir(tenant, workspace));
683
+ const root = path.join(workspaceDir(tenant, workspace), STORAGE_DIR);
684
+ const driver = driverFor(tenant, workspace);
685
+ const brought: string[] = [];
686
+
687
+ for (const record of readIndex(tenant, workspace).files) {
688
+ const target = path.join(root, record.path);
689
+ try {
690
+ const stat = fs.statSync(target);
691
+ // Same size is not proof — a date bumped by a day is the same length
692
+ // as the date before it. Confirm by hash, as the harvest does, before
693
+ // trusting the mirror: a stale copy here rides into the pod, and the
694
+ // harvest then writes it back over the upload that should have won.
695
+ if (stat.size === record.size && sha256(fs.readFileSync(target)) === record.sha) continue;
696
+ } catch {
697
+ // not there yet
698
+ }
699
+ fs.mkdirSync(path.dirname(target), { recursive: true });
700
+ if (driver.kind === "fs") {
701
+ const blob = blobPath(tenant, workspace, record.sha);
702
+ fs.rmSync(target, { force: true });
703
+ try {
704
+ fs.linkSync(blob, target);
705
+ } catch {
706
+ fs.copyFileSync(blob, target); // different filesystem, or a hardlink limit
707
+ }
708
+ } else {
709
+ fs.writeFileSync(target, await driver.get(blobKey(tenant, workspace, record.sha)));
710
+ }
711
+ brought.push(record.path);
712
+ }
713
+ fs.mkdirSync(root, { recursive: true });
714
+ return brought;
715
+ }
716
+
717
+ /**
718
+ * Take what the run left in `storage/` back into the store, stamped with the
719
+ * run that wrote it.
720
+ *
721
+ * Compares against the index rather than a pre-run snapshot: the mirror is
722
+ * derived from the index, so anything on disk that the index does not already
723
+ * describe with the same size and hash is, by construction, new work.
724
+ */
725
+ export async function harvestFiles(
726
+ tenant: string,
727
+ workspace: string,
728
+ by: string,
729
+ ): Promise<{ saved: string[]; errors: string[] }> {
730
+ if (!fileStoreEnabled()) return { saved: [], errors: [] };
731
+ adoptLegacyFilesDir(workspaceDir(tenant, workspace));
732
+ const root = path.join(workspaceDir(tenant, workspace), STORAGE_DIR);
733
+ if (!fs.existsSync(root)) return { saved: [], errors: [] };
734
+
735
+ const known = new Map(readIndex(tenant, workspace).files.map((f) => [f.path, f]));
736
+ const saved: string[] = [];
737
+ const errors: string[] = [];
738
+
739
+ const out: string[] = [];
740
+ const walk = (dir: string) => {
741
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
742
+ const abs = path.join(dir, entry.name);
743
+ if (entry.isDirectory()) {
744
+ walk(abs);
745
+ continue;
746
+ }
747
+ if (!entry.isFile()) continue; // a symlink out of the tree is not a file
748
+ out.push(abs);
749
+ }
750
+ };
751
+ walk(root);
752
+
753
+ for (const abs of out) {
754
+ const rel = path.relative(root, abs).replaceAll("\\", "/");
755
+ try {
756
+ const norm = assertFilePath(rel);
757
+ const stat = fs.statSync(abs);
758
+ const previous = known.get(norm);
759
+ if (previous && previous.size === stat.size) {
760
+ // Same size is not proof, so confirm by hash before skipping a write.
761
+ if (sha256(fs.readFileSync(abs)) === previous.sha) continue;
762
+ }
763
+ await putFileQuietly(tenant, workspace, norm, fs.readFileSync(abs), by);
764
+ saved.push(norm);
765
+ } catch (err) {
766
+ errors.push(`${rel}: ${err instanceof Error ? err.message : String(err)}`);
767
+ }
768
+ }
769
+ if (saved.length) await announceFiles(tenant, workspace, saved, by);
770
+ return { saved, errors };
771
+ }