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