@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/dist/src/secrets.js
CHANGED
|
@@ -22,21 +22,47 @@ import fs from "node:fs";
|
|
|
22
22
|
import path from "node:path";
|
|
23
23
|
import { dataRoot } from "./paths.js";
|
|
24
24
|
import { platform } from "./platform.js";
|
|
25
|
+
import { fetchUntrusted } from "./untrusted-fetch.js";
|
|
26
|
+
/**
|
|
27
|
+
* GCM's auth tag, pinned to its full 16 bytes at every cipher call. Without
|
|
28
|
+
* `authTagLength`, Node accepts whatever length `setAuthTag` is handed — 4, 8,
|
|
29
|
+
* 12, 13, 14, 15 or 16 bytes — so a stored record whose tag had been cut to
|
|
30
|
+
* four bytes would still "verify" on 32 bits of authentication instead of
|
|
31
|
+
* 128. The tag comes from the record on disk; the record is the thing an
|
|
32
|
+
* attacker with file access would edit. One option makes a short tag an
|
|
33
|
+
* error rather than a weaker check.
|
|
34
|
+
*/
|
|
35
|
+
const GCM = { authTagLength: 16 };
|
|
25
36
|
// The account's own data key, when the platform holds one; the install key
|
|
26
37
|
// otherwise. See platform.ts — locally there is no account key.
|
|
27
38
|
const tenantKey = (tenant) => platform.tenantKey(tenant);
|
|
28
39
|
import crypto from "node:crypto";
|
|
29
40
|
import { assertSafeName } from "./store.js";
|
|
41
|
+
import { trimSlashes } from "./paths.js";
|
|
30
42
|
const keyFile = () => path.join(dataRoot(), ".secret-key");
|
|
31
|
-
|
|
43
|
+
/**
|
|
44
|
+
* The install's root secret, as text: FOLDRUN_SECRET_KEY when set, else the
|
|
45
|
+
* contents of `.secret-key` under the data root — created here, the first
|
|
46
|
+
* time anything asks, so there is never a moment when a derived token has
|
|
47
|
+
* nothing real to hang off. The vault, hook tokens, approval links and git
|
|
48
|
+
* push secrets all derive from this one value; rotating it invalidates every
|
|
49
|
+
* one of them together. Exported for webhook.ts, which used to fall back to
|
|
50
|
+
* a fixed dev string until the vault happened to create this file — so hook
|
|
51
|
+
* URLs were computable by anyone who read the source, and then changed the
|
|
52
|
+
* first time a secret was stored.
|
|
53
|
+
*/
|
|
54
|
+
export function installKeyMaterial() {
|
|
32
55
|
const fromEnv = process.env.FOLDRUN_SECRET_KEY;
|
|
33
56
|
if (fromEnv)
|
|
34
|
-
return
|
|
57
|
+
return fromEnv;
|
|
35
58
|
fs.mkdirSync(dataRoot(), { recursive: true });
|
|
36
59
|
if (!fs.existsSync(keyFile())) {
|
|
37
60
|
fs.writeFileSync(keyFile(), crypto.randomBytes(32).toString("hex"), { mode: 0o600 });
|
|
38
61
|
}
|
|
39
|
-
return
|
|
62
|
+
return fs.readFileSync(keyFile(), "utf8");
|
|
63
|
+
}
|
|
64
|
+
function masterKey() {
|
|
65
|
+
return crypto.createHash("sha256").update(installKeyMaterial()).digest();
|
|
40
66
|
}
|
|
41
67
|
// `workspace` undefined means the account store.
|
|
42
68
|
function secretsFile(tenant, workspace) {
|
|
@@ -50,20 +76,83 @@ function secretsFile(tenant, workspace) {
|
|
|
50
76
|
return path.join(base, "secrets.json");
|
|
51
77
|
}
|
|
52
78
|
function read(tenant, workspace) {
|
|
53
|
-
|
|
54
|
-
|
|
79
|
+
return readFile(secretsFile(tenant, workspace));
|
|
80
|
+
}
|
|
81
|
+
// ------------------------------------------------------------ safe writes
|
|
82
|
+
//
|
|
83
|
+
// A vault file is read, changed and written back. It was written in place,
|
|
84
|
+
// with no lock: two saves at once kept one; a crash mid-write left half a
|
|
85
|
+
// file, which is an account that cannot authenticate anything; and the boot
|
|
86
|
+
// re-wrap ran in every pod at once through ONE shared temp name, so two pods
|
|
87
|
+
// could interleave into it and rename a corrupt file into place.
|
|
88
|
+
//
|
|
89
|
+
// Now every change goes through mutateVaultFile: an exclusive lock file
|
|
90
|
+
// (O_EXCL, which NFS 4.1 and EFS honour, so it holds across pods), the read
|
|
91
|
+
// and the change under it, a temp file unique to this writer, and a rename —
|
|
92
|
+
// atomic on the same filesystem, so a reader sees the old file or the new
|
|
93
|
+
// one, never half. A lock older than LOCK_STALE_MS is a crashed holder.
|
|
94
|
+
const LOCK_STALE_MS = 30_000;
|
|
95
|
+
const LOCK_WAIT_MS = 10_000;
|
|
96
|
+
const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
97
|
+
function readFile(file) {
|
|
98
|
+
if (!fs.existsSync(file))
|
|
55
99
|
return {};
|
|
56
100
|
try {
|
|
57
|
-
return JSON.parse(fs.readFileSync(
|
|
101
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
58
102
|
}
|
|
59
103
|
catch {
|
|
60
104
|
return {};
|
|
61
105
|
}
|
|
62
106
|
}
|
|
63
|
-
function
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
107
|
+
function lockVault(file) {
|
|
108
|
+
const lock = `${file}.lock`;
|
|
109
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
110
|
+
for (;;) {
|
|
111
|
+
try {
|
|
112
|
+
const fd = fs.openSync(lock, "wx", 0o600);
|
|
113
|
+
fs.writeSync(fd, `${process.pid}@${new Date().toISOString()}`);
|
|
114
|
+
fs.closeSync(fd);
|
|
115
|
+
return () => fs.rmSync(lock, { force: true });
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
if (err.code !== "EEXIST")
|
|
119
|
+
throw err;
|
|
120
|
+
try {
|
|
121
|
+
if (Date.now() - fs.statSync(lock).mtimeMs > LOCK_STALE_MS) {
|
|
122
|
+
fs.rmSync(lock, { force: true }); // a holder that died; take it over
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
continue; // released between the open and the stat
|
|
128
|
+
}
|
|
129
|
+
if (Date.now() > deadline)
|
|
130
|
+
throw new Error("the vault is busy — another change is being saved; try again");
|
|
131
|
+
sleep(25);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Change one vault file safely: `change` gets what is stored now and returns
|
|
137
|
+
* the new contents, or null to leave the file untouched. Returns what is
|
|
138
|
+
* stored after.
|
|
139
|
+
*/
|
|
140
|
+
export function mutateVaultFile(file, change) {
|
|
141
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
142
|
+
const release = lockVault(file);
|
|
143
|
+
try {
|
|
144
|
+
const current = readFile(file);
|
|
145
|
+
const next = change(structuredClone(current));
|
|
146
|
+
if (next === null)
|
|
147
|
+
return current;
|
|
148
|
+
const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
|
|
149
|
+
fs.writeFileSync(tmp, JSON.stringify(next, null, 2), { mode: 0o600 });
|
|
150
|
+
fs.renameSync(tmp, file);
|
|
151
|
+
return next;
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
release();
|
|
155
|
+
}
|
|
67
156
|
}
|
|
68
157
|
export const SECRET_NAME = /^[A-Z][A-Z0-9_]{1,63}$/;
|
|
69
158
|
export function setSecret(tenant, name, value, workspace, kind) {
|
|
@@ -73,13 +162,14 @@ export function setSecret(tenant, name, value, workspace, kind) {
|
|
|
73
162
|
if (value.length > 8192)
|
|
74
163
|
throw new Error("secret too long");
|
|
75
164
|
const sealed = encryptValue(tenant, value);
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
165
|
+
mutateVaultFile(secretsFile(tenant, workspace), (all) => {
|
|
166
|
+
all[name] = {
|
|
167
|
+
...sealed,
|
|
168
|
+
updatedAt: new Date().toISOString(),
|
|
169
|
+
...(kind ? { kind } : {}),
|
|
170
|
+
};
|
|
171
|
+
return all;
|
|
172
|
+
});
|
|
83
173
|
}
|
|
84
174
|
/**
|
|
85
175
|
* Which key writes this account's records.
|
|
@@ -98,7 +188,7 @@ function keyFor(tenant) {
|
|
|
98
188
|
export function encryptValue(tenant, value) {
|
|
99
189
|
const { key, mark } = keyFor(tenant);
|
|
100
190
|
const iv = crypto.randomBytes(12);
|
|
101
|
-
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
|
|
191
|
+
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv, GCM);
|
|
102
192
|
const enc = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
|
|
103
193
|
return {
|
|
104
194
|
iv: iv.toString("base64"),
|
|
@@ -134,7 +224,7 @@ function decrypt(tenant, rec) {
|
|
|
134
224
|
else {
|
|
135
225
|
key = masterKey();
|
|
136
226
|
}
|
|
137
|
-
const decipher = crypto.createDecipheriv("aes-256-gcm", key, Buffer.from(rec.iv, "base64"));
|
|
227
|
+
const decipher = crypto.createDecipheriv("aes-256-gcm", key, Buffer.from(rec.iv, "base64"), GCM);
|
|
138
228
|
decipher.setAuthTag(Buffer.from(rec.tag, "base64"));
|
|
139
229
|
return Buffer.concat([
|
|
140
230
|
decipher.update(Buffer.from(rec.data, "base64")),
|
|
@@ -165,9 +255,12 @@ export function getSecret(tenant, name, workspace) {
|
|
|
165
255
|
return shared === null ? null : { value: shared, scope: "account" };
|
|
166
256
|
}
|
|
167
257
|
export function deleteSecret(tenant, name, workspace) {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
258
|
+
mutateVaultFile(secretsFile(tenant, workspace), (all) => {
|
|
259
|
+
if (!(name in all))
|
|
260
|
+
return null;
|
|
261
|
+
delete all[name];
|
|
262
|
+
return all;
|
|
263
|
+
});
|
|
171
264
|
}
|
|
172
265
|
// Metadata only — never values. Without a workspace this lists the account
|
|
173
266
|
// store; with one it lists what that workspace actually resolves, marking
|
|
@@ -263,47 +356,47 @@ export function rotateMasterKey(tenant, from, to) {
|
|
|
263
356
|
const rewritten = [];
|
|
264
357
|
const unreadable = [];
|
|
265
358
|
for (const { file } of secretFiles(tenant)) {
|
|
266
|
-
const all = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
267
|
-
const next = {};
|
|
268
359
|
let count = 0;
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
360
|
+
mutateVaultFile(file, (all) => {
|
|
361
|
+
const next = {};
|
|
362
|
+
for (const [name, rec] of Object.entries(all)) {
|
|
363
|
+
// A record under the ACCOUNT's key is not under the install key, so
|
|
364
|
+
// rotating the install key must not touch it — re-encrypting it here
|
|
365
|
+
// with a key it was never sealed with is how a rotation would destroy a
|
|
366
|
+
// vault it was meant to protect. Rotating the install key now re-wraps
|
|
367
|
+
// the account keys instead, which is a handful of small values rather
|
|
368
|
+
// than every secret on the box.
|
|
369
|
+
if (rec.k === "tenant") {
|
|
370
|
+
next[name] = rec;
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
let plain;
|
|
374
|
+
try {
|
|
375
|
+
const d = crypto.createDecipheriv("aes-256-gcm", oldKey, Buffer.from(rec.iv, "base64"), GCM);
|
|
376
|
+
d.setAuthTag(Buffer.from(rec.tag, "base64"));
|
|
377
|
+
plain = Buffer.concat([d.update(Buffer.from(rec.data, "base64")), d.final()]).toString("utf8");
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
unreadable.push(name);
|
|
381
|
+
next[name] = rec; // untouched
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
const iv = crypto.randomBytes(12);
|
|
385
|
+
const c = crypto.createCipheriv("aes-256-gcm", newKey, iv, GCM);
|
|
386
|
+
const enc = Buffer.concat([c.update(plain, "utf8"), c.final()]);
|
|
387
|
+
next[name] = {
|
|
388
|
+
iv: iv.toString("base64"),
|
|
389
|
+
tag: c.getAuthTag().toString("base64"),
|
|
390
|
+
data: enc.toString("base64"),
|
|
391
|
+
updatedAt: rec.updatedAt, // rotation is not an edit
|
|
392
|
+
// Kept: without it an oauth2 secret stops being refreshed after a
|
|
393
|
+
// rotation, and a file/ssh/api secret stops being materialised.
|
|
394
|
+
...(rec.kind ? { kind: rec.kind } : {}),
|
|
395
|
+
};
|
|
396
|
+
count++;
|
|
290
397
|
}
|
|
291
|
-
|
|
292
|
-
|
|
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);
|
|
398
|
+
return next;
|
|
399
|
+
});
|
|
307
400
|
rewritten.push({ file, secrets: count });
|
|
308
401
|
}
|
|
309
402
|
return { rewritten, unreadable };
|
|
@@ -347,12 +440,12 @@ async function exchange(config, cacheKey) {
|
|
|
347
440
|
client_secret: config.client_secret,
|
|
348
441
|
...(config.extra ?? {}),
|
|
349
442
|
});
|
|
350
|
-
const res = await
|
|
443
|
+
const res = await fetchUntrusted(config.token_url, {
|
|
351
444
|
method: "POST",
|
|
352
445
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
353
446
|
body: body.toString(),
|
|
354
447
|
signal: AbortSignal.timeout(15_000),
|
|
355
|
-
});
|
|
448
|
+
}, { sameOrigin: true });
|
|
356
449
|
const payload = (await res.json().catch(() => ({})));
|
|
357
450
|
if (!res.ok || !payload.access_token) {
|
|
358
451
|
throw new Error(`token refresh failed (${res.status}): ${payload.error_description ?? payload.error ?? "no access_token in the reply"}`);
|
|
@@ -376,7 +469,10 @@ async function exchange(config, cacheKey) {
|
|
|
376
469
|
* refresh throws with the secret's name and the provider's reason — a run
|
|
377
470
|
* should fail saying "GOOGLE_ADS_TOKEN: invalid_grant", not 401 somewhere.
|
|
378
471
|
*/
|
|
379
|
-
export async function materializeSecrets(env
|
|
472
|
+
export async function materializeSecrets(env,
|
|
473
|
+
/** Whose secrets these are, when the caller knows: lets the platform keep
|
|
474
|
+
* each OAuth connection's health and quote a reconnect link on failure. */
|
|
475
|
+
ctx) {
|
|
380
476
|
const out = { ...env };
|
|
381
477
|
for (const [name, value] of Object.entries(env)) {
|
|
382
478
|
if (typeof value !== "string")
|
|
@@ -399,8 +495,12 @@ export async function materializeSecrets(env) {
|
|
|
399
495
|
out[name] = await exchange(config, `${name}:${config.client_id}:${fp}`);
|
|
400
496
|
}
|
|
401
497
|
catch (err) {
|
|
402
|
-
|
|
498
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
499
|
+
const link = ctx ? await platform.noteOAuthRefresh(ctx, name, message).catch(() => undefined) : undefined;
|
|
500
|
+
throw new Error(`secret ${name}: ${message}${link ? ` — reconnect it in one click: ${link}` : ""}`);
|
|
403
501
|
}
|
|
502
|
+
if (ctx)
|
|
503
|
+
await platform.noteOAuthRefresh(ctx, name, null).catch(() => undefined);
|
|
404
504
|
}
|
|
405
505
|
else if (value.startsWith(SERVICE_ACCOUNT_PREFIX)) {
|
|
406
506
|
let config;
|
|
@@ -437,7 +537,7 @@ export function setServiceAccountSecret(tenant, name, config, workspace) {
|
|
|
437
537
|
if (!config[field]?.trim())
|
|
438
538
|
throw new Error(`service account secret needs ${field}`);
|
|
439
539
|
}
|
|
440
|
-
if (!/BEGIN [A-Z ]
|
|
540
|
+
if (!/BEGIN [A-Z ]{0,40}PRIVATE KEY/.test(config.private_key)) {
|
|
441
541
|
throw new Error("private_key must be a PEM key (from the service account JSON)");
|
|
442
542
|
}
|
|
443
543
|
setSecret(tenant, name, SERVICE_ACCOUNT_PREFIX + JSON.stringify(config), workspace, "service-account");
|
|
@@ -467,7 +567,7 @@ async function exchangeServiceAccount(config, cacheKey) {
|
|
|
467
567
|
signer.update(`${header}.${claim}`);
|
|
468
568
|
const signature = base64url(signer.sign(config.private_key));
|
|
469
569
|
const jwt = `${header}.${claim}.${signature}`;
|
|
470
|
-
const res = await
|
|
570
|
+
const res = await fetchUntrusted(config.token_url, {
|
|
471
571
|
method: "POST",
|
|
472
572
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
473
573
|
body: new URLSearchParams({
|
|
@@ -475,7 +575,7 @@ async function exchangeServiceAccount(config, cacheKey) {
|
|
|
475
575
|
assertion: jwt,
|
|
476
576
|
}).toString(),
|
|
477
577
|
signal: AbortSignal.timeout(15_000),
|
|
478
|
-
});
|
|
578
|
+
}, { sameOrigin: true });
|
|
479
579
|
const payload = (await res.json().catch(() => ({})));
|
|
480
580
|
if (!res.ok || !payload.access_token) {
|
|
481
581
|
throw new Error(`service account exchange failed (${res.status}): ${payload.error_description ?? payload.error ?? "no access_token"}`);
|
|
@@ -534,7 +634,7 @@ export function setSshSecret(tenant, name, config, workspace) {
|
|
|
534
634
|
const hasPassword = Boolean(config.password);
|
|
535
635
|
if (hasKey === hasPassword)
|
|
536
636
|
throw new Error("ssh secret needs a private key or a password (not both)");
|
|
537
|
-
if (hasKey && !/BEGIN [A-Z ]
|
|
637
|
+
if (hasKey && !/BEGIN [A-Z ]{0,40}PRIVATE KEY/.test(config.private_key)) {
|
|
538
638
|
throw new Error("private_key must be a PEM/OpenSSH private key");
|
|
539
639
|
}
|
|
540
640
|
if (hasPassword && /[\n\r]/.test(config.password))
|
|
@@ -571,7 +671,7 @@ export function setApiSecret(tenant, name, config, workspace) {
|
|
|
571
671
|
const base = config.base_url?.trim();
|
|
572
672
|
if (base && !/^https?:\/\//.test(base))
|
|
573
673
|
throw new Error("base_url must be http(s)");
|
|
574
|
-
setSecret(tenant, name, API_PREFIX + JSON.stringify({ ...(base ? { base_url: base
|
|
674
|
+
setSecret(tenant, name, API_PREFIX + JSON.stringify({ ...(base ? { base_url: trimSlashes(base) } : {}), headers: config.headers }), workspace, "api");
|
|
575
675
|
}
|
|
576
676
|
export function isApiValue(value) {
|
|
577
677
|
return value.startsWith(API_PREFIX);
|
|
@@ -594,33 +694,34 @@ export function rewrapTenantSecrets(tenant) {
|
|
|
594
694
|
let moved = 0;
|
|
595
695
|
const unreadable = [];
|
|
596
696
|
for (const { file } of secretFiles(tenant)) {
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
697
|
+
// Under the vault's lock: every pod runs this at boot, and a save made
|
|
698
|
+
// between our read and our write must not be lost.
|
|
699
|
+
mutateVaultFile(file, (all) => {
|
|
700
|
+
const plain = new Map();
|
|
701
|
+
const unreadableHere = [];
|
|
702
|
+
for (const [name, rec] of Object.entries(all)) {
|
|
703
|
+
if (rec.k === "tenant")
|
|
704
|
+
continue; // already moved
|
|
705
|
+
const value = decrypt(tenant, rec);
|
|
706
|
+
if (value === null) {
|
|
707
|
+
unreadableHere.push(name);
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
plain.set(name, value);
|
|
607
711
|
}
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
fs.writeFileSync(tmp, JSON.stringify(next, null, 2), { mode: 0o600 });
|
|
622
|
-
fs.renameSync(tmp, file);
|
|
623
|
-
moved += pending;
|
|
712
|
+
unreadable.push(...unreadableHere);
|
|
713
|
+
// Nothing to do, or something in THIS file could not be read — leave it
|
|
714
|
+
// alone. A partial conversion is worse than none, because the half that
|
|
715
|
+
// moved is no longer openable by the key the other half still needs.
|
|
716
|
+
if (plain.size === 0 || unreadableHere.length)
|
|
717
|
+
return null;
|
|
718
|
+
const next = { ...all };
|
|
719
|
+
for (const [name, value] of plain) {
|
|
720
|
+
next[name] = { ...encryptValue(tenant, value), updatedAt: all[name].updatedAt, ...(all[name].kind ? { kind: all[name].kind } : {}) };
|
|
721
|
+
}
|
|
722
|
+
moved += plain.size;
|
|
723
|
+
return next;
|
|
724
|
+
});
|
|
624
725
|
}
|
|
625
726
|
return { moved, unreadable };
|
|
626
727
|
}
|
package/dist/src/starter.d.ts
CHANGED
|
@@ -18,4 +18,13 @@ export interface StarterFile {
|
|
|
18
18
|
* workspace somewhere would be worse than no starter.
|
|
19
19
|
*/
|
|
20
20
|
export declare function accountFiles(account: string): StarterFile[];
|
|
21
|
+
/**
|
|
22
|
+
* What an account folder ignores, at its own root.
|
|
23
|
+
*
|
|
24
|
+
* The workspace scaffold has always shipped one of these; an account folder is
|
|
25
|
+
* the thing that gets committed now, and the key that decrypts its secrets
|
|
26
|
+
* lives at ITS root — `<account>/.foldrun/` — so the guard has to be here too
|
|
27
|
+
* or the first `git add .` commits the vault key.
|
|
28
|
+
*/
|
|
29
|
+
export declare function accountGitignore(): StarterFile;
|
|
21
30
|
export declare function starterFiles(workspace: string): StarterFile[];
|
package/dist/src/starter.js
CHANGED
|
@@ -64,6 +64,36 @@ dropped further down.
|
|
|
64
64
|
},
|
|
65
65
|
];
|
|
66
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* What an account folder ignores, at its own root.
|
|
69
|
+
*
|
|
70
|
+
* The workspace scaffold has always shipped one of these; an account folder is
|
|
71
|
+
* the thing that gets committed now, and the key that decrypts its secrets
|
|
72
|
+
* lives at ITS root — `<account>/.foldrun/` — so the guard has to be here too
|
|
73
|
+
* or the first `git add .` commits the vault key.
|
|
74
|
+
*/
|
|
75
|
+
export function accountGitignore() {
|
|
76
|
+
return {
|
|
77
|
+
path: ".gitignore",
|
|
78
|
+
content: `# The key that decrypts every secret in this account, plus the local run
|
|
79
|
+
# store. Never commit these.
|
|
80
|
+
.foldrun/
|
|
81
|
+
|
|
82
|
+
# Written by runs, not by you.
|
|
83
|
+
workspaces/*/runs/
|
|
84
|
+
workspaces/*/outputs/
|
|
85
|
+
state/
|
|
86
|
+
storage/
|
|
87
|
+
# The link a running step keeps in its agent folder (workspace/ for the
|
|
88
|
+
# shell); removed when the step ends, ignored in case one is left behind.
|
|
89
|
+
workspaces/*/agents/*/workspace
|
|
90
|
+
|
|
91
|
+
.env
|
|
92
|
+
.env.local
|
|
93
|
+
.DS_Store
|
|
94
|
+
`,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
67
97
|
export function starterFiles(workspace) {
|
|
68
98
|
return [
|
|
69
99
|
{
|
|
@@ -98,6 +128,9 @@ Context every agent here shares. Prices, rules, anything they should all know.
|
|
|
98
128
|
# Written by runs, not by you.
|
|
99
129
|
runs/
|
|
100
130
|
outputs/
|
|
131
|
+
# The link a running step keeps in its agent folder (workspace/ for the
|
|
132
|
+
# shell); removed when the step ends, ignored in case one is left behind.
|
|
133
|
+
agents/*/workspace
|
|
101
134
|
|
|
102
135
|
.env
|
|
103
136
|
.env.local
|
|
@@ -114,6 +147,8 @@ effort: high
|
|
|
114
147
|
tools:
|
|
115
148
|
- web
|
|
116
149
|
- read
|
|
150
|
+
web:
|
|
151
|
+
actions: [search, fetch]
|
|
117
152
|
---
|
|
118
153
|
|
|
119
154
|
Pick exactly one topic, in a short paragraph, and say who it helps and why now.
|
|
@@ -126,7 +161,7 @@ Do not write the article.
|
|
|
126
161
|
name: writer
|
|
127
162
|
description: Turns a brief into a short draft.
|
|
128
163
|
tools:
|
|
129
|
-
-
|
|
164
|
+
- write
|
|
130
165
|
---
|
|
131
166
|
|
|
132
167
|
Write a short draft from the brief you were given. Save it to outputs/draft.md.
|
|
@@ -209,6 +244,10 @@ foldrun deploy --url <server> --token $FOLDRUN_TOKEN
|
|
|
209
244
|
\`check\` is cheap and catches a broken flow before a schedule fires it at 3am.
|
|
210
245
|
Run it after edits, the way you would run a typecheck.
|
|
211
246
|
|
|
247
|
+
A deploy that would delete files this folder no longer has lists them and
|
|
248
|
+
stops: with no terminal it is refused. Tell the person what it would delete; add \`--yes\`
|
|
249
|
+
only when they asked for exactly that deletion.
|
|
250
|
+
|
|
212
251
|
## Where things go
|
|
213
252
|
|
|
214
253
|
| Path | What |
|
|
@@ -231,10 +270,23 @@ Regenerated every run means a file; accumulated across runs means \`state/\`.
|
|
|
231
270
|
That last row is worth knowing: a subagent you write in Claude Code is imported
|
|
232
271
|
as \`agents/<name>/agent.md\` at deploy, no conversion. Write it either way.
|
|
233
272
|
|
|
273
|
+
## Paths inside a workspace
|
|
274
|
+
|
|
275
|
+
An agent's working directory is \`agents/<name>/\`. Write every path to the
|
|
276
|
+
workspace's own folders as \`workspace/…\` — \`workspace/storage/report.csv\`,
|
|
277
|
+
\`workspace/state/cursor.json\` — in an agent's prose, in flow options
|
|
278
|
+
(\`each: rows of\`, \`when: rows of\`, \`verify:\`, \`preview:\`, \`schema:\`) and in
|
|
279
|
+
script arguments. It resolves the same in the file tools, the shell, scripts
|
|
280
|
+
and checks. The older \`../../storage/…\` reaches the same place and still
|
|
281
|
+
works; \`foldrun check\` suggests the new spelling where it sees it.
|
|
282
|
+
A Node script started as \`node workspace/tools/x/run.mjs\` is its own main
|
|
283
|
+
module there, so its \`import.meta.url\` entry check runs (it did not before
|
|
284
|
+
2 Oct 2026 and exited 0 doing nothing).
|
|
285
|
+
|
|
234
286
|
## What a deploy does NOT touch
|
|
235
287
|
|
|
236
|
-
\`runs/\`, \`state/\`, \`secrets.json\`,
|
|
237
|
-
push does not mention. Those belong to the platform, not to git — a deploy that
|
|
288
|
+
\`runs/\`, \`state/\`, \`storage/\`, \`secrets.json\`, the trigger log, and any
|
|
289
|
+
memory an agent wrote that your push does not mention. Those belong to the platform, not to git — a deploy that
|
|
238
290
|
reverted what an agent learned would make every run a little dumber.
|
|
239
291
|
|
|
240
292
|
Secrets never go in these files. \`foldrun secrets\` puts them in the vault, and
|
|
@@ -270,19 +322,32 @@ All of it, \`/api/workspaces/<workspace>\` unless noted:
|
|
|
270
322
|
| \`/runs/<id>/stop\` | POST | kill it — destroys the sandbox too |
|
|
271
323
|
| \`/runs/<id>/rerun\` | POST | again, or from a chosen step |
|
|
272
324
|
| \`/runs/<id>/approve\` | POST | release a run waiting on a human |
|
|
325
|
+
| \`/runs/<id>/answer\` | POST | answer a question an agent is asking mid-step (\`tools: [ask]\`) |
|
|
326
|
+
| \`/runs/<id>/message\` | POST | tell a running step something; \`step\` picks one of a parallel group |
|
|
273
327
|
| \`/flows\` · \`/flows/<f>\` | GET POST · POST DELETE PATCH | list, create, edit |
|
|
274
328
|
| \`/flows/<f>/run\` | POST | start a flow |
|
|
275
329
|
| \`/agents\` · \`/agents/<a>/run\` | GET POST · POST | list agents, run one |
|
|
276
330
|
| \`/evals\` · \`/evals/<e>/run\` | GET POST · POST | list evals, run one |
|
|
277
331
|
| \`/tools/<t>/test\` | POST | exercise one tool alone |
|
|
278
|
-
| \`/storage\` · \`/storage/download\` · \`/storage/upload-url\` | GET POST PUT DELETE · GET · POST | workspace files |
|
|
332
|
+
| \`/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 |
|
|
279
333
|
| \`/assets\` | POST | upload an asset |
|
|
280
334
|
| \`/hooks/<f>/rotate\` | POST | new webhook token for a flow |
|
|
335
|
+
| \`/notify/deliveries\` · \`/notify/deliveries/<id>/redeliver\` | GET · POST | every notify: webhook sent and each attempt; send one again |
|
|
336
|
+
| \`/restore\` | POST | the workspace's source back to a point in its history — \`dryRun\` first, then \`confirm\` |
|
|
281
337
|
| \`/vocabulary\` | GET | what this workspace's documents may say |
|
|
282
338
|
|
|
283
339
|
Account-level, outside \`/workspaces\`: \`/api/secrets\`, \`/api/keys\`, \`/api/schedule\`,
|
|
284
340
|
\`/api/approvals\`, \`/api/usage\`, \`/api/library/<kind>\`, \`/api/team\`, \`/api/billing\`,
|
|
285
|
-
\`/api/
|
|
341
|
+
\`/api/me/notifications\` (what mail you get; a key acts for whoever minted it),
|
|
342
|
+
\`/api/account/backups\` (snapshots, restore requests), \`/api/me/onboarding\` (getting started),
|
|
343
|
+
\`/api/healthz\` and \`/api/version\` (open), \`/api/changelog\` (release notes)
|
|
344
|
+
and \`/api/metrics\` (Prometheus).
|
|
345
|
+
|
|
346
|
+
Every call answers — and the CLI sends — \`Foldrun-Version: YYYY-MM-DD\` (dated API
|
|
347
|
+
versions; an account pin in Settings → API), \`X-RateLimit-*\` headers (a \`429\`
|
|
348
|
+
carries \`Retry-After\`), and writes that start work take an \`Idempotency-Key\`
|
|
349
|
+
(a retry with the same key replays the first answer). The whole surface as
|
|
350
|
+
OpenAPI 3.1: \`/api/openapi.json\` (open), or \`foldrun api spec --out openapi.json\`.
|
|
286
351
|
|
|
287
352
|
Debugging a failed run is usually: \`runs\` to find it, \`runs/<id>\` to read which
|
|
288
353
|
step failed and what it cost, fix the markdown, \`deploy\`, then \`rerun\`.
|
|
@@ -295,6 +360,11 @@ git push endpoint.
|
|
|
295
360
|
|
|
296
361
|
A numbered list of steps. The number is the GROUP: same number means those
|
|
297
362
|
steps run in parallel, and the flow waits for all of them before the next.
|
|
363
|
+
The dashboard's canvas and the CLI edit the same file: \`foldrun flow show
|
|
364
|
+
<flow>\` draws it, \`foldrun flow add <flow> <pattern>\` inserts a pattern
|
|
365
|
+
(chain, parallel, router, fan-out, loop, approval, ask, wait, rescue,
|
|
366
|
+
subflow), \`foldrun flow rm-step\` removes a step, and \`foldrun agent link
|
|
367
|
+
<agent> --subagent|--consult|--can-ask\` wires agents together.
|
|
298
368
|
|
|
299
369
|
\`\`\`markdown
|
|
300
370
|
---
|
|
@@ -310,7 +380,7 @@ priority: normal # high | normal | low — where its runs stand in the q
|
|
|
310
380
|
1. [[researcher]] — Find three sources on {{topic}}.
|
|
311
381
|
2. [[writer]] — Draft from what the researcher found.
|
|
312
382
|
model: max
|
|
313
|
-
verify: the draft cites every source
|
|
383
|
+
verify: judge: the draft cites every source
|
|
314
384
|
3?. [[editor]] — Tighten it.
|
|
315
385
|
4!. [[publisher]] — Publish it.
|
|
316
386
|
\`\`\`
|
|
@@ -323,11 +393,14 @@ Indented under a step, any of:
|
|
|
323
393
|
| | |
|
|
324
394
|
|---|---|
|
|
325
395
|
| \`approve: true\` | stop for a human (\`!\` is shorthand for this) |
|
|
326
|
-
| \`when: <
|
|
396
|
+
| \`when: <MARKER>\` | run only if a line of the previous result begins with it |
|
|
397
|
+
| \`when: rows of <path>\` | run only if that CSV has a data row — for the steps after an \`each:\` |
|
|
327
398
|
| \`case: <value>\` / \`else: true\` | branch on the previous step's result |
|
|
328
399
|
| \`retry: 2\` | re-run on failure, up to 5 |
|
|
329
400
|
| \`timeout: 900\` | seconds before the step is cut off |
|
|
330
|
-
| \`verify: <
|
|
401
|
+
| \`verify: <shell>\` | the step fails unless the command exits 0; the reply is in \`$FOLDRUN_REPLY_FILE\` |
|
|
402
|
+
| \`verify: judge: <claim>\` | a model grades the claim against the output |
|
|
403
|
+
| \`verify: contains:\` / \`not-contains:\` / \`matches:\` / \`file:\` | the cheap checks, no shell |
|
|
331
404
|
| \`model:\` / \`effort:\` | override the flow's default for this step |
|
|
332
405
|
| \`loop: 3\` / \`until: <condition>\` | repeat until it holds, at most 5 |
|
|
333
406
|
| \`each: lines\` | run once per line of the previous result |
|
|
@@ -340,7 +413,11 @@ Indented under a step, any of:
|
|
|
340
413
|
| \`preview: draft/*.mdx\` | what the approval box shows — paths under \`storage/\`, globs allowed |
|
|
341
414
|
| \`delegate: [[a]], [[b]]\` | hand the step to whichever fits, up to 5 |
|
|
342
415
|
| \`output: json\` | the reply ends with one JSON value; the next step gets it as data, \`each: items\` fans out over it |
|
|
416
|
+
| \`schema: {...}\` | with \`output: json\` — the JSON Schema the value must satisfy; inline, a YAML block under the key, or a file path |
|
|
343
417
|
| \`each: items\` | run once per element of the previous step's JSON array |
|
|
418
|
+
| \`parallel: 3\` | how many \`each:\` instances run at once — default all |
|
|
419
|
+
| \`max_turns: 20\` | the most model turns before the step is stopped, beside \`budget:\` and \`timeout:\` |
|
|
420
|
+
| \`limits: {web.search: 10}\` | this step's call limits, over the agent's key by key |
|
|
344
421
|
|
|
345
422
|
## Writing an agent
|
|
346
423
|
|
|
@@ -354,14 +431,17 @@ description: Finds and summarises sources.
|
|
|
354
431
|
model: default # fast | default | max
|
|
355
432
|
effort: low # how hard to think about it
|
|
356
433
|
size: large # small | large | heavy — the sandbox it rents
|
|
357
|
-
tools: [
|
|
434
|
+
tools: [web, read, ask, my-folder-tool] # built-ins and your own tools/, one list; \`ask\` lets it ask you mid-step
|
|
358
435
|
disallowedTools: [Bash] # subtract from what it would otherwise have
|
|
359
436
|
skills: [house-style] # from skills/
|
|
360
437
|
scripts: [summarise.py] # from scripts/, each becomes a callable tool
|
|
361
438
|
apis: [{ name: crm }] # an HTTP API, as a tool
|
|
362
439
|
mcpServers: {} # an MCP server, as a tool
|
|
363
|
-
agents: [writer] # colleagues this one may consult
|
|
364
|
-
|
|
440
|
+
agents: [writer] # colleagues this one may consult (an answer, no tools)
|
|
441
|
+
subagents: [researcher] # colleagues it may delegate a job to (own context, own tools ≤ its own)
|
|
442
|
+
secrets: [CRM_TOKEN] # keys the agent uses itself; a tool's own come with the tool
|
|
443
|
+
limits: {web.search: 40, crm: 20, calls: 300} # per step; a call past a limit is refused, never run
|
|
444
|
+
timezone: Australia/Sydney # the calendar it works to — IANA name or UTC+10
|
|
365
445
|
provider: {} # BYOK: your own model credential
|
|
366
446
|
permissionMode: plan # plan first, act once approved
|
|
367
447
|
runtime: # only if scripts need packages
|
|
@@ -374,6 +454,10 @@ You research a topic and report what you found...
|
|
|
374
454
|
Only \`name\` and the prose are required. Every other field widens or narrows
|
|
375
455
|
what the agent can reach, and the default is narrow.
|
|
376
456
|
|
|
457
|
+
\`timezone:\` is nearest-wins all the way down: the agent's own, else its
|
|
458
|
+
flow's, else the workspace's AGENTS.md, else the account's, else UTC. It sets
|
|
459
|
+
\`TZ\` and the date the agent is told, so a Sydney desk stamps the Sydney date.
|
|
460
|
+
|
|
377
461
|
\`runtime:\` installs pip/npm packages in the sandbox each agent runs in. Pin
|
|
378
462
|
versions the normal way (\`pandas>=2\`, \`lodash@^4\`). Python and Node are
|
|
379
463
|
supported; anything else should ship as a committed binary.
|