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