@foldrun/core 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -10
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/src/account.d.ts +45 -0
- package/dist/src/account.js +130 -0
- package/dist/src/api-tools.d.ts +8 -0
- package/dist/src/api-tools.js +22 -4
- package/dist/src/approvals.d.ts +104 -1
- package/dist/src/approvals.js +192 -2
- package/dist/src/browser-pod.d.ts +129 -0
- package/dist/src/browser-pod.js +284 -0
- package/dist/src/budget.d.ts +32 -0
- package/dist/src/budget.js +177 -0
- package/dist/src/catalog.js +3 -1
- package/dist/src/clock.d.ts +49 -0
- package/dist/src/clock.js +121 -0
- package/dist/src/completions.js +17 -8
- package/dist/src/confine.d.ts +22 -0
- package/dist/src/confine.js +224 -4
- package/dist/src/deploy.d.ts +33 -0
- package/dist/src/deploy.js +145 -6
- package/dist/src/egress.d.ts +26 -2
- package/dist/src/egress.js +8 -1
- package/dist/src/eol.d.ts +2 -0
- package/dist/src/eol.js +7 -0
- package/dist/src/evals.d.ts +9 -0
- package/dist/src/evals.js +26 -13
- package/dist/src/flow-lint.d.ts +12 -0
- package/dist/src/flow-lint.js +331 -1
- package/dist/src/flow-patterns.d.ts +141 -0
- package/dist/src/flow-patterns.js +568 -0
- package/dist/src/gitrepo.d.ts +18 -0
- package/dist/src/gitrepo.js +49 -0
- package/dist/src/host-env.d.ts +8 -0
- package/dist/src/host-env.js +48 -0
- package/dist/src/json-schema.d.ts +12 -0
- package/dist/src/json-schema.js +202 -0
- package/dist/src/kinds.js +1 -1
- package/dist/src/language.d.ts +17 -0
- package/dist/src/language.js +55 -0
- package/dist/src/layout.d.ts +69 -0
- package/dist/src/layout.js +200 -0
- package/dist/src/library.d.ts +9 -0
- package/dist/src/library.js +43 -4
- package/dist/src/limits.d.ts +84 -0
- package/dist/src/limits.js +287 -0
- package/dist/src/locale.d.ts +47 -0
- package/dist/src/locale.js +177 -0
- package/dist/src/mail.d.ts +88 -0
- package/dist/src/mail.js +245 -0
- package/dist/src/model-probe.d.ts +3 -0
- package/dist/src/model-probe.js +1 -0
- package/dist/src/notify.d.ts +132 -8
- package/dist/src/notify.js +360 -58
- package/dist/src/openapi.js +5 -3
- package/dist/src/operator.d.ts +201 -0
- package/dist/src/operator.js +251 -0
- package/dist/src/paths.d.ts +33 -0
- package/dist/src/paths.js +87 -0
- package/dist/src/platform.d.ts +144 -4
- package/dist/src/platform.js +27 -3
- package/dist/src/provider-health.d.ts +55 -0
- package/dist/src/provider-health.js +233 -0
- package/dist/src/providers.d.ts +481 -0
- package/dist/src/providers.js +1105 -6
- package/dist/src/refusal.d.ts +83 -0
- package/dist/src/refusal.js +281 -0
- package/dist/src/run-container.d.ts +206 -17
- package/dist/src/run-container.js +547 -80
- package/dist/src/runner.d.ts +179 -10
- package/dist/src/runner.js +1423 -169
- package/dist/src/runtime.d.ts +52 -0
- package/dist/src/runtime.js +493 -58
- package/dist/src/script-tools.d.ts +15 -2
- package/dist/src/script-tools.js +44 -4
- package/dist/src/secret-files.d.ts +6 -5
- package/dist/src/secret-files.js +24 -11
- package/dist/src/secret-health.d.ts +83 -0
- package/dist/src/secret-health.js +177 -0
- package/dist/src/secrets.d.ts +38 -1
- package/dist/src/secrets.js +197 -96
- package/dist/src/starter.d.ts +9 -0
- package/dist/src/starter.js +95 -11
- package/dist/src/step-exec.d.ts +106 -1
- package/dist/src/step-exec.js +558 -85
- package/dist/src/step-removal.d.ts +15 -0
- package/dist/src/step-removal.js +49 -0
- package/dist/src/storage.d.ts +23 -4
- package/dist/src/storage.js +125 -23
- package/dist/src/store.d.ts +492 -38
- package/dist/src/store.js +882 -69
- package/dist/src/subagents.d.ts +75 -0
- package/dist/src/subagents.js +149 -0
- package/dist/src/tar.js +2 -1
- package/dist/src/test-mode.d.ts +111 -0
- package/dist/src/test-mode.js +285 -0
- package/dist/src/tool-names.d.ts +13 -1
- package/dist/src/tool-names.js +38 -11
- package/dist/src/tool-programs.d.ts +26 -0
- package/dist/src/tool-programs.js +172 -0
- package/dist/src/tool-test.js +152 -33
- package/dist/src/translator.d.ts +8 -2
- package/dist/src/translator.js +71 -24
- package/dist/src/trigger-log.d.ts +43 -0
- package/dist/src/trigger-log.js +122 -0
- package/dist/src/triggers.d.ts +23 -0
- package/dist/src/triggers.js +101 -12
- package/dist/src/untrusted-fetch.d.ts +11 -0
- package/dist/src/untrusted-fetch.js +77 -0
- package/dist/src/web-actions.d.ts +91 -0
- package/dist/src/web-actions.js +199 -0
- package/dist/src/webhook.d.ts +19 -3
- package/dist/src/webhook.js +50 -11
- package/index.ts +3 -0
- package/package.json +5 -5
- package/src/account.ts +149 -0
- package/src/api-tools.ts +32 -4
- package/src/approvals.ts +253 -2
- package/src/browser-pod.ts +313 -0
- package/src/budget.ts +178 -0
- package/src/catalog.ts +3 -1
- package/src/clock.ts +149 -0
- package/src/completions.ts +17 -8
- package/src/confine.ts +224 -4
- package/src/deploy.ts +137 -6
- package/src/egress.ts +32 -4
- package/src/eol.ts +7 -0
- package/src/evals.ts +36 -11
- package/src/flow-lint.ts +371 -1
- package/src/flow-patterns.ts +597 -0
- package/src/gitrepo.ts +48 -0
- package/src/host-env.ts +51 -0
- package/src/json-schema.ts +184 -0
- package/src/kinds.ts +1 -1
- package/src/language.ts +62 -0
- package/src/layout.ts +228 -0
- package/src/library.ts +50 -4
- package/src/limits.ts +290 -0
- package/src/locale.ts +217 -0
- package/src/mail.ts +316 -0
- package/src/model-probe.ts +4 -0
- package/src/notify.ts +391 -44
- package/src/openapi.ts +5 -3
- package/src/operator.ts +300 -0
- package/src/paths.ts +83 -0
- package/src/platform.ts +158 -5
- package/src/provider-health.ts +275 -0
- package/src/providers.ts +1368 -6
- package/src/refusal.ts +320 -0
- package/src/run-container.ts +611 -76
- package/src/runner.ts +1463 -165
- package/src/runtime.ts +487 -55
- package/src/script-tools.ts +55 -6
- package/src/secret-files.ts +22 -10
- package/src/secret-health.ts +230 -0
- package/src/secrets.ts +191 -95
- package/src/starter.ts +96 -11
- package/src/step-exec.ts +548 -41
- package/src/step-removal.ts +59 -0
- package/src/storage.ts +149 -24
- package/src/store.ts +1227 -74
- package/src/subagents.ts +189 -0
- package/src/tar.ts +2 -1
- package/src/test-mode.ts +311 -0
- package/src/tool-names.ts +40 -11
- package/src/tool-programs.ts +179 -0
- package/src/tool-test.ts +159 -32
- package/src/translator.ts +74 -23
- package/src/trigger-log.ts +160 -0
- package/src/triggers.ts +102 -12
- package/src/untrusted-fetch.ts +88 -0
- package/src/web-actions.ts +237 -0
- package/src/webhook.ts +58 -11
- package/templates/hello/agents/curator/agent.md +1 -1
- package/templates/hello/agents/notetaker/agent.md +1 -1
package/src/secrets.ts
CHANGED
|
@@ -23,26 +23,54 @@ import fs from "node:fs";
|
|
|
23
23
|
import path from "node:path";
|
|
24
24
|
import { dataRoot } from "./paths.ts";
|
|
25
25
|
import { platform } from "./platform.ts";
|
|
26
|
+
import { fetchUntrusted } from "./untrusted-fetch.ts";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* GCM's auth tag, pinned to its full 16 bytes at every cipher call. Without
|
|
30
|
+
* `authTagLength`, Node accepts whatever length `setAuthTag` is handed — 4, 8,
|
|
31
|
+
* 12, 13, 14, 15 or 16 bytes — so a stored record whose tag had been cut to
|
|
32
|
+
* four bytes would still "verify" on 32 bits of authentication instead of
|
|
33
|
+
* 128. The tag comes from the record on disk; the record is the thing an
|
|
34
|
+
* attacker with file access would edit. One option makes a short tag an
|
|
35
|
+
* error rather than a weaker check.
|
|
36
|
+
*/
|
|
37
|
+
const GCM = { authTagLength: 16 } as const;
|
|
26
38
|
|
|
27
39
|
// The account's own data key, when the platform holds one; the install key
|
|
28
40
|
// otherwise. See platform.ts — locally there is no account key.
|
|
29
41
|
const tenantKey = (tenant: string) => platform.tenantKey(tenant);
|
|
30
42
|
import crypto from "node:crypto";
|
|
31
43
|
import { assertSafeName } from "./store.ts";
|
|
44
|
+
import { trimSlashes } from "./paths.ts";
|
|
32
45
|
|
|
33
46
|
const keyFile = () => path.join(dataRoot(), ".secret-key");
|
|
34
47
|
|
|
35
48
|
/** Which store a secret came from. */
|
|
36
49
|
export type SecretScope = "account" | "workspace";
|
|
37
50
|
|
|
38
|
-
|
|
51
|
+
/**
|
|
52
|
+
* The install's root secret, as text: FOLDRUN_SECRET_KEY when set, else the
|
|
53
|
+
* contents of `.secret-key` under the data root — created here, the first
|
|
54
|
+
* time anything asks, so there is never a moment when a derived token has
|
|
55
|
+
* nothing real to hang off. The vault, hook tokens, approval links and git
|
|
56
|
+
* push secrets all derive from this one value; rotating it invalidates every
|
|
57
|
+
* one of them together. Exported for webhook.ts, which used to fall back to
|
|
58
|
+
* a fixed dev string until the vault happened to create this file — so hook
|
|
59
|
+
* URLs were computable by anyone who read the source, and then changed the
|
|
60
|
+
* first time a secret was stored.
|
|
61
|
+
*/
|
|
62
|
+
export function installKeyMaterial(): string {
|
|
39
63
|
const fromEnv = process.env.FOLDRUN_SECRET_KEY;
|
|
40
|
-
if (fromEnv) return
|
|
64
|
+
if (fromEnv) return fromEnv;
|
|
41
65
|
fs.mkdirSync(dataRoot(), { recursive: true });
|
|
42
66
|
if (!fs.existsSync(keyFile())) {
|
|
43
67
|
fs.writeFileSync(keyFile(), crypto.randomBytes(32).toString("hex"), { mode: 0o600 });
|
|
44
68
|
}
|
|
45
|
-
return
|
|
69
|
+
return fs.readFileSync(keyFile(), "utf8");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function masterKey(): Buffer {
|
|
73
|
+
return crypto.createHash("sha256").update(installKeyMaterial()).digest();
|
|
46
74
|
}
|
|
47
75
|
|
|
48
76
|
// `workspace` undefined means the account store.
|
|
@@ -76,19 +104,80 @@ interface StoredSecret {
|
|
|
76
104
|
type SecretsFile = Record<string, StoredSecret>;
|
|
77
105
|
|
|
78
106
|
function read(tenant: string, workspace?: string): SecretsFile {
|
|
79
|
-
|
|
80
|
-
|
|
107
|
+
return readFile(secretsFile(tenant, workspace));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ------------------------------------------------------------ safe writes
|
|
111
|
+
//
|
|
112
|
+
// A vault file is read, changed and written back. It was written in place,
|
|
113
|
+
// with no lock: two saves at once kept one; a crash mid-write left half a
|
|
114
|
+
// file, which is an account that cannot authenticate anything; and the boot
|
|
115
|
+
// re-wrap ran in every pod at once through ONE shared temp name, so two pods
|
|
116
|
+
// could interleave into it and rename a corrupt file into place.
|
|
117
|
+
//
|
|
118
|
+
// Now every change goes through mutateVaultFile: an exclusive lock file
|
|
119
|
+
// (O_EXCL, which NFS 4.1 and EFS honour, so it holds across pods), the read
|
|
120
|
+
// and the change under it, a temp file unique to this writer, and a rename —
|
|
121
|
+
// atomic on the same filesystem, so a reader sees the old file or the new
|
|
122
|
+
// one, never half. A lock older than LOCK_STALE_MS is a crashed holder.
|
|
123
|
+
|
|
124
|
+
const LOCK_STALE_MS = 30_000;
|
|
125
|
+
const LOCK_WAIT_MS = 10_000;
|
|
126
|
+
const sleep = (ms: number) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
127
|
+
|
|
128
|
+
function readFile(file: string): SecretsFile {
|
|
129
|
+
if (!fs.existsSync(file)) return {};
|
|
81
130
|
try {
|
|
82
|
-
return JSON.parse(fs.readFileSync(
|
|
131
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
83
132
|
} catch {
|
|
84
133
|
return {};
|
|
85
134
|
}
|
|
86
135
|
}
|
|
87
136
|
|
|
88
|
-
function
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
137
|
+
function lockVault(file: string): () => void {
|
|
138
|
+
const lock = `${file}.lock`;
|
|
139
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
140
|
+
for (;;) {
|
|
141
|
+
try {
|
|
142
|
+
const fd = fs.openSync(lock, "wx", 0o600);
|
|
143
|
+
fs.writeSync(fd, `${process.pid}@${new Date().toISOString()}`);
|
|
144
|
+
fs.closeSync(fd);
|
|
145
|
+
return () => fs.rmSync(lock, { force: true });
|
|
146
|
+
} catch (err) {
|
|
147
|
+
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
|
|
148
|
+
try {
|
|
149
|
+
if (Date.now() - fs.statSync(lock).mtimeMs > LOCK_STALE_MS) {
|
|
150
|
+
fs.rmSync(lock, { force: true }); // a holder that died; take it over
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
} catch {
|
|
154
|
+
continue; // released between the open and the stat
|
|
155
|
+
}
|
|
156
|
+
if (Date.now() > deadline) throw new Error("the vault is busy — another change is being saved; try again");
|
|
157
|
+
sleep(25);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Change one vault file safely: `change` gets what is stored now and returns
|
|
164
|
+
* the new contents, or null to leave the file untouched. Returns what is
|
|
165
|
+
* stored after.
|
|
166
|
+
*/
|
|
167
|
+
export function mutateVaultFile(file: string, change: (all: SecretsFile) => SecretsFile | null): SecretsFile {
|
|
168
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
169
|
+
const release = lockVault(file);
|
|
170
|
+
try {
|
|
171
|
+
const current = readFile(file);
|
|
172
|
+
const next = change(structuredClone(current));
|
|
173
|
+
if (next === null) return current;
|
|
174
|
+
const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
|
|
175
|
+
fs.writeFileSync(tmp, JSON.stringify(next, null, 2), { mode: 0o600 });
|
|
176
|
+
fs.renameSync(tmp, file);
|
|
177
|
+
return next;
|
|
178
|
+
} finally {
|
|
179
|
+
release();
|
|
180
|
+
}
|
|
92
181
|
}
|
|
93
182
|
|
|
94
183
|
export const SECRET_NAME = /^[A-Z][A-Z0-9_]{1,63}$/;
|
|
@@ -105,13 +194,14 @@ export function setSecret(
|
|
|
105
194
|
}
|
|
106
195
|
if (value.length > 8192) throw new Error("secret too long");
|
|
107
196
|
const sealed = encryptValue(tenant, value);
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
197
|
+
mutateVaultFile(secretsFile(tenant, workspace), (all) => {
|
|
198
|
+
all[name] = {
|
|
199
|
+
...sealed,
|
|
200
|
+
updatedAt: new Date().toISOString(),
|
|
201
|
+
...(kind ? { kind } : {}),
|
|
202
|
+
};
|
|
203
|
+
return all;
|
|
204
|
+
});
|
|
115
205
|
}
|
|
116
206
|
|
|
117
207
|
/**
|
|
@@ -135,7 +225,7 @@ export function encryptValue(
|
|
|
135
225
|
): { iv: string; tag: string; data: string; k?: "tenant" } {
|
|
136
226
|
const { key, mark } = keyFor(tenant);
|
|
137
227
|
const iv = crypto.randomBytes(12);
|
|
138
|
-
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
|
|
228
|
+
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv, GCM);
|
|
139
229
|
const enc = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
|
|
140
230
|
return {
|
|
141
231
|
iv: iv.toString("base64"),
|
|
@@ -180,6 +270,7 @@ function decrypt(tenant: string, rec: StoredSecret | undefined): string | null {
|
|
|
180
270
|
"aes-256-gcm",
|
|
181
271
|
key,
|
|
182
272
|
Buffer.from(rec.iv, "base64"),
|
|
273
|
+
GCM,
|
|
183
274
|
);
|
|
184
275
|
decipher.setAuthTag(Buffer.from(rec.tag, "base64"));
|
|
185
276
|
return Buffer.concat([
|
|
@@ -214,9 +305,11 @@ export function getSecret(
|
|
|
214
305
|
}
|
|
215
306
|
|
|
216
307
|
export function deleteSecret(tenant: string, name: string, workspace?: string) {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
308
|
+
mutateVaultFile(secretsFile(tenant, workspace), (all) => {
|
|
309
|
+
if (!(name in all)) return null;
|
|
310
|
+
delete all[name];
|
|
311
|
+
return all;
|
|
312
|
+
});
|
|
220
313
|
}
|
|
221
314
|
|
|
222
315
|
export interface SecretEntry {
|
|
@@ -340,48 +433,46 @@ export function rotateMasterKey(
|
|
|
340
433
|
const unreadable: string[] = [];
|
|
341
434
|
|
|
342
435
|
for (const { file } of secretFiles(tenant)) {
|
|
343
|
-
const all: SecretsFile = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
344
|
-
const next: SecretsFile = {};
|
|
345
436
|
let count = 0;
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
437
|
+
mutateVaultFile(file, (all) => {
|
|
438
|
+
const next: SecretsFile = {};
|
|
439
|
+
for (const [name, rec] of Object.entries(all)) {
|
|
440
|
+
// A record under the ACCOUNT's key is not under the install key, so
|
|
441
|
+
// rotating the install key must not touch it — re-encrypting it here
|
|
442
|
+
// with a key it was never sealed with is how a rotation would destroy a
|
|
443
|
+
// vault it was meant to protect. Rotating the install key now re-wraps
|
|
444
|
+
// the account keys instead, which is a handful of small values rather
|
|
445
|
+
// than every secret on the box.
|
|
446
|
+
if (rec.k === "tenant") {
|
|
447
|
+
next[name] = rec;
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
let plain: string;
|
|
451
|
+
try {
|
|
452
|
+
const d = crypto.createDecipheriv("aes-256-gcm", oldKey, Buffer.from(rec.iv, "base64"), GCM);
|
|
453
|
+
d.setAuthTag(Buffer.from(rec.tag, "base64"));
|
|
454
|
+
plain = Buffer.concat([d.update(Buffer.from(rec.data, "base64")), d.final()]).toString("utf8");
|
|
455
|
+
} catch {
|
|
456
|
+
unreadable.push(name);
|
|
457
|
+
next[name] = rec; // untouched
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
const iv = crypto.randomBytes(12);
|
|
461
|
+
const c = crypto.createCipheriv("aes-256-gcm", newKey, iv, GCM);
|
|
462
|
+
const enc = Buffer.concat([c.update(plain, "utf8"), c.final()]);
|
|
463
|
+
next[name] = {
|
|
464
|
+
iv: iv.toString("base64"),
|
|
465
|
+
tag: c.getAuthTag().toString("base64"),
|
|
466
|
+
data: enc.toString("base64"),
|
|
467
|
+
updatedAt: rec.updatedAt, // rotation is not an edit
|
|
468
|
+
// Kept: without it an oauth2 secret stops being refreshed after a
|
|
469
|
+
// rotation, and a file/ssh/api secret stops being materialised.
|
|
470
|
+
...(rec.kind ? { kind: rec.kind } : {}),
|
|
471
|
+
};
|
|
472
|
+
count++;
|
|
357
473
|
}
|
|
358
|
-
|
|
359
|
-
|
|
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);
|
|
474
|
+
return next;
|
|
475
|
+
});
|
|
385
476
|
rewritten.push({ file, secrets: count });
|
|
386
477
|
}
|
|
387
478
|
|
|
@@ -459,12 +550,12 @@ async function exchange(config: OAuth2Config, cacheKey: string): Promise<string>
|
|
|
459
550
|
client_secret: config.client_secret,
|
|
460
551
|
...(config.extra ?? {}),
|
|
461
552
|
});
|
|
462
|
-
const res = await
|
|
553
|
+
const res = await fetchUntrusted(config.token_url, {
|
|
463
554
|
method: "POST",
|
|
464
555
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
465
556
|
body: body.toString(),
|
|
466
557
|
signal: AbortSignal.timeout(15_000),
|
|
467
|
-
});
|
|
558
|
+
}, { sameOrigin: true });
|
|
468
559
|
const payload = (await res.json().catch(() => ({}))) as {
|
|
469
560
|
access_token?: string;
|
|
470
561
|
expires_in?: number;
|
|
@@ -498,6 +589,9 @@ async function exchange(config: OAuth2Config, cacheKey: string): Promise<string>
|
|
|
498
589
|
*/
|
|
499
590
|
export async function materializeSecrets(
|
|
500
591
|
env: Record<string, string>,
|
|
592
|
+
/** Whose secrets these are, when the caller knows: lets the platform keep
|
|
593
|
+
* each OAuth connection's health and quote a reconnect link on failure. */
|
|
594
|
+
ctx?: { tenant: string; workspace?: string },
|
|
501
595
|
): Promise<Record<string, string>> {
|
|
502
596
|
const out: Record<string, string> = { ...env };
|
|
503
597
|
for (const [name, value] of Object.entries(env)) {
|
|
@@ -518,8 +612,11 @@ export async function materializeSecrets(
|
|
|
518
612
|
const fp = crypto.createHash("sha256").update(grantMaterial).digest("hex").slice(0, 16);
|
|
519
613
|
out[name] = await exchange(config, `${name}:${config.client_id}:${fp}`);
|
|
520
614
|
} catch (err) {
|
|
521
|
-
|
|
615
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
616
|
+
const link = ctx ? await platform.noteOAuthRefresh(ctx, name, message).catch(() => undefined) : undefined;
|
|
617
|
+
throw new Error(`secret ${name}: ${message}${link ? ` — reconnect it in one click: ${link}` : ""}`);
|
|
522
618
|
}
|
|
619
|
+
if (ctx) await platform.noteOAuthRefresh(ctx, name, null).catch(() => undefined);
|
|
523
620
|
} else if (value.startsWith(SERVICE_ACCOUNT_PREFIX)) {
|
|
524
621
|
let config: ServiceAccountConfig;
|
|
525
622
|
try {
|
|
@@ -570,7 +667,7 @@ export function setServiceAccountSecret(
|
|
|
570
667
|
for (const field of ["token_url", "issuer", "private_key", "scope"] as const) {
|
|
571
668
|
if (!config[field]?.trim()) throw new Error(`service account secret needs ${field}`);
|
|
572
669
|
}
|
|
573
|
-
if (!/BEGIN [A-Z ]
|
|
670
|
+
if (!/BEGIN [A-Z ]{0,40}PRIVATE KEY/.test(config.private_key)) {
|
|
574
671
|
throw new Error("private_key must be a PEM key (from the service account JSON)");
|
|
575
672
|
}
|
|
576
673
|
setSecret(tenant, name, SERVICE_ACCOUNT_PREFIX + JSON.stringify(config), workspace, "service-account");
|
|
@@ -604,7 +701,7 @@ async function exchangeServiceAccount(config: ServiceAccountConfig, cacheKey: st
|
|
|
604
701
|
const signature = base64url(signer.sign(config.private_key));
|
|
605
702
|
const jwt = `${header}.${claim}.${signature}`;
|
|
606
703
|
|
|
607
|
-
const res = await
|
|
704
|
+
const res = await fetchUntrusted(config.token_url, {
|
|
608
705
|
method: "POST",
|
|
609
706
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
610
707
|
body: new URLSearchParams({
|
|
@@ -612,7 +709,7 @@ async function exchangeServiceAccount(config: ServiceAccountConfig, cacheKey: st
|
|
|
612
709
|
assertion: jwt,
|
|
613
710
|
}).toString(),
|
|
614
711
|
signal: AbortSignal.timeout(15_000),
|
|
615
|
-
});
|
|
712
|
+
}, { sameOrigin: true });
|
|
616
713
|
const payload = (await res.json().catch(() => ({}))) as {
|
|
617
714
|
access_token?: string;
|
|
618
715
|
expires_in?: number;
|
|
@@ -692,7 +789,7 @@ export function setSshSecret(tenant: string, name: string, config: SshConfig, wo
|
|
|
692
789
|
const hasKey = Boolean(config.private_key?.trim());
|
|
693
790
|
const hasPassword = Boolean(config.password);
|
|
694
791
|
if (hasKey === hasPassword) throw new Error("ssh secret needs a private key or a password (not both)");
|
|
695
|
-
if (hasKey && !/BEGIN [A-Z ]
|
|
792
|
+
if (hasKey && !/BEGIN [A-Z ]{0,40}PRIVATE KEY/.test(config.private_key!)) {
|
|
696
793
|
throw new Error("private_key must be a PEM/OpenSSH private key");
|
|
697
794
|
}
|
|
698
795
|
if (hasPassword && /[\n\r]/.test(config.password!)) throw new Error("ssh password cannot contain newlines");
|
|
@@ -737,7 +834,7 @@ export function setApiSecret(tenant: string, name: string, config: ApiConfig, wo
|
|
|
737
834
|
if (base && !/^https?:\/\//.test(base)) throw new Error("base_url must be http(s)");
|
|
738
835
|
setSecret(
|
|
739
836
|
tenant, name,
|
|
740
|
-
API_PREFIX + JSON.stringify({ ...(base ? { base_url: base
|
|
837
|
+
API_PREFIX + JSON.stringify({ ...(base ? { base_url: trimSlashes(base) } : {}), headers: config.headers }),
|
|
741
838
|
workspace, "api",
|
|
742
839
|
);
|
|
743
840
|
}
|
|
@@ -767,33 +864,32 @@ export function rewrapTenantSecrets(tenant: string): { moved: number; unreadable
|
|
|
767
864
|
const unreadable: string[] = [];
|
|
768
865
|
|
|
769
866
|
for (const { file } of secretFiles(tenant)) {
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
867
|
+
// Under the vault's lock: every pod runs this at boot, and a save made
|
|
868
|
+
// between our read and our write must not be lost.
|
|
869
|
+
mutateVaultFile(file, (all) => {
|
|
870
|
+
const plain = new Map<string, string>();
|
|
871
|
+
const unreadableHere: string[] = [];
|
|
872
|
+
for (const [name, rec] of Object.entries(all)) {
|
|
873
|
+
if (rec.k === "tenant") continue; // already moved
|
|
874
|
+
const value = decrypt(tenant, rec);
|
|
875
|
+
if (value === null) {
|
|
876
|
+
unreadableHere.push(name);
|
|
877
|
+
continue;
|
|
878
|
+
}
|
|
879
|
+
plain.set(name, value);
|
|
780
880
|
}
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
next
|
|
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;
|
|
881
|
+
unreadable.push(...unreadableHere);
|
|
882
|
+
// Nothing to do, or something in THIS file could not be read — leave it
|
|
883
|
+
// alone. A partial conversion is worse than none, because the half that
|
|
884
|
+
// moved is no longer openable by the key the other half still needs.
|
|
885
|
+
if (plain.size === 0 || unreadableHere.length) return null;
|
|
886
|
+
const next: SecretsFile = { ...all };
|
|
887
|
+
for (const [name, value] of plain) {
|
|
888
|
+
next[name] = { ...encryptValue(tenant, value), updatedAt: all[name].updatedAt, ...(all[name].kind ? { kind: all[name].kind } : {}) };
|
|
889
|
+
}
|
|
890
|
+
moved += plain.size;
|
|
891
|
+
return next;
|
|
892
|
+
});
|
|
797
893
|
}
|
|
798
894
|
return { moved, unreadable };
|
|
799
895
|
}
|
package/src/starter.ts
CHANGED
|
@@ -72,6 +72,37 @@ dropped further down.
|
|
|
72
72
|
];
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* What an account folder ignores, at its own root.
|
|
77
|
+
*
|
|
78
|
+
* The workspace scaffold has always shipped one of these; an account folder is
|
|
79
|
+
* the thing that gets committed now, and the key that decrypts its secrets
|
|
80
|
+
* lives at ITS root — `<account>/.foldrun/` — so the guard has to be here too
|
|
81
|
+
* or the first `git add .` commits the vault key.
|
|
82
|
+
*/
|
|
83
|
+
export function accountGitignore(): StarterFile {
|
|
84
|
+
return {
|
|
85
|
+
path: ".gitignore",
|
|
86
|
+
content: `# The key that decrypts every secret in this account, plus the local run
|
|
87
|
+
# store. Never commit these.
|
|
88
|
+
.foldrun/
|
|
89
|
+
|
|
90
|
+
# Written by runs, not by you.
|
|
91
|
+
workspaces/*/runs/
|
|
92
|
+
workspaces/*/outputs/
|
|
93
|
+
state/
|
|
94
|
+
storage/
|
|
95
|
+
# The link a running step keeps in its agent folder (workspace/ for the
|
|
96
|
+
# shell); removed when the step ends, ignored in case one is left behind.
|
|
97
|
+
workspaces/*/agents/*/workspace
|
|
98
|
+
|
|
99
|
+
.env
|
|
100
|
+
.env.local
|
|
101
|
+
.DS_Store
|
|
102
|
+
`,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
75
106
|
export function starterFiles(workspace: string): StarterFile[] {
|
|
76
107
|
return [
|
|
77
108
|
{
|
|
@@ -107,6 +138,9 @@ Context every agent here shares. Prices, rules, anything they should all know.
|
|
|
107
138
|
# Written by runs, not by you.
|
|
108
139
|
runs/
|
|
109
140
|
outputs/
|
|
141
|
+
# The link a running step keeps in its agent folder (workspace/ for the
|
|
142
|
+
# shell); removed when the step ends, ignored in case one is left behind.
|
|
143
|
+
agents/*/workspace
|
|
110
144
|
|
|
111
145
|
.env
|
|
112
146
|
.env.local
|
|
@@ -124,6 +158,8 @@ effort: high
|
|
|
124
158
|
tools:
|
|
125
159
|
- web
|
|
126
160
|
- read
|
|
161
|
+
web:
|
|
162
|
+
actions: [search, fetch]
|
|
127
163
|
---
|
|
128
164
|
|
|
129
165
|
Pick exactly one topic, in a short paragraph, and say who it helps and why now.
|
|
@@ -137,7 +173,7 @@ Do not write the article.
|
|
|
137
173
|
name: writer
|
|
138
174
|
description: Turns a brief into a short draft.
|
|
139
175
|
tools:
|
|
140
|
-
-
|
|
176
|
+
- write
|
|
141
177
|
---
|
|
142
178
|
|
|
143
179
|
Write a short draft from the brief you were given. Save it to outputs/draft.md.
|
|
@@ -224,6 +260,10 @@ foldrun deploy --url <server> --token $FOLDRUN_TOKEN
|
|
|
224
260
|
\`check\` is cheap and catches a broken flow before a schedule fires it at 3am.
|
|
225
261
|
Run it after edits, the way you would run a typecheck.
|
|
226
262
|
|
|
263
|
+
A deploy that would delete files this folder no longer has lists them and
|
|
264
|
+
stops: with no terminal it is refused. Tell the person what it would delete; add \`--yes\`
|
|
265
|
+
only when they asked for exactly that deletion.
|
|
266
|
+
|
|
227
267
|
## Where things go
|
|
228
268
|
|
|
229
269
|
| Path | What |
|
|
@@ -246,10 +286,23 @@ Regenerated every run means a file; accumulated across runs means \`state/\`.
|
|
|
246
286
|
That last row is worth knowing: a subagent you write in Claude Code is imported
|
|
247
287
|
as \`agents/<name>/agent.md\` at deploy, no conversion. Write it either way.
|
|
248
288
|
|
|
289
|
+
## Paths inside a workspace
|
|
290
|
+
|
|
291
|
+
An agent's working directory is \`agents/<name>/\`. Write every path to the
|
|
292
|
+
workspace's own folders as \`workspace/…\` — \`workspace/storage/report.csv\`,
|
|
293
|
+
\`workspace/state/cursor.json\` — in an agent's prose, in flow options
|
|
294
|
+
(\`each: rows of\`, \`when: rows of\`, \`verify:\`, \`preview:\`, \`schema:\`) and in
|
|
295
|
+
script arguments. It resolves the same in the file tools, the shell, scripts
|
|
296
|
+
and checks. The older \`../../storage/…\` reaches the same place and still
|
|
297
|
+
works; \`foldrun check\` suggests the new spelling where it sees it.
|
|
298
|
+
A Node script started as \`node workspace/tools/x/run.mjs\` is its own main
|
|
299
|
+
module there, so its \`import.meta.url\` entry check runs (it did not before
|
|
300
|
+
2 Oct 2026 and exited 0 doing nothing).
|
|
301
|
+
|
|
249
302
|
## What a deploy does NOT touch
|
|
250
303
|
|
|
251
|
-
\`runs/\`, \`state/\`, \`secrets.json\`,
|
|
252
|
-
push does not mention. Those belong to the platform, not to git — a deploy that
|
|
304
|
+
\`runs/\`, \`state/\`, \`storage/\`, \`secrets.json\`, the trigger log, and any
|
|
305
|
+
memory an agent wrote that your push does not mention. Those belong to the platform, not to git — a deploy that
|
|
253
306
|
reverted what an agent learned would make every run a little dumber.
|
|
254
307
|
|
|
255
308
|
Secrets never go in these files. \`foldrun secrets\` puts them in the vault, and
|
|
@@ -285,19 +338,32 @@ All of it, \`/api/workspaces/<workspace>\` unless noted:
|
|
|
285
338
|
| \`/runs/<id>/stop\` | POST | kill it — destroys the sandbox too |
|
|
286
339
|
| \`/runs/<id>/rerun\` | POST | again, or from a chosen step |
|
|
287
340
|
| \`/runs/<id>/approve\` | POST | release a run waiting on a human |
|
|
341
|
+
| \`/runs/<id>/answer\` | POST | answer a question an agent is asking mid-step (\`tools: [ask]\`) |
|
|
342
|
+
| \`/runs/<id>/message\` | POST | tell a running step something; \`step\` picks one of a parallel group |
|
|
288
343
|
| \`/flows\` · \`/flows/<f>\` | GET POST · POST DELETE PATCH | list, create, edit |
|
|
289
344
|
| \`/flows/<f>/run\` | POST | start a flow |
|
|
290
345
|
| \`/agents\` · \`/agents/<a>/run\` | GET POST · POST | list agents, run one |
|
|
291
346
|
| \`/evals\` · \`/evals/<e>/run\` | GET POST · POST | list evals, run one |
|
|
292
347
|
| \`/tools/<t>/test\` | POST | exercise one tool alone |
|
|
293
|
-
| \`/storage\` · \`/storage/download\` · \`/storage/upload-url\` | GET POST PUT DELETE · GET · POST | workspace files |
|
|
348
|
+
| \`/storage\` · \`/storage/download\` · \`/storage/upload-url\` · \`/storage/preview\` | GET POST PUT DELETE · GET · POST · GET | workspace files, any type; \`preview\` returns one parsed for reading |
|
|
294
349
|
| \`/assets\` | POST | upload an asset |
|
|
295
350
|
| \`/hooks/<f>/rotate\` | POST | new webhook token for a flow |
|
|
351
|
+
| \`/notify/deliveries\` · \`/notify/deliveries/<id>/redeliver\` | GET · POST | every notify: webhook sent and each attempt; send one again |
|
|
352
|
+
| \`/restore\` | POST | the workspace's source back to a point in its history — \`dryRun\` first, then \`confirm\` |
|
|
296
353
|
| \`/vocabulary\` | GET | what this workspace's documents may say |
|
|
297
354
|
|
|
298
355
|
Account-level, outside \`/workspaces\`: \`/api/secrets\`, \`/api/keys\`, \`/api/schedule\`,
|
|
299
356
|
\`/api/approvals\`, \`/api/usage\`, \`/api/library/<kind>\`, \`/api/team\`, \`/api/billing\`,
|
|
300
|
-
\`/api/
|
|
357
|
+
\`/api/me/notifications\` (what mail you get; a key acts for whoever minted it),
|
|
358
|
+
\`/api/account/backups\` (snapshots, restore requests), \`/api/me/onboarding\` (getting started),
|
|
359
|
+
\`/api/healthz\` and \`/api/version\` (open), \`/api/changelog\` (release notes)
|
|
360
|
+
and \`/api/metrics\` (Prometheus).
|
|
361
|
+
|
|
362
|
+
Every call answers — and the CLI sends — \`Foldrun-Version: YYYY-MM-DD\` (dated API
|
|
363
|
+
versions; an account pin in Settings → API), \`X-RateLimit-*\` headers (a \`429\`
|
|
364
|
+
carries \`Retry-After\`), and writes that start work take an \`Idempotency-Key\`
|
|
365
|
+
(a retry with the same key replays the first answer). The whole surface as
|
|
366
|
+
OpenAPI 3.1: \`/api/openapi.json\` (open), or \`foldrun api spec --out openapi.json\`.
|
|
301
367
|
|
|
302
368
|
Debugging a failed run is usually: \`runs\` to find it, \`runs/<id>\` to read which
|
|
303
369
|
step failed and what it cost, fix the markdown, \`deploy\`, then \`rerun\`.
|
|
@@ -310,6 +376,11 @@ git push endpoint.
|
|
|
310
376
|
|
|
311
377
|
A numbered list of steps. The number is the GROUP: same number means those
|
|
312
378
|
steps run in parallel, and the flow waits for all of them before the next.
|
|
379
|
+
The dashboard's canvas and the CLI edit the same file: \`foldrun flow show
|
|
380
|
+
<flow>\` draws it, \`foldrun flow add <flow> <pattern>\` inserts a pattern
|
|
381
|
+
(chain, parallel, router, fan-out, loop, approval, ask, wait, rescue,
|
|
382
|
+
subflow), \`foldrun flow rm-step\` removes a step, and \`foldrun agent link
|
|
383
|
+
<agent> --subagent|--consult|--can-ask\` wires agents together.
|
|
313
384
|
|
|
314
385
|
\`\`\`markdown
|
|
315
386
|
---
|
|
@@ -325,7 +396,7 @@ priority: normal # high | normal | low — where its runs stand in the q
|
|
|
325
396
|
1. [[researcher]] — Find three sources on {{topic}}.
|
|
326
397
|
2. [[writer]] — Draft from what the researcher found.
|
|
327
398
|
model: max
|
|
328
|
-
verify: the draft cites every source
|
|
399
|
+
verify: judge: the draft cites every source
|
|
329
400
|
3?. [[editor]] — Tighten it.
|
|
330
401
|
4!. [[publisher]] — Publish it.
|
|
331
402
|
\`\`\`
|
|
@@ -338,11 +409,14 @@ Indented under a step, any of:
|
|
|
338
409
|
| | |
|
|
339
410
|
|---|---|
|
|
340
411
|
| \`approve: true\` | stop for a human (\`!\` is shorthand for this) |
|
|
341
|
-
| \`when: <
|
|
412
|
+
| \`when: <MARKER>\` | run only if a line of the previous result begins with it |
|
|
413
|
+
| \`when: rows of <path>\` | run only if that CSV has a data row — for the steps after an \`each:\` |
|
|
342
414
|
| \`case: <value>\` / \`else: true\` | branch on the previous step's result |
|
|
343
415
|
| \`retry: 2\` | re-run on failure, up to 5 |
|
|
344
416
|
| \`timeout: 900\` | seconds before the step is cut off |
|
|
345
|
-
| \`verify: <
|
|
417
|
+
| \`verify: <shell>\` | the step fails unless the command exits 0; the reply is in \`$FOLDRUN_REPLY_FILE\` |
|
|
418
|
+
| \`verify: judge: <claim>\` | a model grades the claim against the output |
|
|
419
|
+
| \`verify: contains:\` / \`not-contains:\` / \`matches:\` / \`file:\` | the cheap checks, no shell |
|
|
346
420
|
| \`model:\` / \`effort:\` | override the flow's default for this step |
|
|
347
421
|
| \`loop: 3\` / \`until: <condition>\` | repeat until it holds, at most 5 |
|
|
348
422
|
| \`each: lines\` | run once per line of the previous result |
|
|
@@ -355,7 +429,11 @@ Indented under a step, any of:
|
|
|
355
429
|
| \`preview: draft/*.mdx\` | what the approval box shows — paths under \`storage/\`, globs allowed |
|
|
356
430
|
| \`delegate: [[a]], [[b]]\` | hand the step to whichever fits, up to 5 |
|
|
357
431
|
| \`output: json\` | the reply ends with one JSON value; the next step gets it as data, \`each: items\` fans out over it |
|
|
432
|
+
| \`schema: {...}\` | with \`output: json\` — the JSON Schema the value must satisfy; inline, a YAML block under the key, or a file path |
|
|
358
433
|
| \`each: items\` | run once per element of the previous step's JSON array |
|
|
434
|
+
| \`parallel: 3\` | how many \`each:\` instances run at once — default all |
|
|
435
|
+
| \`max_turns: 20\` | the most model turns before the step is stopped, beside \`budget:\` and \`timeout:\` |
|
|
436
|
+
| \`limits: {web.search: 10}\` | this step's call limits, over the agent's key by key |
|
|
359
437
|
|
|
360
438
|
## Writing an agent
|
|
361
439
|
|
|
@@ -369,14 +447,17 @@ description: Finds and summarises sources.
|
|
|
369
447
|
model: default # fast | default | max
|
|
370
448
|
effort: low # how hard to think about it
|
|
371
449
|
size: large # small | large | heavy — the sandbox it rents
|
|
372
|
-
tools: [
|
|
450
|
+
tools: [web, read, ask, my-folder-tool] # built-ins and your own tools/, one list; \`ask\` lets it ask you mid-step
|
|
373
451
|
disallowedTools: [Bash] # subtract from what it would otherwise have
|
|
374
452
|
skills: [house-style] # from skills/
|
|
375
453
|
scripts: [summarise.py] # from scripts/, each becomes a callable tool
|
|
376
454
|
apis: [{ name: crm }] # an HTTP API, as a tool
|
|
377
455
|
mcpServers: {} # an MCP server, as a tool
|
|
378
|
-
agents: [writer] # colleagues this one may consult
|
|
379
|
-
|
|
456
|
+
agents: [writer] # colleagues this one may consult (an answer, no tools)
|
|
457
|
+
subagents: [researcher] # colleagues it may delegate a job to (own context, own tools ≤ its own)
|
|
458
|
+
secrets: [CRM_TOKEN] # keys the agent uses itself; a tool's own come with the tool
|
|
459
|
+
limits: {web.search: 40, crm: 20, calls: 300} # per step; a call past a limit is refused, never run
|
|
460
|
+
timezone: Australia/Sydney # the calendar it works to — IANA name or UTC+10
|
|
380
461
|
provider: {} # BYOK: your own model credential
|
|
381
462
|
permissionMode: plan # plan first, act once approved
|
|
382
463
|
runtime: # only if scripts need packages
|
|
@@ -389,6 +470,10 @@ You research a topic and report what you found...
|
|
|
389
470
|
Only \`name\` and the prose are required. Every other field widens or narrows
|
|
390
471
|
what the agent can reach, and the default is narrow.
|
|
391
472
|
|
|
473
|
+
\`timezone:\` is nearest-wins all the way down: the agent's own, else its
|
|
474
|
+
flow's, else the workspace's AGENTS.md, else the account's, else UTC. It sets
|
|
475
|
+
\`TZ\` and the date the agent is told, so a Sydney desk stamps the Sydney date.
|
|
476
|
+
|
|
392
477
|
\`runtime:\` installs pip/npm packages in the sandbox each agent runs in. Pin
|
|
393
478
|
versions the normal way (\`pandas>=2\`, \`lodash@^4\`). Python and Node are
|
|
394
479
|
supported; anything else should ship as a committed binary.
|