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