@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,377 @@
|
|
|
1
|
+
// Deploying a workspace from a directory — the git-push half of the platform.
|
|
2
|
+
//
|
|
3
|
+
// A workspace is markdown, so there is nothing to build. Deploy is "make the
|
|
4
|
+
// files on disk match the commit", which is a copy plus two things a copy
|
|
5
|
+
// cannot do on its own:
|
|
6
|
+
//
|
|
7
|
+
// 1. Check first. Everything here is declarative, so the whole workspace can
|
|
8
|
+
// be validated before any of it is live — a broken flow is caught at push
|
|
9
|
+
// time rather than at 3am when its schedule fires. A platform deploying
|
|
10
|
+
// compiled code cannot do this cheaply. We can, and it is the reason to
|
|
11
|
+
// route deploys through here instead of writing files directly.
|
|
12
|
+
//
|
|
13
|
+
// 2. Refuse at the wrong moment. Swapping files under a running flow means
|
|
14
|
+
// step 3 reads agents that step 1 never saw.
|
|
15
|
+
//
|
|
16
|
+
// What a deploy must NOT touch is already settled by saveWorkspace: runs/,
|
|
17
|
+
// state/, secrets.json, and any memory an agent wrote that the push doesn't
|
|
18
|
+
// mention. Those are the platform's, not git's. This module decides whether a
|
|
19
|
+
// deploy happens at all; saveWorkspace decides what survives it.
|
|
20
|
+
import fs from "node:fs";
|
|
21
|
+
import os from "node:os";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import { saveWorkspace, workspaceDir, listRuns, parseFlow, WORKSPACE_DIRS, assertSafeName, } from "./store.js";
|
|
24
|
+
import { conformanceIssues } from "./okf.js";
|
|
25
|
+
import { repoDir } from "./gitrepo.js";
|
|
26
|
+
/** Directories a deploy never reads out of a source tree. */
|
|
27
|
+
const NOT_SOURCE = new Set([".git", "node_modules", "runs", "outputs", ".foldrun", ".results"]);
|
|
28
|
+
/** Files that are never part of a workspace, whatever directory they sit in. */
|
|
29
|
+
const NOT_SOURCE_FILE = new Set([".DS_Store", "secrets.json"]);
|
|
30
|
+
const IN_WORKSPACE_DIR = new RegExp(`^(${WORKSPACE_DIRS.join("|")})/`);
|
|
31
|
+
/**
|
|
32
|
+
* Read a directory into the file list a deploy ships.
|
|
33
|
+
*
|
|
34
|
+
* Only what a workspace is made of: AGENTS.md and the tracked directories.
|
|
35
|
+
* Everything else in the repo — a README, .github/, a package.json — is
|
|
36
|
+
* skipped rather than rejected, because a workspace living in a repo alongside
|
|
37
|
+
* other things is the normal case, not an error.
|
|
38
|
+
*/
|
|
39
|
+
// Where single-file subagents authored by other tools are found, in
|
|
40
|
+
// precedence order: the vendor-neutral cross-client location first, then any
|
|
41
|
+
// tool-specific one for pragmatic compatibility. Extend the tail, never the
|
|
42
|
+
// head. Shared by the deploy import (readTree) and the CLI checker so the two
|
|
43
|
+
// agree on what counts as an agent.
|
|
44
|
+
export const SUBAGENT_IMPORT_DIRS = [".agents/agents", ".claude/agents"];
|
|
45
|
+
export function readTree(dir) {
|
|
46
|
+
const root = path.resolve(dir);
|
|
47
|
+
const out = [];
|
|
48
|
+
const walk = (abs, rel) => {
|
|
49
|
+
let entries;
|
|
50
|
+
try {
|
|
51
|
+
entries = fs.readdirSync(abs).sort();
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
for (const entry of entries) {
|
|
57
|
+
if (NOT_SOURCE.has(entry) || NOT_SOURCE_FILE.has(entry))
|
|
58
|
+
continue;
|
|
59
|
+
const full = path.join(abs, entry);
|
|
60
|
+
const next = rel ? `${rel}/${entry}` : entry;
|
|
61
|
+
// Never follow a link out of the tree — the same rule the file listing
|
|
62
|
+
// uses, for the same reason.
|
|
63
|
+
let stat;
|
|
64
|
+
try {
|
|
65
|
+
if (fs.lstatSync(full).isSymbolicLink())
|
|
66
|
+
continue;
|
|
67
|
+
stat = fs.statSync(full);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (stat.isDirectory()) {
|
|
73
|
+
walk(full, next);
|
|
74
|
+
}
|
|
75
|
+
else if (next === "AGENTS.md" || next === "project.md" || IN_WORKSPACE_DIR.test(next)) {
|
|
76
|
+
out.push({ path: next, content: fs.readFileSync(full, "utf8") });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
walk(root, "");
|
|
81
|
+
// Bridge single-file subagents authored by ANY coding tool into the folder
|
|
82
|
+
// shape the runtime expects. The cross-client, vendor-neutral location is
|
|
83
|
+
// `.agents/agents/<name>.md` — the same `.agents/` root as `.agents/skills/`
|
|
84
|
+
// — and it is scanned first. A tool's own location (only `.claude/agents/`
|
|
85
|
+
// so far, as the one that ships single-file subagents) is scanned after, for
|
|
86
|
+
// pragmatic compatibility; add another vendor here when one appears, never
|
|
87
|
+
// ahead of the neutral location. A subagent's frontmatter — name,
|
|
88
|
+
// description, tools, model, effort, skills, mcpServers, disallowedTools — is
|
|
89
|
+
// already what a foldrun agent reads, so each maps to agents/<name>/agent.md
|
|
90
|
+
// with its content unchanged. A native agents/<name>/agent.md always wins,
|
|
91
|
+
// and the neutral location wins over a vendor one; the import only fills a
|
|
92
|
+
// gap, so authoring elsewhere and deploying here needs no rewrite. Unknown
|
|
93
|
+
// vendor-only keys (permissionMode, hooks, …) are ignored, as every other
|
|
94
|
+
// frontmatter reader here ignores what it does not use.
|
|
95
|
+
const nativeAgent = new Set(out.filter((fEntry) => /^agents\/[^/]+\/agent\.md$/.test(fEntry.path)).map((fEntry) => fEntry.path));
|
|
96
|
+
for (const importDir of SUBAGENT_IMPORT_DIRS) {
|
|
97
|
+
let entries;
|
|
98
|
+
try {
|
|
99
|
+
entries = fs.readdirSync(path.join(root, importDir)).sort();
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
for (const entry of entries) {
|
|
105
|
+
if (!entry.endsWith(".md"))
|
|
106
|
+
continue;
|
|
107
|
+
const name = entry.replace(/\.md$/, "");
|
|
108
|
+
const target = `agents/${name}/agent.md`;
|
|
109
|
+
// Native wins; and .claude/agents beats .agents/agents on a name clash
|
|
110
|
+
// (it is scanned first and this skips a path already imported).
|
|
111
|
+
if (nativeAgent.has(target) || out.some((fEntry) => fEntry.path === target))
|
|
112
|
+
continue;
|
|
113
|
+
const full = path.join(root, importDir, entry);
|
|
114
|
+
try {
|
|
115
|
+
if (fs.lstatSync(full).isSymbolicLink())
|
|
116
|
+
continue;
|
|
117
|
+
out.push({ path: target, content: fs.readFileSync(full, "utf8") });
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// an unreadable import is skipped, not fatal
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Everything wrong with an incoming workspace, before any of it is live.
|
|
128
|
+
*
|
|
129
|
+
* Deliberately the same questions `foldrun check` asks, against files that are
|
|
130
|
+
* not on disk yet. A deploy that would leave the workspace failing its own
|
|
131
|
+
* checker should not be a deploy.
|
|
132
|
+
*/
|
|
133
|
+
export function deployIssues(files) {
|
|
134
|
+
const issues = [];
|
|
135
|
+
const at = (where, message) => issues.push({ where, message });
|
|
136
|
+
if (files.length === 0)
|
|
137
|
+
at("/", "nothing to deploy — no workspace files in this tree");
|
|
138
|
+
if (files.length > 500)
|
|
139
|
+
at("/", `${files.length} files — a workspace is capped at 500`);
|
|
140
|
+
// Path safety, as questions rather than exceptions: a push should report
|
|
141
|
+
// everything wrong with it at once, not fail on the first bad path.
|
|
142
|
+
for (const f of files) {
|
|
143
|
+
const norm = path.normalize(f.path);
|
|
144
|
+
if (norm.startsWith("..") || path.isAbsolute(norm) || norm.startsWith("runs")) {
|
|
145
|
+
at(f.path, "illegal path");
|
|
146
|
+
}
|
|
147
|
+
const base = norm.split("/").pop() ?? norm;
|
|
148
|
+
for (const want of ["AGENTS.md", "SKILL.md"]) {
|
|
149
|
+
if (base !== want && base.toLowerCase() === want.toLowerCase()) {
|
|
150
|
+
at(f.path, `must be spelled exactly "${want}" — the case is part of the standard`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const agents = new Set();
|
|
155
|
+
for (const f of files) {
|
|
156
|
+
const m = f.path.match(/^agents\/([^/]+)\/agent\.md$/);
|
|
157
|
+
if (m)
|
|
158
|
+
agents.add(m[1]);
|
|
159
|
+
}
|
|
160
|
+
if (agents.size === 0) {
|
|
161
|
+
at("agents/", "no agents — a workspace needs at least one agents/<name>/agent.md");
|
|
162
|
+
}
|
|
163
|
+
// Flows, parsed from the incoming text rather than from disk. A step naming
|
|
164
|
+
// an agent that this push does not ship is the single most common way a
|
|
165
|
+
// deploy breaks a schedule, and it is free to catch here.
|
|
166
|
+
const flows = files.filter((f) => /^flows\/[^/]+\.md$/.test(f.path));
|
|
167
|
+
const flowNames = new Set();
|
|
168
|
+
for (const f of flows) {
|
|
169
|
+
try {
|
|
170
|
+
flowNames.add(parseFlow(path.basename(f.path), f.content).name);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
// reported below, when it is parsed for real
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
for (const f of flows) {
|
|
177
|
+
let parsed;
|
|
178
|
+
try {
|
|
179
|
+
parsed = parseFlow(path.basename(f.path), f.content);
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
at(f.path, err instanceof Error ? err.message : String(err));
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (parsed.steps.length === 0)
|
|
186
|
+
at(f.path, "no steps");
|
|
187
|
+
for (const s of parsed.steps) {
|
|
188
|
+
const target = s.subflow ?? s.agent;
|
|
189
|
+
const known = s.subflow ? flowNames.has(target) : agents.has(target);
|
|
190
|
+
if (!known) {
|
|
191
|
+
at(`${f.path}${s.line ? `:${s.line}` : ""}`, `[[${s.subflow ? "flow:" : ""}${target}]] does not exist in this deploy`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// OKF conformance, which needs the files as a tree. Staged in a temp
|
|
196
|
+
// directory and thrown away — nothing touches the live workspace until every
|
|
197
|
+
// check above has passed.
|
|
198
|
+
const staged = fs.mkdtempSync(path.join(os.tmpdir(), "foldrun-stage-"));
|
|
199
|
+
try {
|
|
200
|
+
for (const f of files) {
|
|
201
|
+
const norm = path.normalize(f.path);
|
|
202
|
+
if (norm.startsWith("..") || path.isAbsolute(norm))
|
|
203
|
+
continue;
|
|
204
|
+
const target = path.join(staged, norm);
|
|
205
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
206
|
+
fs.writeFileSync(target, f.content);
|
|
207
|
+
}
|
|
208
|
+
for (const bundle of ["memory", "knowledge"]) {
|
|
209
|
+
for (const { file, issue } of conformanceIssues(path.join(staged, bundle))) {
|
|
210
|
+
at(`${bundle}/${file}`, issue);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
for (const agent of agents) {
|
|
214
|
+
for (const bundle of ["memory", "knowledge"]) {
|
|
215
|
+
const dir = path.join(staged, "agents", agent, bundle);
|
|
216
|
+
for (const { file, issue } of conformanceIssues(dir)) {
|
|
217
|
+
at(`agents/${agent}/${bundle}/${file}`, issue);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
finally {
|
|
223
|
+
fs.rmSync(staged, { recursive: true, force: true });
|
|
224
|
+
}
|
|
225
|
+
return issues;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Runs that make this a bad moment to swap the files under them.
|
|
229
|
+
*
|
|
230
|
+
* A flow reads its agents step by step. Replacing them mid-run means step 3
|
|
231
|
+
* runs against a definition step 1 never saw, and the trace becomes a record
|
|
232
|
+
* of two different workspaces. A run waiting on a person counts too: they are
|
|
233
|
+
* about to approve work that the deploy would silently redefine.
|
|
234
|
+
*/
|
|
235
|
+
export function runsInFlight(tenant, workspace) {
|
|
236
|
+
if (!fs.existsSync(workspaceDir(tenant, workspace)))
|
|
237
|
+
return [];
|
|
238
|
+
return listRuns(tenant, workspace)
|
|
239
|
+
.filter((r) => r.status === "queued" || r.status === "running" || r.status === "awaiting-approval")
|
|
240
|
+
.map((r) => r.id);
|
|
241
|
+
}
|
|
242
|
+
/** What a deploy would do, without doing it. */
|
|
243
|
+
export function planDeploy(tenant, workspace, files) {
|
|
244
|
+
const dir = workspaceDir(tenant, workspace);
|
|
245
|
+
const shipped = new Map(files.map((f) => [path.normalize(f.path), f.content]));
|
|
246
|
+
const present = new Set();
|
|
247
|
+
if (fs.existsSync(dir)) {
|
|
248
|
+
for (const entry of fs.readdirSync(dir, { recursive: true })) {
|
|
249
|
+
const rel = String(entry).split(path.sep).join("/");
|
|
250
|
+
if (NOT_SOURCE.has(rel.split("/")[0]))
|
|
251
|
+
continue;
|
|
252
|
+
if (/(^|\/)(runs|outputs|\.results)\//.test(rel))
|
|
253
|
+
continue;
|
|
254
|
+
if (rel === "secrets.json")
|
|
255
|
+
continue;
|
|
256
|
+
try {
|
|
257
|
+
if (!fs.statSync(path.join(dir, rel)).isFile())
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
present.add(rel);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const added = [];
|
|
267
|
+
const updated = [];
|
|
268
|
+
for (const [rel, content] of shipped) {
|
|
269
|
+
if (!present.has(rel)) {
|
|
270
|
+
added.push(rel);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
try {
|
|
274
|
+
if (fs.readFileSync(path.join(dir, rel), "utf8") !== content)
|
|
275
|
+
updated.push(rel);
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
updated.push(rel);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
// What saveWorkspace will keep regardless — reporting these as removals
|
|
282
|
+
// would be a lie, and the alarming kind.
|
|
283
|
+
const kept = (rel) => /(^|\/)state\//.test(rel) || /(^|\/)memory\/[^/]+\.md$/.test(rel);
|
|
284
|
+
const removed = [...present].filter((rel) => !shipped.has(rel) && !kept(rel));
|
|
285
|
+
return {
|
|
286
|
+
files,
|
|
287
|
+
added: added.sort(),
|
|
288
|
+
updated: updated.sort(),
|
|
289
|
+
removed: removed.sort(),
|
|
290
|
+
issues: deployIssues(files),
|
|
291
|
+
blockedBy: runsInFlight(tenant, workspace),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
export const pushNotePath = (tenant, workspace) => path.join(repoDir(tenant, workspace), "foldrun-last-push.json");
|
|
295
|
+
export function readPushNote(tenant, workspace) {
|
|
296
|
+
try {
|
|
297
|
+
return JSON.parse(fs.readFileSync(pushNotePath(tenant, workspace), "utf8"));
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
export function writePushNote(tenant, workspace, note) {
|
|
304
|
+
try {
|
|
305
|
+
fs.writeFileSync(pushNotePath(tenant, workspace), JSON.stringify(note));
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
// The note is a record, not the deploy. Losing it must not fail a push.
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* A push whose deploy was refused only because a run was in flight is not a
|
|
313
|
+
* failure — it is a deploy that has not happened yet. Git already said the
|
|
314
|
+
* push succeeded (the deploy happens after the client is gone), so nothing
|
|
315
|
+
* would ever tell anyone the workspace on disk is behind `main`. This applies
|
|
316
|
+
* it once the runs are done.
|
|
317
|
+
*
|
|
318
|
+
* Only `blockedBy`: a push refused for `issues` is a broken workspace and
|
|
319
|
+
* wants a person, not a retry.
|
|
320
|
+
*/
|
|
321
|
+
export function applyPendingPush(tenant, workspace, filesAt) {
|
|
322
|
+
const note = readPushNote(tenant, workspace);
|
|
323
|
+
if (!note || note.applied)
|
|
324
|
+
return null;
|
|
325
|
+
if (!note.blockedBy?.length || (note.issues?.length ?? 0) > 0)
|
|
326
|
+
return null;
|
|
327
|
+
if (runsInFlight(tenant, workspace).length > 0)
|
|
328
|
+
return null;
|
|
329
|
+
// Only the commit the note names, and only while it is still what main
|
|
330
|
+
// points at — a newer push supersedes it and wrote its own note.
|
|
331
|
+
const result = deployWorkspace(tenant, workspace, filesAt(tenant, workspace, note.commit), {
|
|
332
|
+
commit: note.commit,
|
|
333
|
+
force: false,
|
|
334
|
+
});
|
|
335
|
+
writePushNote(tenant, workspace, {
|
|
336
|
+
...note,
|
|
337
|
+
applied: result.applied,
|
|
338
|
+
issues: result.issues ?? [],
|
|
339
|
+
blockedBy: result.blockedBy ?? [],
|
|
340
|
+
...(result.applied ? { appliedAt: new Date().toISOString() } : {}),
|
|
341
|
+
});
|
|
342
|
+
return result.applied ? { commit: note.commit } : null;
|
|
343
|
+
}
|
|
344
|
+
export function deployWorkspace(tenant, workspace, files, opts = {}) {
|
|
345
|
+
assertSafeName(tenant, "tenant");
|
|
346
|
+
assertSafeName(workspace, "workspace");
|
|
347
|
+
const plan = planDeploy(tenant, workspace, files);
|
|
348
|
+
const blocked = plan.blockedBy.length > 0 && !opts.force;
|
|
349
|
+
if (plan.issues.length > 0 || blocked) {
|
|
350
|
+
return { ...plan, applied: false, commit: null, preserved: 0 };
|
|
351
|
+
}
|
|
352
|
+
const { preserved } = saveWorkspace(tenant, workspace, files, { commit: opts.commit ?? null, by: opts.by ?? "deploy", message: opts.message });
|
|
353
|
+
const commit = opts.commit ?? null;
|
|
354
|
+
if (commit)
|
|
355
|
+
writeDeployedCommit(tenant, workspace, commit);
|
|
356
|
+
return { ...plan, applied: true, commit, preserved };
|
|
357
|
+
}
|
|
358
|
+
// Which commit a workspace is running.
|
|
359
|
+
//
|
|
360
|
+
// Outside the workspace directory on purpose: saveWorkspace replaces that
|
|
361
|
+
// directory wholesale, so a marker written inside it would be deleted by the
|
|
362
|
+
// very deploy it describes. It also is not a workspace file — nobody should
|
|
363
|
+
// see it in the file tree or be able to edit it.
|
|
364
|
+
const commitFile = (tenant, workspace) => path.join(workspaceDir(tenant, workspace), "..", `.${workspace}.deployed`);
|
|
365
|
+
export function writeDeployedCommit(tenant, workspace, commit) {
|
|
366
|
+
const f = commitFile(tenant, workspace);
|
|
367
|
+
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
368
|
+
fs.writeFileSync(f, `${JSON.stringify({ commit, at: new Date().toISOString() })}\n`);
|
|
369
|
+
}
|
|
370
|
+
export function deployedCommit(tenant, workspace) {
|
|
371
|
+
try {
|
|
372
|
+
return JSON.parse(fs.readFileSync(commitFile(tenant, workspace), "utf8"));
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/** `${NAME}` — the shape a secret reference takes everywhere in foldrun. */
|
|
2
|
+
export declare const PLACEHOLDER: RegExp;
|
|
3
|
+
/** The env var the pod reads to know it should send through a proxy. */
|
|
4
|
+
export declare const EGRESS_ENV = "FOLDRUN_EGRESS";
|
|
5
|
+
/** The placeholder name the platform's own model key travels under. */
|
|
6
|
+
export declare const MODEL_KEY_NAME = "FOLDRUN_MODEL_KEY";
|
|
7
|
+
/**
|
|
8
|
+
* The address a request to `target` should actually be sent to. With no
|
|
9
|
+
* proxy configured, the target itself — every caller works unchanged on a
|
|
10
|
+
* machine with no worker, which is the CLI and every test.
|
|
11
|
+
*/
|
|
12
|
+
export declare function viaEgress(egress: string | undefined | null, target: string): string;
|
|
13
|
+
/** The names referenced by placeholders in a string. */
|
|
14
|
+
export declare function placeholderNames(text: string): string[];
|
|
15
|
+
/** Replace the placeholders in `text` whose names `values` knows. Unknown
|
|
16
|
+
* names stay literal, so a wrong grant fails loudly at the provider
|
|
17
|
+
* instead of silently sending nothing. */
|
|
18
|
+
export declare function substitutePlaceholders(text: string, values: Record<string, string>): string;
|
|
19
|
+
/** The host a granted secret may be sent to. Compared case-insensitively;
|
|
20
|
+
* a port is part of the host. */
|
|
21
|
+
export declare function hostOf(url: string): string | null;
|
|
22
|
+
/**
|
|
23
|
+
* What one step is allowed to send, and where. The runner builds this
|
|
24
|
+
* from the step's declared secrets and the hosts its tools and provider
|
|
25
|
+
* name; the proxy enforces it per request.
|
|
26
|
+
*/
|
|
27
|
+
export interface EgressGrant {
|
|
28
|
+
/** Secret name → the hosts that secret may be substituted for. */
|
|
29
|
+
secrets: Record<string, {
|
|
30
|
+
value: string;
|
|
31
|
+
hosts: string[];
|
|
32
|
+
}>;
|
|
33
|
+
}
|
|
34
|
+
export interface EgressLease {
|
|
35
|
+
/** The base the pod sends through: `http://…/e/<token>`. */
|
|
36
|
+
url: string;
|
|
37
|
+
/** The grant is finished being built — publish it. The runner keeps
|
|
38
|
+
* adding grants after the lease exists (the model key's host needs the
|
|
39
|
+
* lease URL first), so a store that is not this process's memory has
|
|
40
|
+
* to be told when the grant is complete. Call before every attempt. */
|
|
41
|
+
commit(): Promise<void>;
|
|
42
|
+
/** One line per request, for the run trace. Drained by the runner. */
|
|
43
|
+
drainLog(): Promise<string[]>;
|
|
44
|
+
/** The step is over: the lease is void and its values are dropped. */
|
|
45
|
+
release(): Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The seam the platform fills. `null` from `lease` (or no `egress` at all)
|
|
49
|
+
* means "no proxy here" and the runner falls back to materialising, exactly
|
|
50
|
+
* as it always did.
|
|
51
|
+
*/
|
|
52
|
+
export interface EgressHooks {
|
|
53
|
+
lease(args: {
|
|
54
|
+
tenant: string;
|
|
55
|
+
runId: string;
|
|
56
|
+
grant: EgressGrant;
|
|
57
|
+
}): Promise<EgressLease | null>;
|
|
58
|
+
}
|
|
59
|
+
/** Strip a header set of the proxy's own and hop-by-hop headers. */
|
|
60
|
+
export declare const HOP_BY_HOP: Set<string>;
|
|
61
|
+
/**
|
|
62
|
+
* A model env (the SDK's ANTHROPIC_* variables) rewritten to send through
|
|
63
|
+
* a lease: the base URL goes via the proxy, and whichever credential is
|
|
64
|
+
* set becomes the `${FOLDRUN_MODEL_KEY}` placeholder — the SDK only ever
|
|
65
|
+
* puts it in a header, and the proxy fills it for that one host. Provider
|
|
66
|
+
* headers that carried a secret's value get the value swapped back for
|
|
67
|
+
* its `${NAME}`, so the pod holds the name and the proxy holds the value.
|
|
68
|
+
* Adds the grants it needs to `grant`. Pure apart from that.
|
|
69
|
+
*/
|
|
70
|
+
export declare function proxyModelEnv(env: Record<string, string | undefined>, leaseUrl: string, grant: EgressGrant, knownSecrets?: Record<string, string>, name?: string): Record<string, string | undefined>;
|
|
71
|
+
/** Put the `${NAME}` back where a known secret's value sits in `text`,
|
|
72
|
+
* granting the name to `host`. Longest values first, so one that contains
|
|
73
|
+
* another is replaced whole. */
|
|
74
|
+
export declare function unsubstitute(text: string, knownSecrets: Record<string, string>, host: string, grant: EgressGrant): string;
|
|
75
|
+
export declare function addGrant(grant: EgressGrant, name: string, value: string, host: string): void;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Egress: the run sandbox never holds a credential it only ever puts in a
|
|
2
|
+
// header.
|
|
3
|
+
//
|
|
4
|
+
// Until now a step's secrets were decrypted host-side and copied into the
|
|
5
|
+
// sandbox as environment variables. The model never saw a value — only the
|
|
6
|
+
// names — but every program in the pod could read them: a script tool, a
|
|
7
|
+
// bash grant, a dependency `runtime:` installed. The pod was the blast
|
|
8
|
+
// radius, and the credential was inside it.
|
|
9
|
+
//
|
|
10
|
+
// The egress proxy moves the credential to the boundary. The pod is handed
|
|
11
|
+
// a per-step address (`http://<worker>/e/<lease>`) and a set of
|
|
12
|
+
// placeholders (`${RESEND_API_KEY}`), and sends every API request through
|
|
13
|
+
// that address with the placeholder still in the header. The worker holds
|
|
14
|
+
// the lease, swaps the placeholder for the value on the way out — for the
|
|
15
|
+
// host that secret was granted to, and no other — forwards the request, and
|
|
16
|
+
// streams the answer back. The value exists in the worker's memory and on
|
|
17
|
+
// the wire to the provider. Never in the pod.
|
|
18
|
+
//
|
|
19
|
+
// Three consumers, in the pod:
|
|
20
|
+
// - the http tools (api-tools.ts): every declared API
|
|
21
|
+
// - the translator (translator.ts): the model key, for Chat Completions
|
|
22
|
+
// and Responses providers
|
|
23
|
+
// - the Agent SDK itself, for Anthropic-shaped providers: its base URL is
|
|
24
|
+
// pointed through the proxy and its key IS the placeholder
|
|
25
|
+
//
|
|
26
|
+
// Script tools are the exception, on purpose: a script reads secrets from
|
|
27
|
+
// its environment and may need the real value in process (a browser seeding
|
|
28
|
+
// cookies, an ssh key). A step that grants such a script materialises its
|
|
29
|
+
// secrets into the pod as before, and the run record says so. A script that
|
|
30
|
+
// declares `secrets: proxied` in its tool file gets the placeholders and
|
|
31
|
+
// `FOLDRUN_EGRESS` instead, and sends through the proxy like everything
|
|
32
|
+
// else.
|
|
33
|
+
//
|
|
34
|
+
// This file is the vocabulary both sides share. The server is the
|
|
35
|
+
// platform's (it needs a long-lived process and a Service); the pod side is
|
|
36
|
+
// here in core, because it is what the runner image runs.
|
|
37
|
+
/** `${NAME}` — the shape a secret reference takes everywhere in foldrun. */
|
|
38
|
+
export const PLACEHOLDER = /\$\{([A-Z][A-Z0-9_]*)\}/g;
|
|
39
|
+
/** The env var the pod reads to know it should send through a proxy. */
|
|
40
|
+
export const EGRESS_ENV = "FOLDRUN_EGRESS";
|
|
41
|
+
/** The placeholder name the platform's own model key travels under. */
|
|
42
|
+
export const MODEL_KEY_NAME = "FOLDRUN_MODEL_KEY";
|
|
43
|
+
/**
|
|
44
|
+
* The address a request to `target` should actually be sent to. With no
|
|
45
|
+
* proxy configured, the target itself — every caller works unchanged on a
|
|
46
|
+
* machine with no worker, which is the CLI and every test.
|
|
47
|
+
*/
|
|
48
|
+
export function viaEgress(egress, target) {
|
|
49
|
+
if (!egress)
|
|
50
|
+
return target;
|
|
51
|
+
return `${egress.replace(/\/+$/, "")}/${target}`;
|
|
52
|
+
}
|
|
53
|
+
/** The names referenced by placeholders in a string. */
|
|
54
|
+
export function placeholderNames(text) {
|
|
55
|
+
const out = new Set();
|
|
56
|
+
for (const m of text.matchAll(PLACEHOLDER))
|
|
57
|
+
out.add(m[1]);
|
|
58
|
+
return [...out];
|
|
59
|
+
}
|
|
60
|
+
/** Replace the placeholders in `text` whose names `values` knows. Unknown
|
|
61
|
+
* names stay literal, so a wrong grant fails loudly at the provider
|
|
62
|
+
* instead of silently sending nothing. */
|
|
63
|
+
export function substitutePlaceholders(text, values) {
|
|
64
|
+
return text.replace(PLACEHOLDER, (whole, name) => (name in values ? values[name] : whole));
|
|
65
|
+
}
|
|
66
|
+
/** The host a granted secret may be sent to. Compared case-insensitively;
|
|
67
|
+
* a port is part of the host. */
|
|
68
|
+
export function hostOf(url) {
|
|
69
|
+
try {
|
|
70
|
+
return new URL(url).host.toLowerCase();
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** Strip a header set of the proxy's own and hop-by-hop headers. */
|
|
77
|
+
export const HOP_BY_HOP = new Set([
|
|
78
|
+
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer",
|
|
79
|
+
"transfer-encoding", "upgrade", "host", "content-length",
|
|
80
|
+
]);
|
|
81
|
+
const ANTHROPIC_DEFAULT_BASE = "https://api.anthropic.com";
|
|
82
|
+
/**
|
|
83
|
+
* A model env (the SDK's ANTHROPIC_* variables) rewritten to send through
|
|
84
|
+
* a lease: the base URL goes via the proxy, and whichever credential is
|
|
85
|
+
* set becomes the `${FOLDRUN_MODEL_KEY}` placeholder — the SDK only ever
|
|
86
|
+
* puts it in a header, and the proxy fills it for that one host. Provider
|
|
87
|
+
* headers that carried a secret's value get the value swapped back for
|
|
88
|
+
* its `${NAME}`, so the pod holds the name and the proxy holds the value.
|
|
89
|
+
* Adds the grants it needs to `grant`. Pure apart from that.
|
|
90
|
+
*/
|
|
91
|
+
export function proxyModelEnv(env, leaseUrl, grant, knownSecrets = {}, name = MODEL_KEY_NAME) {
|
|
92
|
+
const base = env.ANTHROPIC_BASE_URL || ANTHROPIC_DEFAULT_BASE;
|
|
93
|
+
const host = hostOf(base);
|
|
94
|
+
if (!host)
|
|
95
|
+
return env;
|
|
96
|
+
const out = { ...env, ANTHROPIC_BASE_URL: viaEgress(leaseUrl, base) };
|
|
97
|
+
const credentialKeys = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"];
|
|
98
|
+
for (const key of credentialKeys) {
|
|
99
|
+
const value = env[key];
|
|
100
|
+
if (typeof value === "string" && value.length) {
|
|
101
|
+
addGrant(grant, name, value, host);
|
|
102
|
+
out[key] = `\${${name}}`;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (typeof env.ANTHROPIC_CUSTOM_HEADERS === "string") {
|
|
106
|
+
out.ANTHROPIC_CUSTOM_HEADERS = unsubstitute(env.ANTHROPIC_CUSTOM_HEADERS, knownSecrets, host, grant);
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
/** Put the `${NAME}` back where a known secret's value sits in `text`,
|
|
111
|
+
* granting the name to `host`. Longest values first, so one that contains
|
|
112
|
+
* another is replaced whole. */
|
|
113
|
+
export function unsubstitute(text, knownSecrets, host, grant) {
|
|
114
|
+
let out = text;
|
|
115
|
+
const entries = Object.entries(knownSecrets).filter(([, v]) => v.length >= 8).sort((a, b) => b[1].length - a[1].length);
|
|
116
|
+
for (const [name, value] of entries) {
|
|
117
|
+
if (!out.includes(value))
|
|
118
|
+
continue;
|
|
119
|
+
out = out.split(value).join(`\${${name}}`);
|
|
120
|
+
addGrant(grant, name, value, host);
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
export function addGrant(grant, name, value, host) {
|
|
125
|
+
const h = host.toLowerCase();
|
|
126
|
+
const existing = grant.secrets[name];
|
|
127
|
+
if (existing) {
|
|
128
|
+
if (!existing.hosts.includes(h))
|
|
129
|
+
existing.hosts.push(h);
|
|
130
|
+
}
|
|
131
|
+
else
|
|
132
|
+
grant.secrets[name] = { value, hosts: [h] };
|
|
133
|
+
}
|