@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
@@ -0,0 +1,296 @@
1
+ // Scripts declared as tools. An agent that writes
2
+ //
3
+ // scripts:
4
+ // - name: ads_summary
5
+ // run: scripts/summary.py
6
+ // description: Pull campaign performance for the account.
7
+ // args:
8
+ // customer_id: The Google Ads customer id
9
+ //
10
+ // gets a real tool called `ads_summary(customer_id)` — no bash needed. The
11
+ // platform spawns the script with the declared args as CLI flags, injects the
12
+ // agent's secrets as environment variables, and returns stdout.
13
+ //
14
+ // This is safer than granting `bash`: the agent can only run the scripts you
15
+ // declared, with the arguments you declared, and never composes a shell line.
16
+ import path from "node:path";
17
+ import fs from "node:fs";
18
+ import { spawn } from "node:child_process";
19
+ import { z } from "zod";
20
+ import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
21
+ import { runInContainer } from "./container.js";
22
+ // A script's only clock is the `timeout:` its own tool.md declares. There
23
+ // was a two-minute default and a ten-minute cap here; both were the
24
+ // platform's opinion about how long a person's script may take, and the
25
+ // deploy watcher had to be taught to call its tool four times to outlast the
26
+ // cap. The platform orchestrates; it does not decide that.
27
+ const timeoutFor = (spec) => (spec.timeout ? spec.timeout * 1000 : undefined);
28
+ const MAX_OUTPUT = 20_000;
29
+ export function parseScripts(raw) {
30
+ if (!Array.isArray(raw))
31
+ return [];
32
+ const out = [];
33
+ for (const entry of raw) {
34
+ if (!entry || typeof entry !== "object")
35
+ continue;
36
+ const e = entry;
37
+ const name = typeof e.name === "string" ? e.name : null;
38
+ const run = typeof e.run === "string" ? e.run : null;
39
+ const code = typeof e.code === "string" ? e.code : null;
40
+ if (!name || (!run && !code))
41
+ continue;
42
+ const args = {};
43
+ if (e.args && typeof e.args === "object" && !Array.isArray(e.args)) {
44
+ for (const [k, v] of Object.entries(e.args)) {
45
+ if (/^[a-z][a-z0-9_]*$/i.test(k))
46
+ args[k] = String(v);
47
+ }
48
+ }
49
+ const timeout = Number(e.timeout);
50
+ out.push({
51
+ name: name.replace(/[^a-zA-Z0-9_]/g, "_"),
52
+ run: run ?? "",
53
+ ...(code ? { code, codeExt: typeof e.codeExt === "string" ? e.codeExt : ".mjs" } : {}),
54
+ description: typeof e.description === "string" ? e.description : "",
55
+ args,
56
+ interpreter: typeof e.interpreter === "string" ? e.interpreter : undefined,
57
+ secrets: e.secrets === "proxied" ? "proxied" : undefined,
58
+ timeout: Number.isFinite(timeout) && timeout > 0 ? timeout : undefined,
59
+ });
60
+ }
61
+ return out;
62
+ }
63
+ // Pick how to execute a file when no interpreter is declared.
64
+ //
65
+ // Exported because the tool tester has to make the same choice, and used to
66
+ // make it from its own shorter table — which knew .js but not .mjs, so the
67
+ // Test button spawned every .mjs tool as a program and reported EACCES for a
68
+ // tool that runs perfectly. Two tables, one of them wrong.
69
+ export function commandFor(spec, abs, overrides = {}) {
70
+ if (spec.interpreter)
71
+ return { cmd: spec.interpreter, args: [abs] };
72
+ const ext = path.extname(abs).toLowerCase();
73
+ // A prepared runtime (venv, npm prefix) wins over the host interpreter.
74
+ if (overrides[ext])
75
+ return { cmd: overrides[ext], args: [abs] };
76
+ const byExt = {
77
+ ".py": "python3",
78
+ ".sh": "bash",
79
+ ".js": "node",
80
+ ".mjs": "node",
81
+ ".ts": "node",
82
+ ".rb": "ruby",
83
+ };
84
+ const interp = byExt[ext];
85
+ return interp ? { cmd: interp, args: [abs] } : { cmd: abs, args: [] };
86
+ }
87
+ // Inside the image the interpreter is the distro's, not a host venv path.
88
+ function containerInterpreter(abs) {
89
+ const byExt = {
90
+ ".py": "python3",
91
+ ".sh": "bash",
92
+ ".js": "node",
93
+ ".mjs": "node",
94
+ ".ts": "node",
95
+ ".rb": "ruby",
96
+ };
97
+ return byExt[path.extname(abs).toLowerCase()] ?? abs;
98
+ }
99
+ function runScript(cwd, spec, values, env, libraryScripts, interpreters, exec) {
100
+ // An inline-code tool has no file yet: write it inside the agent's own
101
+ // directory — already an allowed root — and run it like any other script.
102
+ // Materialised per call rather than per build, so an edit to the tool's
103
+ // markdown is live on the next call with nothing to clean up but one file.
104
+ if (!spec.run && spec.code) {
105
+ const dir = path.join(cwd, ".tool-code");
106
+ fs.mkdirSync(dir, { recursive: true });
107
+ const file = path.join(dir, `${spec.name}${spec.codeExt ?? ".mjs"}`);
108
+ fs.writeFileSync(file, spec.code, { mode: 0o755 });
109
+ spec = { ...spec, run: `.tool-code/${spec.name}${spec.codeExt ?? ".mjs"}` };
110
+ }
111
+ return new Promise((resolve) => {
112
+ // Confine to the agent's own directory plus the workspace it belongs to.
113
+ // The workspace root is allowed because `shared/` resolves to
114
+ // <workspace>/scripts — a virtual prefix expanded at resolve time, not a
115
+ // symlink on disk. (Symlinking it was tried and reverted: the link
116
+ // recursed into the workspace's own file listing.)
117
+ const abs = resolveRunPath(cwd, spec.run, libraryScripts);
118
+ const workspaceRoot = path.resolve(cwd, "..", "..");
119
+ // The library's tools/ as well as its scripts/: a folder tool installed at
120
+ // the account keeps its code beside its definition, which is one directory
121
+ // over from where shared scripts live. Without it the containment check
122
+ // refuses an account tool the resolver just found.
123
+ const libraryTools = libraryScripts ? path.resolve(libraryScripts, "..", "tools") : "";
124
+ const allowedRoots = [path.resolve(cwd), workspaceRoot, libraryScripts, libraryTools]
125
+ .filter(Boolean)
126
+ .map((p) => {
127
+ try {
128
+ return fs.realpathSync(p);
129
+ }
130
+ catch {
131
+ return p;
132
+ }
133
+ });
134
+ let real;
135
+ try {
136
+ real = fs.realpathSync(abs);
137
+ }
138
+ catch {
139
+ return resolve({ code: null, out: `script not found: ${spec.run}` });
140
+ }
141
+ // Containment, not a string prefix. `startsWith` called
142
+ // `<workspace>-attacker/steal.py` a path inside `<workspace>`, because it
143
+ // is — as text. The question is whether one path is under another, and
144
+ // only path.relative answers that.
145
+ const within = (root) => {
146
+ const rel = path.relative(root, real);
147
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
148
+ };
149
+ if (!allowedRoots.some(within)) {
150
+ return resolve({ code: null, out: `script path escapes the workspace: ${spec.run}` });
151
+ }
152
+ const { cmd, args } = commandFor(spec, abs, interpreters);
153
+ // Declared args become long flags: --customer_id 123
154
+ for (const key of Object.keys(spec.args)) {
155
+ if (values[key] !== undefined && values[key] !== "") {
156
+ args.push(`--${key}`, values[key]);
157
+ }
158
+ }
159
+ // Container path: same command, executed inside an isolated container
160
+ // with only the agent's directory (plus declared shared dirs) mounted.
161
+ if (exec && exec.executor === "docker") {
162
+ const inContainer = (p) => {
163
+ if (p.startsWith(path.resolve(cwd)))
164
+ return p.replace(path.resolve(cwd), "/workspace");
165
+ for (const [host, mount] of Object.entries(exec.mounts)) {
166
+ if (p.startsWith(host))
167
+ return p.replace(host, mount);
168
+ }
169
+ return p;
170
+ };
171
+ const argv = [
172
+ // The interpreter inside the image, not the host's venv path.
173
+ spec.interpreter ?? containerInterpreter(abs),
174
+ ...args.map(inContainer),
175
+ ].filter(Boolean);
176
+ // Drop the host interpreter that commandFor prepended, if any.
177
+ if (argv[1] === argv[0])
178
+ argv.splice(1, 1);
179
+ runInContainer({
180
+ agentDir: path.resolve(cwd),
181
+ readOnly: exec.mounts,
182
+ image: exec.image,
183
+ argv,
184
+ env,
185
+ timeoutMs: timeoutFor(spec),
186
+ network: exec.network,
187
+ maxOutput: MAX_OUTPUT,
188
+ }).then(resolve, (err) => resolve({ code: null, out: `container error: ${err instanceof Error ? err.message : String(err)}` }));
189
+ return;
190
+ }
191
+ const child = spawn(cmd, args, {
192
+ cwd,
193
+ env: { ...process.env, ...env },
194
+ timeout: timeoutFor(spec),
195
+ });
196
+ let out = "";
197
+ const append = (chunk) => {
198
+ if (out.length < MAX_OUTPUT)
199
+ out += chunk.toString();
200
+ };
201
+ child.stdout.on("data", append);
202
+ child.stderr.on("data", append);
203
+ child.on("error", (err) => resolve({ code: null, out: `failed to start: ${err.message}` }));
204
+ child.on("close", (code) => resolve({
205
+ code,
206
+ out: out.length > MAX_OUTPUT
207
+ ? `${out.slice(0, MAX_OUTPUT)}\n…[truncated]`
208
+ : out || "(no output)",
209
+ }));
210
+ });
211
+ }
212
+ // `run:` accepts:
213
+ // scripts/x.py this agent's own script
214
+ // workspace/tools/<t>/x.py a folder tool's own code, in this workspace
215
+ // account/tools/<t>/x.py a folder tool's own code, in the library
216
+ // shared/x.py the workspace's scripts/
217
+ // library/x.py the workspace library's scripts/
218
+ //
219
+ // The tools/ forms are written by readToolDir rather than by hand: a folder
220
+ // tool's definition says `run: run.mjs` and never names its own scope, which
221
+ // is what lets the same folder be copied into a workspace or installed at the
222
+ // account without an edit.
223
+ export function resolveRunPath(agentDir, run, libraryScripts) {
224
+ const workspaceRoot = path.resolve(agentDir, "..", "..");
225
+ // The library's tools/ sits beside its scripts/ — one mount, two shelves.
226
+ const libraryRoot = path.resolve(libraryScripts, "..");
227
+ if (run.startsWith("workspace/tools/")) {
228
+ return path.resolve(workspaceRoot, "tools", run.slice("workspace/tools/".length));
229
+ }
230
+ if (run.startsWith("account/tools/")) {
231
+ return path.resolve(libraryRoot, "tools", run.slice("account/tools/".length));
232
+ }
233
+ // Canonical prefixes, then the legacy spellings they replaced.
234
+ for (const p of ["workspace/scripts/", "shared/"]) {
235
+ if (run.startsWith(p)) {
236
+ return path.resolve(workspaceRoot, "scripts", run.slice(p.length));
237
+ }
238
+ }
239
+ for (const p of ["account/scripts/", "library/"]) {
240
+ if (run.startsWith(p))
241
+ return path.resolve(libraryScripts, run.slice(p.length));
242
+ }
243
+ return path.resolve(agentDir, run);
244
+ }
245
+ /**
246
+ * The schema a script's arguments are offered to the model under. Every
247
+ * argument becomes a command-line flag, so it is text on the way out — but a
248
+ * model reading "depth: how many results (default 20)" sends 10, the number,
249
+ * and the SDK refuses the call before the handler's String() ever runs; on
250
+ * the fast tier the model then announced a retry and ended its turn. A
251
+ * number or a boolean is accepted and written out as the text it would
252
+ * have been.
253
+ */
254
+ export function scriptArgShape(args) {
255
+ const shape = {};
256
+ for (const [arg, desc] of Object.entries(args)) {
257
+ shape[arg] = z.union([z.string(), z.number(), z.boolean()]).optional().describe(desc);
258
+ }
259
+ return shape;
260
+ }
261
+ export function buildScriptTools(agentDir, scripts, env, libraryScripts = "", interpreters = {}, exec = null) {
262
+ if (scripts.length === 0) {
263
+ return { server: null, toolNames: [], promptLines: [], drainLog: () => [] };
264
+ }
265
+ const log = [];
266
+ const tools = scripts.map((spec) => {
267
+ const shape = scriptArgShape(spec.args);
268
+ return tool(spec.name, `${spec.description || `Run ${spec.run}.`} Runs the workspace script ${spec.run}; returns its output. ` +
269
+ `Credentials it needs are already in its environment.`, shape, async (args) => {
270
+ const started = Date.now();
271
+ const values = Object.fromEntries(Object.entries(args ?? {}).map(([k, v]) => [k, v === undefined ? "" : String(v)]));
272
+ const { code, out } = await runScript(agentDir, spec, values, env, libraryScripts, interpreters, exec);
273
+ // The arguments, on the record. Without them a run says a script was
274
+ // called and not what it was asked to do — and for a script that
275
+ // writes to someone else's system, "it ran" is not an account of what
276
+ // happened. Model-supplied strings only: secrets reach a script
277
+ // through its environment, never through these.
278
+ const shown = Object.entries(values)
279
+ .filter(([, v]) => v !== "")
280
+ .map(([k, v]) => `${k}=${v.length > 80 ? `${v.slice(0, 77)}…` : v}`)
281
+ .join(", ");
282
+ log.push(`${spec.name}(${shown}) → exit ${code ?? "error"} ` +
283
+ `(${Date.now() - started}ms, ${exec?.executor ?? "sandbox"})`);
284
+ return {
285
+ content: [{ type: "text", text: `exit ${code ?? "error"}\n\n${out}` }],
286
+ isError: code !== 0,
287
+ };
288
+ });
289
+ });
290
+ return {
291
+ server: createSdkMcpServer({ name: "foldrun_scripts", version: "1.0.0", tools }),
292
+ toolNames: scripts.map((s) => `mcp__foldrun_scripts__${s.name}`),
293
+ promptLines: scripts.map((s) => `- **${s.name}** — ${s.description || `runs ${s.run}`}${Object.keys(s.args).length ? ` Arguments: ${Object.keys(s.args).join(", ")}.` : ""}`),
294
+ drainLog: () => log.splice(0, log.length),
295
+ };
296
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Turn any @file/@ssh/@api values in `env` into files under
3
+ * `<agentDir>/.secret-files`, rewriting each to its path. Returns the
4
+ * directory written (for cleanup) and the transformed env. Plain values
5
+ * pass through untouched.
6
+ */
7
+ export declare function materializeFileSecrets(agentDir: string, env: Record<string, string>): {
8
+ env: Record<string, string>;
9
+ dir: string | null;
10
+ };
11
+ /** Best-effort removal of a materialised secret-files directory. */
12
+ export declare function cleanupFileSecrets(dir: string | null): void;
@@ -0,0 +1,92 @@
1
+ // Materialising config-shaped secrets (@file, @ssh, @api) into a run.
2
+ //
3
+ // An SSH private key and a client certificate are credentials tools read
4
+ // from a path — ssh flatly refuses a key that isn't 0600, curl wants
5
+ // --cert a file. And an SSH or API *connection* is more than a value: host,
6
+ // port, user, headers. The user describes the connection; this layer turns
7
+ // it into something directly runnable — a 0600 file, or a 0700 wrapper
8
+ // script — and the env var carries the *path*. The agent runs
9
+ // `"$PROD_VM" 'uptime'` or `"$STRIPE" /v1/charges` and never learns whether
10
+ // it was key or password, one header or five.
11
+ //
12
+ // Everything lands in a per-run scratch directory inside the agent's own
13
+ // tree (so it crosses into a container with the workspace) but dot-prefixed
14
+ // and never archived or copied back — it holds live credentials.
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ import { isFileValue, fileContent, isSshValue, sshConfigOf, isApiValue, apiConfigOf, } from "./secrets.js";
18
+ // POSIX single-quoting: the one escape sh actually honours inside ' is none,
19
+ // so close, backslash-quote, reopen. Wrapper scripts embed hosts, users and
20
+ // header values through this and nothing else.
21
+ const q = (s) => `'${s.replaceAll("'", `'\\''`)}'`;
22
+ function writeWrapper(dir, name, body) {
23
+ const file = path.join(dir, name.toLowerCase());
24
+ fs.writeFileSync(file, `#!/bin/sh\n${body}\n`, { mode: 0o700 });
25
+ return file;
26
+ }
27
+ /**
28
+ * Turn any @file/@ssh/@api values in `env` into files under
29
+ * `<agentDir>/.secret-files`, rewriting each to its path. Returns the
30
+ * directory written (for cleanup) and the transformed env. Plain values
31
+ * pass through untouched.
32
+ */
33
+ export function materializeFileSecrets(agentDir, env) {
34
+ const entries = Object.entries(env).filter(([, v]) => typeof v === "string" && (isFileValue(v) || isSshValue(v) || isApiValue(v)));
35
+ if (entries.length === 0)
36
+ return { env, dir: null };
37
+ const dir = path.join(agentDir, ".secret-files");
38
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
39
+ const out = { ...env };
40
+ for (const [name, value] of entries) {
41
+ // Lowercased name as the filename keeps it predictable without leaking
42
+ // the value; the env var still points at it by absolute path.
43
+ if (isFileValue(value)) {
44
+ const file = path.join(dir, name.toLowerCase());
45
+ fs.writeFileSync(file, fileContent(value), { mode: 0o600 });
46
+ out[name] = file;
47
+ }
48
+ else if (isSshValue(value)) {
49
+ const ssh = sshConfigOf(value);
50
+ const dest = `${ssh.user}@${ssh.host}`;
51
+ const opts = `-p ${ssh.port ?? 22} -o StrictHostKeyChecking=accept-new`;
52
+ let cmd;
53
+ if (ssh.private_key) {
54
+ const key = path.join(dir, `${name.toLowerCase()}.key`);
55
+ // ssh wants a trailing newline on OpenSSH keys and 0600 or it balks.
56
+ fs.writeFileSync(key, ssh.private_key.replace(/\n?$/, "\n"), { mode: 0o600 });
57
+ cmd = `exec ssh -i ${q(key)} ${opts} ${q(dest)} "$@"`;
58
+ out[`${name}_KEY`] = key;
59
+ }
60
+ else {
61
+ const pw = path.join(dir, `${name.toLowerCase()}.pw`);
62
+ fs.writeFileSync(pw, ssh.password ?? "", { mode: 0o600 });
63
+ cmd = `exec sshpass -f ${q(pw)} ssh ${opts} ${q(dest)} "$@"`;
64
+ }
65
+ out[name] = writeWrapper(dir, name, cmd);
66
+ // The parts, for scp/rsync/anything the wrapper doesn't cover.
67
+ out[`${name}_HOST`] = ssh.host;
68
+ out[`${name}_PORT`] = String(ssh.port ?? 22);
69
+ out[`${name}_USER`] = ssh.user;
70
+ }
71
+ else {
72
+ const api = apiConfigOf(value);
73
+ const headers = Object.entries(api.headers)
74
+ .map(([h, v]) => `-H ${q(`${h}: ${v}`)}`)
75
+ .join(" ");
76
+ // With a base URL, a path-shaped first argument gets prefixed — so
77
+ // `"$STRIPE" /v1/charges` and `"$STRIPE" https://elsewhere` both work.
78
+ const prefix = api.base_url
79
+ ? `case "$1" in /*) _u=${q(api.base_url)}"$1"; shift; set -- "$_u" "$@";; esac\n`
80
+ : "";
81
+ out[name] = writeWrapper(dir, name, `${prefix}exec curl -sS ${headers} "$@"`);
82
+ if (api.base_url)
83
+ out[`${name}_URL`] = api.base_url;
84
+ }
85
+ }
86
+ return { env: out, dir };
87
+ }
88
+ /** Best-effort removal of a materialised secret-files directory. */
89
+ export function cleanupFileSecrets(dir) {
90
+ if (dir)
91
+ fs.rmSync(dir, { recursive: true, force: true });
92
+ }
@@ -0,0 +1,147 @@
1
+ /** Which store a secret came from. */
2
+ export type SecretScope = "account" | "workspace";
3
+ /** How the stored value is used at run time — "oauth2"/"service-account"
4
+ * are refreshed into live tokens, "file"/"ssh"/"api" are materialised into
5
+ * the run's scratch. Plain values have no kind. */
6
+ export type SecretKind = "oauth2" | "service-account" | "file" | "ssh" | "api";
7
+ export declare const SECRET_NAME: RegExp;
8
+ export declare function setSecret(tenant: string, name: string, value: string, workspace?: string, kind?: SecretKind): void;
9
+ /** Encrypt one value for an account — the same envelope secrets use, exported
10
+ * so sibling stores (OAuth clients) don't invent a second crypto path. */
11
+ export declare function encryptValue(tenant: string, value: string): {
12
+ iv: string;
13
+ tag: string;
14
+ data: string;
15
+ k?: "tenant";
16
+ };
17
+ export declare function decryptValue(tenant: string, rec: {
18
+ iv: string;
19
+ tag: string;
20
+ data: string;
21
+ k?: "tenant";
22
+ } | undefined): string | null;
23
+ /** Nearest wins: a workspace secret shadows an account one of the same name. */
24
+ export declare function getSecret(tenant: string, name: string, workspace?: string): {
25
+ value: string;
26
+ scope: SecretScope;
27
+ } | null;
28
+ export declare function deleteSecret(tenant: string, name: string, workspace?: string): void;
29
+ export interface SecretEntry {
30
+ name: string;
31
+ updatedAt: string;
32
+ scope: SecretScope;
33
+ /** An account secret this workspace overrides — worth showing, not hiding. */
34
+ shadowed?: boolean;
35
+ /** How this credential works: auto-refreshing oauth2, a signed service
36
+ * account, a file/ssh/api materialised into runs — absent for static values. */
37
+ kind?: SecretKind;
38
+ }
39
+ export declare function listSecrets(tenant: string, workspace?: string): SecretEntry[];
40
+ export declare function resolveSecrets(tenant: string, names: string[], workspace?: string): {
41
+ env: Record<string, string>;
42
+ from: Record<string, SecretScope>;
43
+ missing: string[];
44
+ };
45
+ /**
46
+ * Where the master key came from.
47
+ *
48
+ * The key and the ciphertext it protects live on the same disk by default, so
49
+ * one filesystem read gets both. `FOLDRUN_SECRET_KEY` fixes that — a managed
50
+ * secret store holds the key, the disk holds only the encrypted values — and
51
+ * this reports which of the two is in force so the platform can say so rather
52
+ * than leaving it to be discovered.
53
+ */
54
+ export declare function masterKeySource(): {
55
+ source: "env" | "file";
56
+ path: string | null;
57
+ };
58
+ export interface RotationResult {
59
+ /** Files rewritten, and how many values each held. */
60
+ rewritten: {
61
+ file: string;
62
+ secrets: number;
63
+ }[];
64
+ /** Names that could not be read with `from` — left untouched, never dropped. */
65
+ unreadable: string[];
66
+ }
67
+ /**
68
+ * Re-encrypt every secret in an account from one master key to another.
69
+ *
70
+ * Without this, moving the key into a secret manager is unsafe: change the key
71
+ * and every stored value fails to decrypt, which the platform reports as a
72
+ * *missing* secret. You would not lose the ciphertext, but you would lose the
73
+ * ability to read it, and the error would tell you the secret was never set.
74
+ *
75
+ * Both keys are passed in raw, exactly as FOLDRUN_SECRET_KEY would be, and
76
+ * hashed here the same way masterKey does — so the caller never has to know
77
+ * how a key becomes an AES key.
78
+ *
79
+ * A value that will not decrypt with `from` is reported and left exactly as it
80
+ * is. Half-rotating is bad; silently discarding is worse.
81
+ */
82
+ export declare function rotateMasterKey(tenant: string, from: string, to: string): RotationResult;
83
+ export interface OAuth2Config {
84
+ token_url: string;
85
+ client_id: string;
86
+ client_secret: string;
87
+ /** Required for the refresh grant; absent for client_credentials. */
88
+ refresh_token?: string;
89
+ /** "refresh_token" (default — user-consented) or "client_credentials"
90
+ * (machine-to-machine: no user, no refresh token, re-exchanged on
91
+ * expiry). One machinery, two grants. */
92
+ grant_type?: "refresh_token" | "client_credentials";
93
+ /** Extra form fields some providers want (audience, scope, …). */
94
+ extra?: Record<string, string>;
95
+ }
96
+ export declare function setOAuth2Secret(tenant: string, name: string, config: OAuth2Config, workspace?: string): void;
97
+ export declare function isOAuth2Value(value: string): boolean;
98
+ /**
99
+ * Swap every oauth2 recipe in an env map for a live access token. The one
100
+ * async moment in secret resolution, kept out of the sync paths: callers
101
+ * materialise right before values are injected or substituted. A failed
102
+ * refresh throws with the secret's name and the provider's reason — a run
103
+ * should fail saying "GOOGLE_ADS_TOKEN: invalid_grant", not 401 somewhere.
104
+ */
105
+ export declare function materializeSecrets(env: Record<string, string>): Promise<Record<string, string>>;
106
+ export interface ServiceAccountConfig {
107
+ token_url: string;
108
+ issuer: string;
109
+ private_key: string;
110
+ scope: string;
111
+ /** JWT audience — usually the same as token_url. */
112
+ audience?: string;
113
+ }
114
+ export declare function setServiceAccountSecret(tenant: string, name: string, config: ServiceAccountConfig, workspace?: string): void;
115
+ export declare function setFileSecret(tenant: string, name: string, content: string, workspace?: string): void;
116
+ export declare function isFileValue(value: string): boolean;
117
+ export declare function fileContent(value: string): string;
118
+ export interface SshConfig {
119
+ host: string;
120
+ port?: number;
121
+ user: string;
122
+ /** Exactly one of these two. */
123
+ private_key?: string;
124
+ password?: string;
125
+ }
126
+ export declare function setSshSecret(tenant: string, name: string, config: SshConfig, workspace?: string): void;
127
+ export declare function isSshValue(value: string): boolean;
128
+ export declare function sshConfigOf(value: string): SshConfig;
129
+ export interface ApiConfig {
130
+ base_url?: string;
131
+ headers: Record<string, string>;
132
+ }
133
+ export declare function setApiSecret(tenant: string, name: string, config: ApiConfig, workspace?: string): void;
134
+ export declare function isApiValue(value: string): boolean;
135
+ export declare function apiConfigOf(value: string): ApiConfig;
136
+ /**
137
+ * Move an account's secrets from the install key onto its own key.
138
+ *
139
+ * Read with whichever key each record names, write them all back under the
140
+ * account's. Every value is decrypted BEFORE anything is written: a vault
141
+ * half-moved is an account that can authenticate nothing, so the whole file
142
+ * either converts or is left exactly as it was.
143
+ */
144
+ export declare function rewrapTenantSecrets(tenant: string): {
145
+ moved: number;
146
+ unreadable: string[];
147
+ };