@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/secrets.ts ADDED
@@ -0,0 +1,799 @@
1
+ // Secret store. Values are encrypted at rest with AES-256-GCM using a key
2
+ // derived from FOLDRUN_SECRET_KEY (falling back to a generated per-install
3
+ // key file, so dev works without configuration).
4
+ //
5
+ // Two scopes, nearest wins — the same rule knowledge uses:
6
+ //
7
+ // data/<account>/secrets.json account: credentials
8
+ // genuinely shared by every
9
+ // workspace, rotated once
10
+ // data/<account>/workspaces/<workspace>/secrets.json workspace: the default.
11
+ // ads-desk's ad token is not
12
+ // newsroom's to read
13
+ //
14
+ // There is deliberately no agent scope. A credential buried in one agent's
15
+ // folder can't be rotated or audited, and per-agent *grant* already exists:
16
+ // an agent only receives what it names in `secrets:`. Definition is shared,
17
+ // grant is per-agent — the same split tools use.
18
+ //
19
+ // Secrets are injected as environment variables into the agent's tools at run
20
+ // time and are never returned by the API.
21
+
22
+ import fs from "node:fs";
23
+ import path from "node:path";
24
+ import { dataRoot } from "./paths.ts";
25
+ import { platform } from "./platform.ts";
26
+
27
+ // The account's own data key, when the platform holds one; the install key
28
+ // otherwise. See platform.ts — locally there is no account key.
29
+ const tenantKey = (tenant: string) => platform.tenantKey(tenant);
30
+ import crypto from "node:crypto";
31
+ import { assertSafeName } from "./store.ts";
32
+
33
+ const keyFile = () => path.join(dataRoot(), ".secret-key");
34
+
35
+ /** Which store a secret came from. */
36
+ export type SecretScope = "account" | "workspace";
37
+
38
+ function masterKey(): Buffer {
39
+ const fromEnv = process.env.FOLDRUN_SECRET_KEY;
40
+ if (fromEnv) return crypto.createHash("sha256").update(fromEnv).digest();
41
+ fs.mkdirSync(dataRoot(), { recursive: true });
42
+ if (!fs.existsSync(keyFile())) {
43
+ fs.writeFileSync(keyFile(), crypto.randomBytes(32).toString("hex"), { mode: 0o600 });
44
+ }
45
+ return crypto.createHash("sha256").update(fs.readFileSync(keyFile(), "utf8")).digest();
46
+ }
47
+
48
+ // `workspace` undefined means the account store.
49
+ function secretsFile(tenant: string, workspace?: string) {
50
+ assertSafeName(tenant, "tenant");
51
+ if (!workspace) return path.join(dataRoot(), tenant, "secrets.json");
52
+ assertSafeName(workspace, "workspace");
53
+ // Follow the workspace wherever it lives — see WORKSPACES in store.ts.
54
+ const legacy = path.join(dataRoot(), tenant, "projects", workspace);
55
+ const base = fs.existsSync(legacy) ? legacy : path.join(dataRoot(), tenant, "workspaces", workspace);
56
+ return path.join(base, "secrets.json");
57
+ }
58
+
59
+ /** How the stored value is used at run time — "oauth2"/"service-account"
60
+ * are refreshed into live tokens, "file"/"ssh"/"api" are materialised into
61
+ * the run's scratch. Plain values have no kind. */
62
+ export type SecretKind = "oauth2" | "service-account" | "file" | "ssh" | "api";
63
+
64
+ interface StoredSecret {
65
+ iv: string;
66
+ tag: string;
67
+ data: string;
68
+ updatedAt: string;
69
+ kind?: SecretKind;
70
+ /** Which key opens this. Absent means the install key, which is what every
71
+ * record written before per-account keys existed is encrypted with — so an
72
+ * old vault stays readable without a migration having to run first. */
73
+ k?: "tenant";
74
+ }
75
+
76
+ type SecretsFile = Record<string, StoredSecret>;
77
+
78
+ function read(tenant: string, workspace?: string): SecretsFile {
79
+ const f = secretsFile(tenant, workspace);
80
+ if (!fs.existsSync(f)) return {};
81
+ try {
82
+ return JSON.parse(fs.readFileSync(f, "utf8"));
83
+ } catch {
84
+ return {};
85
+ }
86
+ }
87
+
88
+ function write(tenant: string, workspace: string | undefined, data: SecretsFile) {
89
+ const f = secretsFile(tenant, workspace);
90
+ fs.mkdirSync(path.dirname(f), { recursive: true });
91
+ fs.writeFileSync(f, JSON.stringify(data, null, 2), { mode: 0o600 });
92
+ }
93
+
94
+ export const SECRET_NAME = /^[A-Z][A-Z0-9_]{1,63}$/;
95
+
96
+ export function setSecret(
97
+ tenant: string,
98
+ name: string,
99
+ value: string,
100
+ workspace?: string,
101
+ kind?: SecretKind,
102
+ ) {
103
+ if (!SECRET_NAME.test(name)) {
104
+ throw new Error(`secret name "${name}" must be UPPER_SNAKE_CASE`);
105
+ }
106
+ if (value.length > 8192) throw new Error("secret too long");
107
+ const sealed = encryptValue(tenant, value);
108
+ const all = read(tenant, workspace);
109
+ all[name] = {
110
+ ...sealed,
111
+ updatedAt: new Date().toISOString(),
112
+ ...(kind ? { kind } : {}),
113
+ };
114
+ write(tenant, workspace, all);
115
+ }
116
+
117
+ /**
118
+ * Which key writes this account's records.
119
+ *
120
+ * The account's own where there is one, the install key otherwise — a laptop,
121
+ * a self-hoster with no database, or an account whose key has not been minted.
122
+ * Returned with the marker to store beside it, so the reader never has to
123
+ * guess which key a record was written with.
124
+ */
125
+ function keyFor(tenant: string): { key: Buffer; mark: "tenant" | undefined } {
126
+ const own = tenantKey(tenant);
127
+ return own ? { key: own, mark: "tenant" } : { key: masterKey(), mark: undefined };
128
+ }
129
+
130
+ /** Encrypt one value for an account — the same envelope secrets use, exported
131
+ * so sibling stores (OAuth clients) don't invent a second crypto path. */
132
+ export function encryptValue(
133
+ tenant: string,
134
+ value: string,
135
+ ): { iv: string; tag: string; data: string; k?: "tenant" } {
136
+ const { key, mark } = keyFor(tenant);
137
+ const iv = crypto.randomBytes(12);
138
+ const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
139
+ const enc = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
140
+ return {
141
+ iv: iv.toString("base64"),
142
+ tag: cipher.getAuthTag().toString("base64"),
143
+ data: enc.toString("base64"),
144
+ ...(mark ? { k: mark } : {}),
145
+ };
146
+ }
147
+
148
+ export function decryptValue(
149
+ tenant: string,
150
+ rec: { iv: string; tag: string; data: string; k?: "tenant" } | undefined,
151
+ ): string | null {
152
+ return decrypt(tenant, rec as StoredSecret | undefined);
153
+ }
154
+
155
+ function decrypt(tenant: string, rec: StoredSecret | undefined): string | null {
156
+ if (!rec) return null;
157
+ try {
158
+ // The record says which key wrote it. An unmarked one predates per-account
159
+ // keys and is under the install key; guessing either way would turn a
160
+ // readable vault into an unreadable one on the first upgrade.
161
+ let key: Buffer;
162
+ if (rec.k === "tenant") {
163
+ const own = tenantKey(tenant);
164
+ if (!own) {
165
+ // Say so. Without this the read returns null and the caller reports
166
+ // "no such secret" — a process that forgot to load the account keys
167
+ // would look exactly like an account that never set one, which is the
168
+ // most expensive kind of wrong answer to debug.
169
+ console.error(
170
+ `[secrets] ${tenant} has account-keyed secrets but no key is loaded — ` +
171
+ `call loadTenantKeys() before reading, or the value will read as absent`,
172
+ );
173
+ return null;
174
+ }
175
+ key = own;
176
+ } else {
177
+ key = masterKey();
178
+ }
179
+ const decipher = crypto.createDecipheriv(
180
+ "aes-256-gcm",
181
+ key,
182
+ Buffer.from(rec.iv, "base64"),
183
+ );
184
+ decipher.setAuthTag(Buffer.from(rec.tag, "base64"));
185
+ return Buffer.concat([
186
+ decipher.update(Buffer.from(rec.data, "base64")),
187
+ decipher.final(),
188
+ ]).toString("utf8");
189
+ } catch {
190
+ return null; // key rotated or file tampered with
191
+ }
192
+ }
193
+
194
+ /** Nearest wins: a workspace secret shadows an account one of the same name. */
195
+ export function getSecret(
196
+ tenant: string,
197
+ name: string,
198
+ workspace?: string,
199
+ ): { value: string; scope: SecretScope } | null {
200
+ if (workspace) {
201
+ const own = decrypt(tenant, read(tenant, workspace)[name]);
202
+ if (own !== null) return { value: own, scope: "workspace" };
203
+ // A preview is a branch of its source workspace, and the branch's
204
+ // agents need the same credentials the source's do. Its own store is
205
+ // still consulted first, so a preview can override one on purpose.
206
+ const source = platform.previewSourceOf(tenant, workspace);
207
+ if (source) {
208
+ const inherited = decrypt(tenant, read(tenant, source)[name]);
209
+ if (inherited !== null) return { value: inherited, scope: "workspace" };
210
+ }
211
+ }
212
+ const shared = decrypt(tenant, read(tenant)[name]);
213
+ return shared === null ? null : { value: shared, scope: "account" };
214
+ }
215
+
216
+ export function deleteSecret(tenant: string, name: string, workspace?: string) {
217
+ const all = read(tenant, workspace);
218
+ delete all[name];
219
+ write(tenant, workspace, all);
220
+ }
221
+
222
+ export interface SecretEntry {
223
+ name: string;
224
+ updatedAt: string;
225
+ scope: SecretScope;
226
+ /** An account secret this workspace overrides — worth showing, not hiding. */
227
+ shadowed?: boolean;
228
+ /** How this credential works: auto-refreshing oauth2, a signed service
229
+ * account, a file/ssh/api materialised into runs — absent for static values. */
230
+ kind?: SecretKind;
231
+ }
232
+
233
+ // Metadata only — never values. Without a workspace this lists the account
234
+ // store; with one it lists what that workspace actually resolves, marking
235
+ // which scope each name comes from.
236
+ export function listSecrets(tenant: string, workspace?: string): SecretEntry[] {
237
+ const account = Object.entries(read(tenant)).map(([name, rec]) => ({
238
+ name,
239
+ updatedAt: rec.updatedAt,
240
+ scope: "account" as const,
241
+ ...(rec.kind ? { kind: rec.kind } : {}),
242
+ }));
243
+ if (!workspace) return account.sort((a, b) => a.name.localeCompare(b.name));
244
+
245
+ const own = Object.entries(read(tenant, workspace)).map(([name, rec]) => ({
246
+ name,
247
+ updatedAt: rec.updatedAt,
248
+ scope: "workspace" as const,
249
+ shadowed: account.some((a) => a.name === name),
250
+ ...(rec.kind ? { kind: rec.kind } : {}),
251
+ }));
252
+ const ownNames = new Set(own.map((s) => s.name));
253
+ return [...own, ...account.filter((a) => !ownNames.has(a.name))].sort((a, b) =>
254
+ a.name.localeCompare(b.name),
255
+ );
256
+ }
257
+
258
+ // Resolve the secrets an agent declared into an env map for its tools.
259
+ // `from` records where each one came from, so a workspace silently falling
260
+ // back to an account credential is visible in the run trace instead of assumed.
261
+ export function resolveSecrets(tenant: string, names: string[], workspace?: string) {
262
+ const env: Record<string, string> = {};
263
+ const from: Record<string, SecretScope> = {};
264
+ const missing: string[] = [];
265
+ for (const name of names) {
266
+ const hit = getSecret(tenant, name, workspace);
267
+ if (hit === null) missing.push(name);
268
+ else {
269
+ env[name] = hit.value;
270
+ from[name] = hit.scope;
271
+ }
272
+ }
273
+ return { env, from, missing };
274
+ }
275
+
276
+ // ---------------------------------------------------------------- rotation
277
+
278
+ /**
279
+ * Where the master key came from.
280
+ *
281
+ * The key and the ciphertext it protects live on the same disk by default, so
282
+ * one filesystem read gets both. `FOLDRUN_SECRET_KEY` fixes that — a managed
283
+ * secret store holds the key, the disk holds only the encrypted values — and
284
+ * this reports which of the two is in force so the platform can say so rather
285
+ * than leaving it to be discovered.
286
+ */
287
+ export function masterKeySource(): { source: "env" | "file"; path: string | null } {
288
+ return process.env.FOLDRUN_SECRET_KEY
289
+ ? { source: "env", path: null }
290
+ : { source: "file", path: keyFile() };
291
+ }
292
+
293
+ /** Every secrets.json under an account: the account's, then each workspace's. */
294
+ function secretFiles(tenant: string): { file: string; workspace?: string }[] {
295
+ const out: { file: string; workspace?: string }[] = [{ file: secretsFile(tenant) }];
296
+ for (const dirName of ["workspaces", "projects"]) {
297
+ const dir = path.join(dataRoot(), tenant, dirName);
298
+ if (!fs.existsSync(dir)) continue;
299
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
300
+ if (!entry.isDirectory()) continue;
301
+ const f = path.join(dir, entry.name, "secrets.json");
302
+ if (fs.existsSync(f)) out.push({ file: f, workspace: entry.name });
303
+ }
304
+ }
305
+ return out;
306
+ }
307
+
308
+ export interface RotationResult {
309
+ /** Files rewritten, and how many values each held. */
310
+ rewritten: { file: string; secrets: number }[];
311
+ /** Names that could not be read with `from` — left untouched, never dropped. */
312
+ unreadable: string[];
313
+ }
314
+
315
+ /**
316
+ * Re-encrypt every secret in an account from one master key to another.
317
+ *
318
+ * Without this, moving the key into a secret manager is unsafe: change the key
319
+ * and every stored value fails to decrypt, which the platform reports as a
320
+ * *missing* secret. You would not lose the ciphertext, but you would lose the
321
+ * ability to read it, and the error would tell you the secret was never set.
322
+ *
323
+ * Both keys are passed in raw, exactly as FOLDRUN_SECRET_KEY would be, and
324
+ * hashed here the same way masterKey does — so the caller never has to know
325
+ * how a key becomes an AES key.
326
+ *
327
+ * A value that will not decrypt with `from` is reported and left exactly as it
328
+ * is. Half-rotating is bad; silently discarding is worse.
329
+ */
330
+ export function rotateMasterKey(
331
+ tenant: string,
332
+ from: string,
333
+ to: string,
334
+ ): RotationResult {
335
+ assertSafeName(tenant, "tenant");
336
+ const oldKey = crypto.createHash("sha256").update(from).digest();
337
+ const newKey = crypto.createHash("sha256").update(to).digest();
338
+
339
+ const rewritten: RotationResult["rewritten"] = [];
340
+ const unreadable: string[] = [];
341
+
342
+ for (const { file } of secretFiles(tenant)) {
343
+ const all: SecretsFile = JSON.parse(fs.readFileSync(file, "utf8"));
344
+ const next: SecretsFile = {};
345
+ let count = 0;
346
+
347
+ for (const [name, rec] of Object.entries(all)) {
348
+ // A record under the ACCOUNT's key is not under the install key, so
349
+ // rotating the install key must not touch it — re-encrypting it here
350
+ // with a key it was never sealed with is how a rotation would destroy a
351
+ // vault it was meant to protect. Rotating the install key now re-wraps
352
+ // the account keys instead, which is a handful of small values rather
353
+ // than every secret on the box.
354
+ if (rec.k === "tenant") {
355
+ next[name] = rec;
356
+ continue;
357
+ }
358
+ let plain: string;
359
+ try {
360
+ const d = crypto.createDecipheriv("aes-256-gcm", oldKey, Buffer.from(rec.iv, "base64"));
361
+ d.setAuthTag(Buffer.from(rec.tag, "base64"));
362
+ plain = Buffer.concat([d.update(Buffer.from(rec.data, "base64")), d.final()]).toString("utf8");
363
+ } catch {
364
+ unreadable.push(name);
365
+ next[name] = rec; // untouched
366
+ continue;
367
+ }
368
+ const iv = crypto.randomBytes(12);
369
+ const c = crypto.createCipheriv("aes-256-gcm", newKey, iv);
370
+ const enc = Buffer.concat([c.update(plain, "utf8"), c.final()]);
371
+ next[name] = {
372
+ iv: iv.toString("base64"),
373
+ tag: c.getAuthTag().toString("base64"),
374
+ data: enc.toString("base64"),
375
+ updatedAt: rec.updatedAt, // rotation is not an edit
376
+ };
377
+ count++;
378
+ }
379
+
380
+ // Write via a temp file in the same directory: a half-written secrets.json
381
+ // is an account that cannot authenticate anything.
382
+ const tmp = `${file}.rotating`;
383
+ fs.writeFileSync(tmp, JSON.stringify(next, null, 2), { mode: 0o600 });
384
+ fs.renameSync(tmp, file);
385
+ rewritten.push({ file, secrets: count });
386
+ }
387
+
388
+ return { rewritten, unreadable };
389
+ }
390
+
391
+ // ---------------------------------------------------------------- oauth2
392
+
393
+ // Auto-refreshing credentials. A static value covers most APIs; the Google
394
+ // family (Ads, Search Console, Drive, …) hands out a long-lived refresh
395
+ // token that must be exchanged for a short-lived access token before every
396
+ // use. An oauth2 secret stores the exchange recipe, encrypted like any
397
+ // value; what an agent ever sees is the fresh access token, materialised at
398
+ // the last host-side moment before injection. The refresh credentials
399
+ // themselves never cross into a run.
400
+
401
+ export interface OAuth2Config {
402
+ token_url: string;
403
+ client_id: string;
404
+ client_secret: string;
405
+ /** Required for the refresh grant; absent for client_credentials. */
406
+ refresh_token?: string;
407
+ /** "refresh_token" (default — user-consented) or "client_credentials"
408
+ * (machine-to-machine: no user, no refresh token, re-exchanged on
409
+ * expiry). One machinery, two grants. */
410
+ grant_type?: "refresh_token" | "client_credentials";
411
+ /** Extra form fields some providers want (audience, scope, …). */
412
+ extra?: Record<string, string>;
413
+ }
414
+
415
+ const OAUTH2_PREFIX = "@oauth2 ";
416
+
417
+ export function setOAuth2Secret(
418
+ tenant: string,
419
+ name: string,
420
+ config: OAuth2Config,
421
+ workspace?: string,
422
+ ) {
423
+ for (const field of ["token_url", "client_id", "client_secret"] as const) {
424
+ if (!config[field]?.trim()) throw new Error(`oauth2 secret needs ${field}`);
425
+ }
426
+ if (config.grant_type !== "client_credentials" && !config.refresh_token?.trim()) {
427
+ throw new Error("oauth2 secret needs refresh_token (or grant_type: client_credentials)");
428
+ }
429
+ // Loopback is exempt: a token endpoint on this same machine (tests, a
430
+ // local mock, a sidecar) crosses no wire for http to leak on.
431
+ if (!/^https:\/\//.test(config.token_url) && !/^http:\/\/(127\.0\.0\.1|localhost)[:/]/.test(config.token_url)) {
432
+ throw new Error("token_url must be https — a refresh token over http is a leaked one");
433
+ }
434
+ setSecret(tenant, name, OAUTH2_PREFIX + JSON.stringify(config), workspace, "oauth2");
435
+ }
436
+
437
+ export function isOAuth2Value(value: string): boolean {
438
+ return value.startsWith(OAUTH2_PREFIX);
439
+ }
440
+
441
+ // Fresh tokens are cached until shortly before expiry, and concurrent
442
+ // requests for the same secret share one exchange — a fan-out of twenty
443
+ // steps must not hit Google twenty times in one second.
444
+ const tokenCache = new Map<string, { token: string; expiresAt: number }>();
445
+ const inFlight = new Map<string, Promise<string>>();
446
+
447
+ async function exchange(config: OAuth2Config, cacheKey: string): Promise<string> {
448
+ const cached = tokenCache.get(cacheKey);
449
+ if (cached && cached.expiresAt > Date.now()) return cached.token;
450
+
451
+ const pending = inFlight.get(cacheKey);
452
+ if (pending) return pending;
453
+
454
+ const flight = (async () => {
455
+ const body = new URLSearchParams({
456
+ grant_type: config.grant_type ?? "refresh_token",
457
+ ...(config.grant_type === "client_credentials" ? {} : { refresh_token: config.refresh_token! }),
458
+ client_id: config.client_id,
459
+ client_secret: config.client_secret,
460
+ ...(config.extra ?? {}),
461
+ });
462
+ const res = await fetch(config.token_url, {
463
+ method: "POST",
464
+ headers: { "content-type": "application/x-www-form-urlencoded" },
465
+ body: body.toString(),
466
+ signal: AbortSignal.timeout(15_000),
467
+ });
468
+ const payload = (await res.json().catch(() => ({}))) as {
469
+ access_token?: string;
470
+ expires_in?: number;
471
+ error?: string;
472
+ error_description?: string;
473
+ };
474
+ if (!res.ok || !payload.access_token) {
475
+ throw new Error(
476
+ `token refresh failed (${res.status}): ${payload.error_description ?? payload.error ?? "no access_token in the reply"}`,
477
+ );
478
+ }
479
+ const ttl = Math.max(60, (payload.expires_in ?? 3600) - 60) * 1000;
480
+ tokenCache.set(cacheKey, { token: payload.access_token, expiresAt: Date.now() + ttl });
481
+ return payload.access_token;
482
+ })();
483
+
484
+ inFlight.set(cacheKey, flight);
485
+ try {
486
+ return await flight;
487
+ } finally {
488
+ inFlight.delete(cacheKey);
489
+ }
490
+ }
491
+
492
+ /**
493
+ * Swap every oauth2 recipe in an env map for a live access token. The one
494
+ * async moment in secret resolution, kept out of the sync paths: callers
495
+ * materialise right before values are injected or substituted. A failed
496
+ * refresh throws with the secret's name and the provider's reason — a run
497
+ * should fail saying "GOOGLE_ADS_TOKEN: invalid_grant", not 401 somewhere.
498
+ */
499
+ export async function materializeSecrets(
500
+ env: Record<string, string>,
501
+ ): Promise<Record<string, string>> {
502
+ const out: Record<string, string> = { ...env };
503
+ for (const [name, value] of Object.entries(env)) {
504
+ if (typeof value !== "string") continue;
505
+ if (isOAuth2Value(value)) {
506
+ let config: OAuth2Config;
507
+ try {
508
+ config = JSON.parse(value.slice(OAUTH2_PREFIX.length));
509
+ } catch {
510
+ throw new Error(`secret ${name}: stored oauth2 config is unreadable`);
511
+ }
512
+ try {
513
+ // Fingerprint the WHOLE refresh token, not its first 8 chars: Google
514
+ // refresh tokens share a long common prefix, so an 8-char slice could
515
+ // collide two distinct grants onto one cache entry and hand one the
516
+ // other's access token. Hashed so the token is not held in a Map key.
517
+ const grantMaterial = config.refresh_token ?? config.grant_type ?? "";
518
+ const fp = crypto.createHash("sha256").update(grantMaterial).digest("hex").slice(0, 16);
519
+ out[name] = await exchange(config, `${name}:${config.client_id}:${fp}`);
520
+ } catch (err) {
521
+ throw new Error(`secret ${name}: ${err instanceof Error ? err.message : String(err)}`);
522
+ }
523
+ } else if (value.startsWith(SERVICE_ACCOUNT_PREFIX)) {
524
+ let config: ServiceAccountConfig;
525
+ try {
526
+ config = JSON.parse(value.slice(SERVICE_ACCOUNT_PREFIX.length));
527
+ } catch {
528
+ throw new Error(`secret ${name}: stored service account config is unreadable`);
529
+ }
530
+ try {
531
+ out[name] = await exchangeServiceAccount(config, `sa:${name}:${config.issuer}`);
532
+ } catch (err) {
533
+ throw new Error(`secret ${name}: ${err instanceof Error ? err.message : String(err)}`);
534
+ }
535
+ }
536
+ // @file/@ssh/@api values are left as-is here; the run layer materialises
537
+ // them (paths, wrapper scripts), because only it knows the sandbox's
538
+ // writable scratch.
539
+ }
540
+ return out;
541
+ }
542
+
543
+ // ---------------------------------------------------------------- service account
544
+
545
+ // Signed-key credentials: a Google service account, or anything that mints a
546
+ // token by self-signing a JWT with a private key and exchanging it. No user,
547
+ // no browser, no refresh token — the key IS the identity. Better than the
548
+ // consent flow for server-side access: no consent to expire, IAM per service.
549
+ //
550
+ // Stored as the JSON key, encrypted. Materialised the same way oauth2 is: a
551
+ // live access token appears at the last host-side moment, cached until expiry.
552
+
553
+ const SERVICE_ACCOUNT_PREFIX = "@service-account ";
554
+
555
+ export interface ServiceAccountConfig {
556
+ token_url: string; // where the signed JWT is exchanged
557
+ issuer: string; // JWT iss/sub — the service account's email
558
+ private_key: string; // PEM
559
+ scope: string; // space-separated
560
+ /** JWT audience — usually the same as token_url. */
561
+ audience?: string;
562
+ }
563
+
564
+ export function setServiceAccountSecret(
565
+ tenant: string,
566
+ name: string,
567
+ config: ServiceAccountConfig,
568
+ workspace?: string,
569
+ ) {
570
+ for (const field of ["token_url", "issuer", "private_key", "scope"] as const) {
571
+ if (!config[field]?.trim()) throw new Error(`service account secret needs ${field}`);
572
+ }
573
+ if (!/BEGIN [A-Z ]*PRIVATE KEY/.test(config.private_key)) {
574
+ throw new Error("private_key must be a PEM key (from the service account JSON)");
575
+ }
576
+ setSecret(tenant, name, SERVICE_ACCOUNT_PREFIX + JSON.stringify(config), workspace, "service-account");
577
+ }
578
+
579
+ function base64url(input: Buffer | string): string {
580
+ return Buffer.from(input).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
581
+ }
582
+
583
+ async function exchangeServiceAccount(config: ServiceAccountConfig, cacheKey: string): Promise<string> {
584
+ const cached = tokenCache.get(cacheKey);
585
+ if (cached && cached.expiresAt > Date.now()) return cached.token;
586
+ const pending = inFlight.get(cacheKey);
587
+ if (pending) return pending;
588
+
589
+ const flight = (async () => {
590
+ const now = Math.floor(Date.now() / 1000);
591
+ const header = base64url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
592
+ const claim = base64url(
593
+ JSON.stringify({
594
+ iss: config.issuer,
595
+ sub: config.issuer,
596
+ scope: config.scope,
597
+ aud: config.audience ?? config.token_url,
598
+ iat: now,
599
+ exp: now + 3600,
600
+ }),
601
+ );
602
+ const signer = crypto.createSign("RSA-SHA256");
603
+ signer.update(`${header}.${claim}`);
604
+ const signature = base64url(signer.sign(config.private_key));
605
+ const jwt = `${header}.${claim}.${signature}`;
606
+
607
+ const res = await fetch(config.token_url, {
608
+ method: "POST",
609
+ headers: { "content-type": "application/x-www-form-urlencoded" },
610
+ body: new URLSearchParams({
611
+ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
612
+ assertion: jwt,
613
+ }).toString(),
614
+ signal: AbortSignal.timeout(15_000),
615
+ });
616
+ const payload = (await res.json().catch(() => ({}))) as {
617
+ access_token?: string;
618
+ expires_in?: number;
619
+ error?: string;
620
+ error_description?: string;
621
+ };
622
+ if (!res.ok || !payload.access_token) {
623
+ throw new Error(
624
+ `service account exchange failed (${res.status}): ${payload.error_description ?? payload.error ?? "no access_token"}`,
625
+ );
626
+ }
627
+ const ttl = Math.max(60, (payload.expires_in ?? 3600) - 60) * 1000;
628
+ tokenCache.set(cacheKey, { token: payload.access_token, expiresAt: Date.now() + ttl });
629
+ return payload.access_token;
630
+ })();
631
+
632
+ inFlight.set(cacheKey, flight);
633
+ try {
634
+ return await flight;
635
+ } finally {
636
+ inFlight.delete(cacheKey);
637
+ }
638
+ }
639
+
640
+ // ---------------------------------------------------------------- file secrets
641
+
642
+ // Some credentials are files, not strings: an SSH private key ssh insists on
643
+ // reading at 0600, a client certificate + key for mTLS. The value is stored
644
+ // like any secret; what differs is delivery — the run materialises it to a
645
+ // path and the agent's env var holds the *path*, not the bytes. The
646
+ // materialising happens in the sandbox layer (run-container/host), which is
647
+ // the only place that knows where a run's writable scratch is; here we only
648
+ // mark the kind and carry the content.
649
+
650
+ const FILE_PREFIX = "@file ";
651
+
652
+ export function setFileSecret(tenant: string, name: string, content: string, workspace?: string) {
653
+ if (!content) throw new Error("a file secret needs content");
654
+ setSecret(tenant, name, FILE_PREFIX + content, workspace, "file");
655
+ }
656
+
657
+ export function isFileValue(value: string): boolean {
658
+ return value.startsWith(FILE_PREFIX);
659
+ }
660
+
661
+ export function fileContent(value: string): string {
662
+ return value.slice(FILE_PREFIX.length);
663
+ }
664
+
665
+ // ---------------------------------------------------------------- ssh
666
+
667
+ // A whole SSH destination, stored as one connection: host, port, user, and
668
+ // either a private key or a password. The user describes the connection the
669
+ // way the service handed it to them; how it is plumbed (key files, sshpass,
670
+ // ssh flags) is the platform's business — the run layer materialises the
671
+ // config into an executable wrapper, so the agent runs `"$NAME" 'uptime'`
672
+ // and never learns which auth flavour it is.
673
+
674
+ const SSH_PREFIX = "@ssh ";
675
+
676
+ export interface SshConfig {
677
+ host: string;
678
+ port?: number;
679
+ user: string;
680
+ /** Exactly one of these two. */
681
+ private_key?: string;
682
+ password?: string;
683
+ }
684
+
685
+ export function setSshSecret(tenant: string, name: string, config: SshConfig, workspace?: string) {
686
+ const host = config.host?.trim();
687
+ const user = config.user?.trim();
688
+ if (!host || /[\s'"`$\\]/.test(host)) throw new Error("ssh secret needs a host (no spaces or quotes)");
689
+ if (!user || /[\s'"`$\\]/.test(user)) throw new Error("ssh secret needs a username (no spaces or quotes)");
690
+ const port = config.port ?? 22;
691
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("ssh port must be 1-65535");
692
+ const hasKey = Boolean(config.private_key?.trim());
693
+ const hasPassword = Boolean(config.password);
694
+ if (hasKey === hasPassword) throw new Error("ssh secret needs a private key or a password (not both)");
695
+ if (hasKey && !/BEGIN [A-Z ]*PRIVATE KEY/.test(config.private_key!)) {
696
+ throw new Error("private_key must be a PEM/OpenSSH private key");
697
+ }
698
+ if (hasPassword && /[\n\r]/.test(config.password!)) throw new Error("ssh password cannot contain newlines");
699
+ const stored: SshConfig = {
700
+ host, user, port,
701
+ ...(hasKey ? { private_key: config.private_key } : { password: config.password }),
702
+ };
703
+ setSecret(tenant, name, SSH_PREFIX + JSON.stringify(stored), workspace, "ssh");
704
+ }
705
+
706
+ export function isSshValue(value: string): boolean {
707
+ return value.startsWith(SSH_PREFIX);
708
+ }
709
+
710
+ export function sshConfigOf(value: string): SshConfig {
711
+ return JSON.parse(value.slice(SSH_PREFIX.length));
712
+ }
713
+
714
+ // ---------------------------------------------------------------- api
715
+
716
+ // An HTTP API credential that is more than one bare token: a base URL plus
717
+ // the headers the service wants (Cloudflare's X-Auth-Email + X-Auth-Key,
718
+ // Stripe's Authorization + Stripe-Version, ...). Materialised as a curl
719
+ // wrapper with the headers baked in — `"$NAME" /v1/charges` — so the secret
720
+ // never appears in the bash command at all.
721
+
722
+ const API_PREFIX = "@api ";
723
+
724
+ export interface ApiConfig {
725
+ base_url?: string;
726
+ headers: Record<string, string>;
727
+ }
728
+
729
+ export function setApiSecret(tenant: string, name: string, config: ApiConfig, workspace?: string) {
730
+ const headers = Object.entries(config.headers ?? {});
731
+ if (headers.length === 0) throw new Error("api secret needs at least one header");
732
+ for (const [h, v] of headers) {
733
+ if (!/^[A-Za-z0-9-]+$/.test(h)) throw new Error(`"${h}" is not a valid header name`);
734
+ if (/[\n\r]/.test(v)) throw new Error(`header ${h} cannot contain newlines`);
735
+ }
736
+ const base = config.base_url?.trim();
737
+ if (base && !/^https?:\/\//.test(base)) throw new Error("base_url must be http(s)");
738
+ setSecret(
739
+ tenant, name,
740
+ API_PREFIX + JSON.stringify({ ...(base ? { base_url: base.replace(/\/+$/, "") } : {}), headers: config.headers }),
741
+ workspace, "api",
742
+ );
743
+ }
744
+
745
+ export function isApiValue(value: string): boolean {
746
+ return value.startsWith(API_PREFIX);
747
+ }
748
+
749
+ export function apiConfigOf(value: string): ApiConfig {
750
+ return JSON.parse(value.slice(API_PREFIX.length));
751
+ }
752
+
753
+
754
+ /**
755
+ * Move an account's secrets from the install key onto its own key.
756
+ *
757
+ * Read with whichever key each record names, write them all back under the
758
+ * account's. Every value is decrypted BEFORE anything is written: a vault
759
+ * half-moved is an account that can authenticate nothing, so the whole file
760
+ * either converts or is left exactly as it was.
761
+ */
762
+ export function rewrapTenantSecrets(tenant: string): { moved: number; unreadable: string[] } {
763
+ assertSafeName(tenant, "tenant");
764
+ if (!tenantKey(tenant)) return { moved: 0, unreadable: [] };
765
+
766
+ let moved = 0;
767
+ const unreadable: string[] = [];
768
+
769
+ for (const { file } of secretFiles(tenant)) {
770
+ const all: SecretsFile = JSON.parse(fs.readFileSync(file, "utf8"));
771
+ const plain = new Map<string, string>();
772
+ let pending = 0;
773
+
774
+ for (const [name, rec] of Object.entries(all)) {
775
+ if (rec.k === "tenant") continue; // already moved
776
+ const value = decrypt(tenant, rec);
777
+ if (value === null) {
778
+ unreadable.push(name);
779
+ continue;
780
+ }
781
+ plain.set(name, value);
782
+ pending += 1;
783
+ }
784
+ // Nothing to do, or something could not be read — leave the file alone.
785
+ // A partial conversion is worse than none, because the half that moved is
786
+ // no longer openable by the key the other half still needs.
787
+ if (pending === 0 || unreadable.length) continue;
788
+
789
+ const next: SecretsFile = { ...all };
790
+ for (const [name, value] of plain) {
791
+ next[name] = { ...encryptValue(tenant, value), updatedAt: all[name].updatedAt, ...(all[name].kind ? { kind: all[name].kind } : {}) };
792
+ }
793
+ const tmp = `${file}.rewrap`;
794
+ fs.writeFileSync(tmp, JSON.stringify(next, null, 2), { mode: 0o600 });
795
+ fs.renameSync(tmp, file);
796
+ moved += pending;
797
+ }
798
+ return { moved, unreadable };
799
+ }