foldrun 2.4.0 → 2.6.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/bin/foldrun.mjs +11 -7
- package/docs/agents.md +68 -0
- package/docs/api.md +9 -7
- package/docs/checks.md +10 -0
- package/docs/cli.md +10 -7
- package/docs/four-jobs.md +1 -1
- package/docs/secrets.md +106 -0
- package/package.json +3 -3
- package/src/commands.mjs +176 -27
- package/src/guide.mjs +1 -1
package/bin/foldrun.mjs
CHANGED
|
@@ -183,6 +183,7 @@ const HELP = `foldrun — agents are just folders
|
|
|
183
183
|
foldrun billing card Stripe's page for saving a card for auto top-up (owner only)
|
|
184
184
|
foldrun billing credit <usd> put credit on the balance by hand (--note "…") — only on an install without Stripe (self-hosted, dev); refused once billing is live
|
|
185
185
|
foldrun gallery the platform's built-in tools (web) and whether you keep your own copy — also pull (a copy for offline runs), upgrade <tool>
|
|
186
|
+
foldrun identity what your cloud must trust for keyless identities: the issuer and each agent's subject (--to <workspace>, --json)
|
|
186
187
|
foldrun jobs the four jobs — governance, guardrail, observability, evaluation — and what each has set (--to <workspace>, --json)
|
|
187
188
|
foldrun account the account's defaults — also set <key> <value>, clear <key> (timezone, notify, budget, concurrency, deploy_block none|secrets|security, network_enforce on|off, owner, alerts <threshold> <value>, judge model|effort <value>; or <job>.<field>, e.g. governance.owner), pause [--reason] [--stop] / resume (the kill switch), telemetry [set <url> --header name=\${SECRET} | clear | test] (runs as OpenTelemetry traces), providers (--check) (singular; accounts lists logins)
|
|
188
189
|
foldrun account export everything the platform holds about the account as one JSON file (owner only; --file <path>, --force)
|
|
@@ -202,7 +203,7 @@ const HELP = `foldrun — agents are just folders
|
|
|
202
203
|
foldrun backups requests every restore asked of the platform team, and where each stands (--json)
|
|
203
204
|
foldrun secrets set NAME store a secret (prompted, never echoed) — also ls, rm, status; --kind file|ssh|api|service-account|m2m for the other shapes; --site <domain> (repeatable) binds it as it is stored
|
|
204
205
|
foldrun secrets sites NAME [site…] bind a secret to sites: the egress proxy fills it for those alone and it never enters a sandbox; no sites lists them, --clear unbinds
|
|
205
|
-
foldrun secrets clients the OAuth clients saved on the platform — also add <name> (--
|
|
206
|
+
foldrun secrets clients the OAuth clients saved on the platform — also add <name> (--authorize-url/--token-url, --client-id, --scopes, --authorize-param name=value), rm <name>
|
|
206
207
|
foldrun connect NAME OAuth sign-in from the terminal, stored as an auto-refreshing secret — --client <saved> runs it from a saved client through the platform
|
|
207
208
|
foldrun deploy [dir] push the whole account, or deploy <workspace> for one of them
|
|
208
209
|
foldrun pull [workspace] bring the platform's account down here (refuses to clobber; --force overrides)
|
|
@@ -277,7 +278,7 @@ Options
|
|
|
277
278
|
--on-fail <agent> flow add: who takes the step over when it fails
|
|
278
279
|
--off flow add: take the pattern off the step instead (fan-out, loop, approval, ask, wait, rescue)
|
|
279
280
|
--schedule "<cron>" flow trigger: the cron line a schedule fires on (5 fields, or @daily); --timezone <zone> the zone it is read in
|
|
280
|
-
--kind <k> secrets set: file (--file <path>), ssh (--host, --user, --port, --key-file or a prompted password), api (--base-url, --header "Name: value" repeatable), service-account (--file <key.json>, --scopes), m2m (--token-url, --client-id, --scopes; secret prompted)
|
|
281
|
+
--kind <k> secrets set: file (--file <path>), ssh (--host, --user, --port, --key-file or a prompted password), api (--base-url, --header "Name: value" repeatable, or --basic-user <u> with the password prompted or --basic-password), service-account (--file <key.json>, --scopes, --subject <user to act as>), client-cert (--cert-file, --key-file, --passphrase, --export to put it in the sandbox as files too), identity (--audience: keyless cloud access, no credential stored), m2m (--token-url, --client-id, --scopes; secret prompted)
|
|
281
282
|
--client <name> connect: run the consent from that OAuth client saved on the platform (secrets clients lists them)
|
|
282
283
|
--subagent <name> agent link, agent unlink: a worker it delegates to (subagents:)
|
|
283
284
|
--consult <name> agent link, agent unlink: a colleague it asks (agents:)
|
|
@@ -333,11 +334,14 @@ Options
|
|
|
333
334
|
--print open: print the URL only
|
|
334
335
|
--from <n> invoke, flow run, rerun: start at step n; earlier steps are recorded as skipped (a bulk rerun defaults to 1)
|
|
335
336
|
--no-browser login, connect, billing top-up/card/plan: print the address instead of opening it
|
|
336
|
-
--provider <name> connect: google, github, microsoft or linkedin
|
|
337
|
-
--
|
|
337
|
+
--provider <name> (deprecated — use --authorize-url, --token-url and --authorize-param) connect: google, github, microsoft or linkedin
|
|
338
|
+
--no-pkce connect, secrets clients add: leave PKCE off (only for an app with a secret whose provider refuses it)
|
|
339
|
+
--issuer <url> secrets clients add: the provider's issuer, checked against the iss it sends back (RFC 9207)
|
|
340
|
+
--scopes "<a b c>" connect: space-separated scopes (default: the provider's example; with --client, the saved client's default — the connection keeps the ones asked for)
|
|
338
341
|
--client-id / --client-secret connect: the OAuth app (prompted if omitted; secret never echoed)
|
|
339
342
|
--port <n> connect: loopback port for the redirect (default 8642 — register http://localhost:8642/callback on the app)
|
|
340
|
-
--authorize-url / --token-url connect:
|
|
343
|
+
--authorize-url / --token-url connect, secrets clients add: the provider's own OAuth addresses
|
|
344
|
+
--authorize-param name=value connect, secrets clients add: an extra authorize-URL parameter the provider documents (repeatable — e.g. access_type=offline)
|
|
341
345
|
--role <r> keys create: viewer, editor (default) or admin
|
|
342
346
|
--expires <span> keys create: when it stops working — 30d, 90d, 365d or never (default never)
|
|
343
347
|
--grace <span> keys rotate: keep the old key working for 1h or 24h while you swap the new one in (default 0: revoked now)
|
|
@@ -381,9 +385,9 @@ if (!command || command === "--help" || command === "-h") {
|
|
|
381
385
|
// `--value` as the account's argument and stored an empty secret; `--force
|
|
382
386
|
// ./dir` swallowed the directory. A flag followed by another flag is also
|
|
383
387
|
// boolean, so an unlisted switch at least does not eat its neighbour.
|
|
384
|
-
const BOOLEAN_FLAGS = new Set(["account", "follow", "force", "oauth2", "wait", "watch", "print", "dry-run", "help", "no-browser", "local", "test", "flat", "yes", "platform", "json", "forever", "all", "check", "new-client", "quiet", "off", "can-ask", "preview", "csv", "failed", "starter", "history", "list", "library", "clear", "security", "changed", "write-baseline", "no-baseline", "rules", "calls", "tree", "tool-error", "stop", "last"]);
|
|
388
|
+
const BOOLEAN_FLAGS = new Set(["account", "follow", "force", "oauth2", "wait", "watch", "print", "dry-run", "help", "no-browser", "local", "test", "flat", "yes", "platform", "json", "forever", "all", "check", "new-client", "quiet", "off", "can-ask", "preview", "csv", "failed", "starter", "history", "list", "library", "clear", "security", "changed", "write-baseline", "no-baseline", "rules", "calls", "tree", "tool-error", "stop", "last", "no-pkce", "export", "isolated"]);
|
|
385
389
|
// Flags said more than once collect into a list: `--tag a --tag b`.
|
|
386
|
-
const REPEATABLE = new Set(["tag", "expect", "header", "site", "add", "remove", "rule"]);
|
|
390
|
+
const REPEATABLE = new Set(["tag", "expect", "header", "site", "add", "remove", "rule", "authorize-param"]);
|
|
387
391
|
// `--wait` is a switch everywhere but `flow add <flow> wait`, where it takes
|
|
388
392
|
// a span: `--wait 30m`, `--wait event`. Only a value that reads as one is
|
|
389
393
|
// taken, so `invoke x --wait` and friends are as they were.
|
package/docs/agents.md
CHANGED
|
@@ -91,6 +91,7 @@ the point of the week; a step that weighs two arguments and picks one is not.
|
|
|
91
91
|
| `skills` | an allowlist. **Absent inherits every skill in scope**; `[]` withholds all of them |
|
|
92
92
|
| `agents` | colleagues it may consult mid-run — an answer, no tools |
|
|
93
93
|
| `subagents` | colleagues it may delegate a whole job to — own context, own tools, never more than its own ([below](#delegating-to-a-sub-agent)) |
|
|
94
|
+
| `sandbox` | `own` \| `parent` (default) — delegated to as a sub-agent, run each job in a sandbox of its own, with its own tools, secrets and network ([below](#isolated-sub-agents-sandbox-own)) |
|
|
94
95
|
| `scripts` | programs in `scripts/`, each becoming a callable tool |
|
|
95
96
|
| `hooks` | your own code at every tool call, step start and step end — refuse, rewrite, add context — the same on every model ([Hooks](hooks)). Also in a workspace's or the account's `AGENTS.md` for every agent under it |
|
|
96
97
|
| `apis` | an HTTP API declared inline, as one tool |
|
|
@@ -267,6 +268,73 @@ platform alike. `foldrun check` errors on a name that is not an agent and on a
|
|
|
267
268
|
sub-agent with no description, and warns when a sub-agent would hold none of
|
|
268
269
|
its tools inside the delegating agent's.
|
|
269
270
|
|
|
271
|
+
### Isolated sub-agents: `sandbox: own`
|
|
272
|
+
|
|
273
|
+
A sub-agent shares its parent's sandbox: whatever it reads, it reads where the
|
|
274
|
+
parent's secrets are. For a sub-agent that reads untrusted content — web
|
|
275
|
+
pages, mail, uploads — put it in a sandbox of its own instead. The sub-agent
|
|
276
|
+
says so in its own file:
|
|
277
|
+
|
|
278
|
+
```yaml
|
|
279
|
+
---
|
|
280
|
+
name: reader
|
|
281
|
+
description: Reads the pages it is given and summarises them.
|
|
282
|
+
tools: [web]
|
|
283
|
+
sandbox: own
|
|
284
|
+
---
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
The parent is unchanged (`subagents: [reader]`). Its model gets a
|
|
288
|
+
`delegate_reader(job)` tool in place of the SDK's `Agent` tool for this one.
|
|
289
|
+
|
|
290
|
+
| | `subagents:` | `subagents:` + `sandbox: own` |
|
|
291
|
+
|---|---|---|
|
|
292
|
+
| where it runs | inside the parent's sandbox | a sandbox of its own, started for the job |
|
|
293
|
+
| tools | its own, cut to the parent's | its own — the parent may hold less |
|
|
294
|
+
| secrets, network, size, runtime | the parent's | its own file's |
|
|
295
|
+
| gives back | its reply | its reply, and the paths of files it changed |
|
|
296
|
+
| on the run | lines in the parent's trace | a step of its own after the parent's, marked `subagentOf` |
|
|
297
|
+
| cost | on the parent's step | on its own step, in the run's total |
|
|
298
|
+
|
|
299
|
+
What holds:
|
|
300
|
+
|
|
301
|
+
- **Its file decides what it holds.** Its `tools:`, `secrets:`, `network:`,
|
|
302
|
+
`limits:`, `hooks:`, `runtime:`, `size:` and `model:` are read exactly as
|
|
303
|
+
for a step of its own. So a reader with `tools: [web]` and no secrets can
|
|
304
|
+
serve a writer with secrets and no web: a page that tells the reader to
|
|
305
|
+
send something finds nothing to send, and the writer never read the page.
|
|
306
|
+
`foldrun check` names what it holds that the parent does not
|
|
307
|
+
([`security/subagent-own-sandbox`](checks#securitysubagent-own-sandbox)).
|
|
308
|
+
- **Only the reply comes back.** The reply is still text the parent reads —
|
|
309
|
+
it can carry an instruction — but it reaches a model whose sandbox never
|
|
310
|
+
held the page.
|
|
311
|
+
- **Files.** What it writes reaches the workspace when its job ends, so the
|
|
312
|
+
next step sees it. The parent's own sandbox does not: it was copied in when
|
|
313
|
+
the parent started. The reply lists the paths. If both write one path, the
|
|
314
|
+
parent, finishing later, wins — as between two steps of a parallel group.
|
|
315
|
+
- **One level deep.** Its own `subagents:` are not offered to it.
|
|
316
|
+
- **Bounded by its parent.** A job runs within the parent step's `timeout:`
|
|
317
|
+
and under two budgets. The sub-agent's own `budget:`: a job holds what is
|
|
318
|
+
left of it while it runs, so a second job for the same agent at the same
|
|
319
|
+
moment, or one after the budget is spent, is not started and comes back as
|
|
320
|
+
failed. The flow's `budget:`: a job gets only what is neither spent nor held
|
|
321
|
+
by the steps and jobs running now — and a running step holds its whole
|
|
322
|
+
share, so in a flow with a `budget:` a job usually finds nothing left and is
|
|
323
|
+
not started. Give the sub-agent its own `budget:` and leave the flow's off,
|
|
324
|
+
or expect the job to be refused. The parent's step ends only when its jobs have. A stopped run
|
|
325
|
+
stops them.
|
|
326
|
+
- **Needs a run.** On the platform each job starts a sandbox of its own.
|
|
327
|
+
With `foldrun run` on your machine no step has a sandbox, so a job runs in
|
|
328
|
+
the same process: its own context, tools and secrets, but the same files
|
|
329
|
+
and network as the step that asked — the step's trace says so. Where
|
|
330
|
+
nothing can start a job from inside a step (an install without the egress
|
|
331
|
+
proxy), or nothing can stop one job's sandbox on its own (the local docker
|
|
332
|
+
executor), the tool is not offered and the step says so: a job whose parent
|
|
333
|
+
stopped waiting must not run on.
|
|
334
|
+
|
|
335
|
+
`foldrun agent link <parent> --subagent <reader> --isolated` writes
|
|
336
|
+
`sandbox: own` on the reader along with the link.
|
|
337
|
+
|
|
270
338
|
## Where it runs
|
|
271
339
|
|
|
272
340
|
| field | |
|
package/docs/api.md
CHANGED
|
@@ -173,7 +173,7 @@ history with each message.
|
|
|
173
173
|
| Route | Methods | |
|
|
174
174
|
|---|---|---|
|
|
175
175
|
| `/api/workspaces/<ws>/agents` | GET, POST | list — each agent's frontmatter as read, `owner` and `ownerFrom` (its own `owner:`, else the workspace's, else the account's), `lastRunAt` (when its latest step that ran started, from the runs kept; null for none) and `stale` (past `alerts: stale_days`); create (`name`) |
|
|
176
|
-
| `/api/workspaces/<ws>/agents/<agent>` | PATCH | edit one line of an agent's frontmatter in place, as the flow canvas does: `{ list: { key, name, action } }` — `key` one of `subagents`, `agents`, `tools`; `action` `add` or `remove` — or `{ set: { key: "description", value } }`, or `{ network }` — the sites its sandbox may reach: `"open"`, `"trusted"`, `"none"`, a list of sites, or `{ level, allow, defaults }`; `null` removes the line so it inherits ([Network](network)). A `network` nobody could read is a 400 that says why. Only that key's lines change — comments, key order and prose stay. Answers `{ ok, changed }`; 404 for an unknown agent. `workspace:write` |
|
|
176
|
+
| `/api/workspaces/<ws>/agents/<agent>` | PATCH | edit one line of an agent's frontmatter in place, as the flow canvas does: `{ list: { key, name, action } }` — `key` one of `subagents`, `agents`, `tools`; `action` `add` or `remove` — or `{ set: { key: "description" | "sandbox", value } }` (`sandbox` takes `own` or `parent`, else 400), or `{ network }` — the sites its sandbox may reach: `"open"`, `"trusted"`, `"none"`, a list of sites, or `{ level, allow, defaults }`; `null` removes the line so it inherits ([Network](network)). A `network` nobody could read is a 400 that says why. Only that key's lines change — comments, key order and prose stay. Answers `{ ok, changed }`; 404 for an unknown agent. `workspace:write` |
|
|
177
177
|
| `/api/workspaces/<ws>/agents/<agent>/run` | POST | run one agent alone (`task`; `test: true` for a test run) |
|
|
178
178
|
| `/api/workspaces/<ws>/agents/import` | GET, POST | copy an agent in from another workspace of the account. GET lists what could be imported (every other workspace you can read, with its agents). POST `{ from, agent, as? }` copies `agents/<agent>/` — its agent.md and own skills/, scripts/, knowledge/; never memory/, outputs/, state/ — and answers `{ name, files, warnings }`; `warnings` names tools, skills, scripts and secrets the copy uses that this workspace lacks. 409 on a name clash (pass `as`), 404 for an unknown workspace or agent, 403 when `from` is outside your key's scope. Idempotent |
|
|
179
179
|
| `/api/workspaces/<ws>/export` | GET | this workspace, one flow or one agent as a `.zip` — `?kind=flow&name=<flow>` (the flow, every flow it runs as a step, every agent those steps and their colleagues name, and the workspace tools, skills and scripts they grant), `?kind=agent&name=<agent>` (its folder and what it grants), or neither for the whole authored tree. Never memory/, state/, outputs/, storage, runs or a secret's value; `foldrun-package.json` inside says what it is. Any role that can read the workspace |
|
|
@@ -192,7 +192,7 @@ paying for the steps around it.
|
|
|
192
192
|
| Route | Methods | |
|
|
193
193
|
|---|---|---|
|
|
194
194
|
| `/api/workspaces/<ws>/runs?limit=` | GET | `{ runs: [{ id, flow, status, verdict, outcome, startedAt, finishedAt, ... }] }`; search (see [Runs](runs#searching-runs)) with `tool` (a step called a tool whose name contains it), `tool_error=1`, `agent`, `model` (contains), `min_tokens` (`50k`), `min_secs` — `limit` counts the matches — `verdict` is the word the summary leads with (`GOOD`, `QUIET`, `BLOCKED`, `BAD` or null) and `outcome` the one word every page shows (`running`, `queued`, `waiting`, `good`, `done`, `quiet`, `blocked`, `bad`, `failed`, `stopped`); `failure` is `{ agent, reason }` for a run that failed (null otherwise), `chargedUsd` is what the account was charged for the run (one credit is one cent), null until the ledger settles it, while `costUsd` is the provider's token cost — off the run index (see [Runs](runs#the-runs-list)); a finished run's steps carry `agent`, `status`, `costUsd` and `tokens` (`{ input, output, cacheRead, cacheWrite }`, see [Runs](runs#the-record)), and the run its summed `tokens` — not their reply or trace |
|
|
195
|
-
| `/api/workspaces/<ws>/runs/<id>/calls/<call>` | GET | one tool call's recorded content (see [Runs](runs#what-a-run-records)): `{ call: { id, tool, input, output, inputChars, outputChars, truncated, inputCut, outputCut } }` — what it was given and what it gave back, secrets masked, each at most 65,536 characters; `inputChars`/`outputChars` are the whole length, `inputCut`/`outputCut` say which part was cut (`truncated`: either). `<call>` is the tool event's `call`; an event with `captured: true` has content here. 404 when nothing was recorded: the workspace says `capture: metadata`, or the run is from before 19 Oct 2026. A run's steps also carry `calls`, each model call (`n`, `at`, `ms`, `model`, `stop`, `tokens`, `tools`, and `subagent` / `attempt` when a sub-agent or a later attempt made it), and the run its `revision` (the workspace's latest revision when a worker picked it up — a deploy's commit, or the id an edit through `/source` was saved as) and `drivenAt` (when that was) |
|
|
195
|
+
| `/api/workspaces/<ws>/runs/<id>/calls/<call>` | GET | one tool call's recorded content (see [Runs](runs#what-a-run-records)): `{ call: { id, tool, input, output, inputChars, outputChars, truncated, inputCut, outputCut } }` — what it was given and what it gave back, secrets masked, each at most 65,536 characters; `inputChars`/`outputChars` are the whole length, `inputCut`/`outputCut` say which part was cut (`truncated`: either). `<call>` is the tool event's `call`; an event with `captured: true` has content here. 404 when nothing was recorded: the workspace says `capture: metadata`, or the run is from before 19 Oct 2026. A run's steps also carry `calls`, each model call (`n`, `at`, `ms`, `model`, `stop`, `tokens`, `tools`, and `subagent` / `attempt` when a sub-agent or a later attempt made it); a step that handed jobs to `sandbox: own` sub-agents carries `delegations` (`id`, `agent`, `status`, `costUsd`, `ms`), and each job is a step of its own with `subagentOf` (the delegating agent), `delegation` (that `id`) and `wrote` (the workspace paths it changed); and the run its `revision` (the workspace's latest revision when a worker picked it up — a deploy's commit, or the id an edit through `/source` was saved as) and `drivenAt` (when that was) |
|
|
196
196
|
| `/api/workspaces/<ws>/runs/<id>/trace` | GET | the run as one tree (see [Runs](runs#the-trace-tree)): `{ tree, failure }`. Each span is `{ id, kind, label, start, end, status, children, … }`, where `kind` is `run`, `step`, `model` (one model call: `model`, `stop`, `tokens`), `tool` (`call`, `captured` when its input and output were recorded) or `error` (`text`); `start`/`end` are epoch milliseconds and `status` is `ok`, `error`, `running`, `waiting`, `skipped` or `pending`. A tool call sits under the model call that asked for it. `failure` is the ids from the root to the first thing that went wrong, which the run page opens on; `[]` when nothing did |
|
|
197
197
|
| `/api/workspaces/<ws>/runs/<id>/compare?with=` | GET | this run beside another of the same flow (see [Runs](runs#compare-with-the-last-good-run)): `{ comparison: { base, run, revisionChanged, changedFiles, steps }, chosen }`. Each step is `{ step, agent, base, run, changes }`: both sides' status, instruction, model, seconds, tokens, model calls, tool counts, tool errors, first errors and first line said, and `changes` in words (`status: completed → failed`, `new error: …`, `instruction changed`, `tools: +web ×3`, `took 12s → 48s`, …). `changedFiles` lists the files changed between the revisions the two ran (null when one is unknown). `with` names the other run; unasked it is the newest earlier run of the flow that finished well, else the run this one re-ran (`chosen` says which) |
|
|
198
198
|
| `/api/workspaces/<ws>/runs/<id>` | GET, DELETE | the whole run: every step, its output, cost, timing, the runner `image` it ran on and — for a step that browsed through the account's pod from slim — its `browserPod` reconnects and fallback ([runs](runs#the-record)). `?wait=true&timeout=<s>` instead blocks until the run stops (or `timeout`, default 300, max 900) and answers the verdict the run routes give — `200` completed, `500` failed with the record, `202` still going (`timedOut`) or parked on a person (`awaitingApproval`). Ask in short pieces: whatever sits in front of the platform cuts a request that stays silent too long (Cloudflare at 100 s, an AWS ALB at 60 s), and a flow can run for many minutes — the CLI's `--wait` asks 25 s at a time and comes back here on every `202` |
|
|
@@ -319,7 +319,7 @@ its header and requires a URL it can GET for itself.
|
|
|
319
319
|
| `/api/secrets?workspace=` | GET, PUT, PATCH, DELETE | the vault: list which secrets exist (never a value), each with the `sites` it is bound to; set one (`name` plus one shape, below, and optionally `sites`); bind one to sites with PATCH `{ name, sites }` — `sites` a list like `["api.example.com", "*.example.com"]`, `[]` unbinds, the value untouched, 404 for a secret that is not there, 400 for a site with a scheme, path or port and for a file, ssh or api secret ([bound to sites](secrets#bound-to-sites)); delete one (`name`). `workspace` names a workspace's own store; unsaid is the account's, which a key scoped to some workspaces may not touch. PUT is idempotent; PATCH and a PUT with `sites` are audited as `secret.sites` / `secret.set` |
|
|
320
320
|
|
|
321
321
|
`PUT` takes `name` plus exactly one shape: `value` (a string), `file` (a PEM,
|
|
322
|
-
a cert), `oauth2`, `service_account`, `ssh`, or `
|
|
322
|
+
a cert), `oauth2`, `service_account`, `ssh`, `api`, `client_cert` (`{ cert, key, passphrase?, export? }` — mutual TLS, presented by the egress proxy to the exact sites the secret is bound to), or `identity` (`{ audience }` — keyless cloud access). They go to the vault;
|
|
323
323
|
agents name them in `secrets:` and never see them in a file.
|
|
324
324
|
|
|
325
325
|
Reads return which secrets exist, never their values.
|
|
@@ -462,10 +462,12 @@ signed-in person, never by a key — mints an ordinary API key at their role.
|
|
|
462
462
|
|
|
463
463
|
| Route | Methods | |
|
|
464
464
|
|---|---|---|
|
|
465
|
-
| `/api/oauth/clients` | GET, POST, DELETE |
|
|
466
|
-
| `/api/oauth/start` | POST | begin a connection (`client`, `secret`, `workspace`) |
|
|
467
|
-
| `/api/oauth/callback` | GET | where the provider returns |
|
|
468
|
-
| `/api/
|
|
465
|
+
| `/api/oauth/clients` | GET, POST, DELETE | saved OAuth apps (`name`, `client`, `config`). `config`: `authorize_url`, `token_url`, `client_id`, `client_secret` (empty for a public app), `scopes`, optional `authorize_extra` (`{ name: value }` added to the authorize URL — the flow's own parameters are refused), `pkce` (false to turn it off), `issuer` |
|
|
466
|
+
| `/api/oauth/start` | POST | begin a connection (`client`, `secret`, `workspace`, optional `scopes`). From a signed-in browser: `{ url }` is the provider's consent, bound to that browser by a cookie the callback checks, with PKCE. With an API key: `{ url, handoff: true }` is a dashboard page where a signed-in person accepts first (`foldrun connect --client` opens it). `scopes` (space-separated, as the provider spells them) is what this connection asks for instead of the client's default, and the connection keeps it for its reconnects — one saved app serves any number of connections at different scopes |
|
|
467
|
+
| `/api/oauth/callback` | GET | where the provider returns — refuses a consent that comes back to another browser, and an `iss` that is not the app's issuer |
|
|
468
|
+
| `/api/identity?workspace=` | GET | keyless cloud identity: `{ enabled, issuer, subject, tokenSeconds }` — the issuer a cloud must trust and the subject pattern each agent's tokens carry (`account:<a>:workspace:<w>:agent:<agent>`). Open (no key), outside `/api`: `/.well-known/openid-configuration` and `/.well-known/jwks.json`, 404 when the install does not issue tokens |
|
|
469
|
+
| `/api/oauth/handoff/<id>` | POST | accept a connection started from a terminal: a signed-in person only, never a key; answers `{ url }`, the provider's consent bound to this browser. Used once, ten minutes |
|
|
470
|
+
| `/api/oauth/reconnect` | POST | reconnect a secret with the client it was connected with — no client id (`secret`, `workspace`, `provider`, optional `scopes`) → `{ url }`; it asks for `scopes` when given, else the scopes the secret was connected with, else the client's default |
|
|
469
471
|
| `/api/oauth/connections` | GET | each oauth2 secret's grant: saved client, `status` (ok, expiring, expired, failed, no-client), days left, last error |
|
|
470
472
|
|
|
471
473
|
## Triggers
|
package/docs/checks.md
CHANGED
|
@@ -261,6 +261,16 @@ is the other answer, though this rule looks at the agent, not the flow.
|
|
|
261
261
|
*info · ASI03.* A sub-agent declares tools its parent does not have. A
|
|
262
262
|
sub-agent never gets more than its parent, so they are dropped inside the
|
|
263
263
|
delegation. Grant them to the parent, or remove them from the sub-agent.
|
|
264
|
+
A sub-agent with `sandbox: own` is not this rule's: its tools are its own
|
|
265
|
+
([below](#securitysubagent-own-sandbox)).
|
|
266
|
+
|
|
267
|
+
### security/subagent-own-sandbox
|
|
268
|
+
|
|
269
|
+
*info · ASI03.* A sub-agent with `sandbox: own` holds tools its parent does
|
|
270
|
+
not. That is what `sandbox: own` is for: each job it is handed runs in a
|
|
271
|
+
sandbox of its own, with its own tools, secrets and network, and only its
|
|
272
|
+
reply reaches the parent. The finding says what it holds, so the choice
|
|
273
|
+
stays visible ([agents](agents#isolated-sub-agents-sandbox-own)).
|
|
264
274
|
|
|
265
275
|
### flow/outward-ungated
|
|
266
276
|
|
package/docs/cli.md
CHANGED
|
@@ -44,7 +44,7 @@ claude.ai or Claude Code there, nor any MCP configuration on disk.
|
|
|
44
44
|
| `foldrun extract [dir]` | move each single-file script tool — a `tools/<name>.md` with its program in a fenced block — into a folder: `tools/<name>/tool.md` with `run:` pointing at `run.js`, `run.py` or `run.sh` beside it. Frontmatter is edited as text, never re-serialised, so the diff is only what moved. A tool that already has a folder is left for you; `--dry-run` lists what it would do |
|
|
45
45
|
| `foldrun check --to <ws>` | validate the copy that is DEPLOYED, rather than the folder in front of you — a workspace edited through the API, by an agent or by `foldrun source put`, has had no way to be checked short of spending a run. The files are fetched into a temporary folder and the ordinary check runs over them, so there is one copy of the rules; the output names the workspace and the platform it checked. `--online` adds the network checks the platform runs: known vulnerabilities for exact runtime pins and remote MCP servers' tool text, and what could not be checked |
|
|
46
46
|
| `foldrun agent run <name>` | run one agent once on a platform, with no flow file: a flow of one step. `--task "…"` is the instruction and is required. Prints the run id and how to follow it; `--wait` holds on (in 25-second pieces, as `invoke` does) and then prints the same report `foldrun report` would; `--test` makes it a [test run](runs#test-runs) |
|
|
47
|
-
| `foldrun agent link <agent>` | add to an agent's team, in its own frontmatter — `--subagent <worker>` (`subagents:`, a worker it delegates to), `--consult <agent>` (`agents:`, a colleague it asks) or `--can-ask` (`ask` in `tools:`, so it may ask you mid-step). The edit is core's, the one the canvas's docks make: only the lines of that one list change, flow style stays flow style, comments and the prose stay byte for byte. A sub-agent without a `description:` needs one, because the model picks a sub-agent by it — asked for at a terminal, `--description "…"` otherwise, and written into the worker's file first. Says which flows use the agent ("used by 3 flows"), since the change reaches all of them. Prints the diff, and refuses an edit `foldrun check` would call an error; `--dry-run` writes nothing. In this folder by default (`--workspace <name>` in an account with several); `--to <workspace>` edits the deployed copy through `PATCH /api/workspaces/<ws>/agents/<name>`. In this folder it re-reads each file just before writing — after the description prompt, after `check` — and refuses, writing nothing to either file, when one changed since it was read |
|
|
47
|
+
| `foldrun agent link <agent>` | add to an agent's team, in its own frontmatter — `--subagent <worker>` (`subagents:`, a worker it delegates to), `--consult <agent>` (`agents:`, a colleague it asks) or `--can-ask` (`ask` in `tools:`, so it may ask you mid-step). The edit is core's, the one the canvas's docks make: only the lines of that one list change, flow style stays flow style, comments and the prose stay byte for byte. A sub-agent without a `description:` needs one, because the model picks a sub-agent by it — asked for at a terminal, `--description "…"` otherwise, and written into the worker's file first. `--isolated` also writes `sandbox: own` on the worker: each job it is handed runs in its own sandbox, with its own tools, secrets and network, not cut to the delegating agent's, and only its reply comes back (`foldrun docs agents`). Says which flows use the agent ("used by 3 flows"), since the change reaches all of them. Prints the diff, and refuses an edit `foldrun check` would call an error; `--dry-run` writes nothing. In this folder by default (`--workspace <name>` in an account with several); `--to <workspace>` edits the deployed copy through `PATCH /api/workspaces/<ws>/agents/<name>`. In this folder it re-reads each file just before writing — after the description prompt, after `check` — and refuses, writing nothing to either file, when one changed since it was read |
|
|
48
48
|
| `foldrun agent import <workspace>/<agent>` | copy an agent you already have in another workspace into this one: its `agent.md` and own `skills/`, `scripts/`, `knowledge/` — never `memory/`, `outputs/` or `state/`. Into the workspace you stand in, or `--workspace <name>`; `--as <name>` imports it under another name (and rewrites `name:`), which a name clash requires. `--to <workspace>` does it on the platform (`POST /api/workspaces/<ws>/agents/import`) and lists the tools, skills, scripts and secrets the copy uses that the workspace lacks. See [Importing an agent](workspaces#importing-an-agent-you-already-have) |
|
|
49
49
|
| `foldrun agent import --list` | what could be imported into a deployed workspace: every other workspace this key may read, and each one's agents with their descriptions (`GET …/agents/import`). Imports nothing. `--to <workspace>`, `--json` |
|
|
50
50
|
| `foldrun agent unlink <agent>` | take one away: `--subagent <name>`, `--consult <name>` or `--can-ask`. An emptied list is written `key: []`, not deleted. In this folder, refused the same way, nothing written, when the file changed since it was read |
|
|
@@ -96,10 +96,10 @@ claude.ai or Claude Code there, nor any MCP configuration on disk.
|
|
|
96
96
|
| `foldrun message <run-id> "…"` | say something to a running agent. It reaches the model after its next tool call, as context — it changes none of the step's tools. `--step <n>` picks one when a parallel group has several running. Platform only |
|
|
97
97
|
| `foldrun secrets set NAME` | store a secret — also `ls`, `rm`, and `status` (OAuth grant health). `--site <domain>` (repeatable, or `a,b`) binds it as it is stored |
|
|
98
98
|
| `foldrun secrets sites NAME [site…]` | bind a secret to the sites it is for: the egress proxy fills it in requests to those sites alone, and it never enters a sandbox ([bound to sites](secrets#bound-to-sites)). No sites prints what it is bound to; `--clear` unbinds. On the platform, or this machine's vault with `--local` |
|
|
99
|
-
| `foldrun secrets set NAME --kind <k>` | the other shapes the Connections form stores: `file` (`--file <path>`), `ssh` (`--host`, `--user`, `--port`, `--key-file <path>` or a prompted password), `api` (`--base-url`, `--header "Name: value"` repeatable), `service-account` (`--file <key.json>`, `--scopes`), `m2m` (a client-credentials app: `--token-url`, `--client-id`, `--scopes`, the secret prompted). On the platform, or this machine's vault with `--local` |
|
|
100
|
-
| `foldrun secrets clients` | the OAuth clients saved on the platform — what a consent runs from without typing the client again; the client secret is never shown. `clients add <name> --
|
|
101
|
-
| `foldrun connect NAME` | OAuth sign-in from the terminal, stored as an auto-refreshing secret — `--provider
|
|
102
|
-
| `foldrun connect NAME --client <saved>` | the Connections page's **Connect**: a consent from a client saved on the platform, returning to the platform's own callback, which stores the grant as NAME (`--to <workspace>` for a workspace's). Opens the browser (`--no-browser` prints the address) and waits for it to land |
|
|
99
|
+
| `foldrun secrets set NAME --kind <k>` | the other shapes the Connections form stores: `file` (`--file <path>`), `ssh` (`--host`, `--user`, `--port`, `--key-file <path>` or a prompted password), `api` (`--base-url`, `--header "Name: value"` repeatable, or `--basic-user <u>` with the password prompted or `--basic-password`), `service-account` (`--file <key.json>`, `--scopes`, `--subject <user>` to act as a user where the provider allows it), `client-cert` (`--cert-file`, `--key-file`, `--passphrase`, `--export` to put it in the sandbox as files too; bind it with `secrets sites`), `identity` (`--audience`: keyless cloud access — nothing secret is stored), `m2m` (a client-credentials app: `--token-url`, `--client-id`, `--scopes`, the secret prompted). On the platform, or this machine's vault with `--local` |
|
|
100
|
+
| `foldrun secrets clients` | the OAuth clients saved on the platform — what a consent runs from without typing the client again; the client secret is never shown. `clients add <name> --authorize-url … --token-url … --client-id … --scopes …` saves one (secret prompted), with `--authorize-param name=value` for anything else the provider's authorize URL takes, `clients rm <name>` removes it |
|
|
101
|
+
| `foldrun connect NAME` | OAuth sign-in from the terminal, stored as an auto-refreshing secret — `--authorize-url` and `--token-url` from the provider's OAuth docs, `--authorize-param name=value` for its extras; PKCE is on (`--no-pkce` only for an app with a secret whose provider refuses it), and an app with no client secret is a public app; reconnecting a secret whose client is saved asks for nothing (`--new-client` to enter one) |
|
|
102
|
+
| `foldrun connect NAME --client <saved>` | the Connections page's **Connect**: a consent from a client saved on the platform (`--scopes "<a b>"` asks for this connection's own scopes instead of the client's default; the connection keeps them). It opens a page in the dashboard where you, signed in, accept the connection before the provider is asked, returning to the platform's own callback, which stores the grant as NAME (`--to <workspace>` for a workspace's). Opens the browser (`--no-browser` prints the address) and waits for it to land |
|
|
103
103
|
| `foldrun deploy [dir]` | push this account into an installation — every workspace and the shared library. `foldrun deploy <workspace>` pushes one. Never removes a workspace the platform has and the folder does not. It first prints where it is going and as whom (`deploying to <url> as <profile> (<email>)`); a deploy that would delete files the folder no longer has lists them and asks — what runs wrote (`storage/`, `state/`, the trigger log) is never deleted, so never listed. The confirmed list goes with the deploy; if the platform would now remove anything else it refuses and the CLI asks again. With no terminal (a script, a CI job, a coding agent) it refuses unless `--yes`. A deploy that only adds or changes files asks nothing |
|
|
104
104
|
| `foldrun help <command>` | that command's usage and options. `foldrun <command> --help` and `-h` print the same and never run the command |
|
|
105
105
|
| `foldrun pull [workspace]` | bring the platform's account down into this folder — refuses to overwrite a locally edited file, and names every one it would have taken (`--force` takes them) |
|
|
@@ -142,6 +142,7 @@ claude.ai or Claude Code there, nor any MCP configuration on disk.
|
|
|
142
142
|
| `foldrun billing top-up <usd>` | the Payment page's top-up: Stripe's checkout page for that amount, opened in the browser (`--no-browser`, or no terminal, prints it). The credit lands when Stripe confirms the payment. `billing card` is the same for saving a card for auto top-up. Both need `billing:manage` |
|
|
143
143
|
| `foldrun billing credit <usd>` | put credit on the balance by hand (`POST /api/billing { usd, note }`, `--note "…"` on the ledger line) — only on an install without Stripe, a self-hosted or dev platform; once billing is live the platform refuses it and top-ups go through `billing top-up`. The dashboard's Billing → Add credit does the same while billing is off. Needs `billing:manage` |
|
|
144
144
|
| `foldrun gallery` | the tools the platform ships to every account (`web`), and for each whether this account keeps its own copy and whether that copy still matches. `pull` lays the shelf down under `~/.foldrun/gallery/<host>/` for offline runs (`foldrun run` and `foldrun eval` refresh it themselves before each run); `upgrade <tool>` replaces the account's copy with the current gallery version, the old one a revision back |
|
|
145
|
+
| `foldrun identity` | keyless cloud identity: the issuer your cloud must trust and the subject each agent's tokens carry (`GET /api/identity`); `--to <workspace>` fills the workspace in, `--json` |
|
|
145
146
|
| `foldrun jobs` | the four jobs — governance, guardrail, observability, evaluation — and every `AGENTS.md` field of each, with what is set (`GET /api/account/jobs`). `--to <workspace>` shows the workspace's value beside the account's and how the two combine; `--json` the document. `account set <job>.<field>` and `workspace set <job>.<field>` refuse a field that scope cannot set and name the `AGENTS.md` to write it in. See [The four jobs](four-jobs) |
|
|
146
147
|
| `foldrun account` | the account's own defaults — the AGENTS.md frontmatter every workspace under it inherits: `timezone`, `notify`, `budget`, `concurrency`, `deploy_block`, `network_enforce`. `foldrun account set timezone Australia/Sydney`, `set notify email you@example.com` (`--events failed,awaiting-approval,completed`), `set notify url https://…`, `set budget 60/month`, `set concurrency 4`, `set deploy_block secrets` (`none`, `secrets` or `security` — what the scanner's findings do to a deploy), `set network_enforce on` (every workspace refuses a site off an agent's `network:` list; `off` reports only), `set owner <email or name>`, `set alerts <threshold> <value>` (merged into what is set; `off` removes one), `set judge model|effort <value>`. Any setting may be named by its job — `set governance.deploy_block secrets`, `set observability.alerts stale_days 30` — and one named under the wrong job is refused with the right one, `pause` (`--reason "…"`, `--stop` to also stop every run going now — asks, or `--yes`) and `resume`: the kill switch, no new run starts in any workspace while paused, `telemetry` — where each finished run goes as an OpenTelemetry trace: bare shows it, `set <url> --header authorization='${OTEL_TOKEN}'` (repeatable; a value is a vault secret's name), `clear`, `test` (one made-up run, and what the collector answered; exit 1 when not delivered), and `clear <key>` to unset one. `set notify` merges against what is there, because the platform replaces the whole block. `providers` lists the model providers the account's files use and the last daily key check — `--check` asks now, and a dead key is a non-zero exit. `model` is the account's own model key, what every step runs on unless a `provider:` block names another: `foldrun account model` shows it (never the key, only its last four; exit 1 when there is none and runs are refused), `model set <provider> --key <api key>` sets it — a preset name (anthropic, openrouter, openai, groq, …) or `custom --base-url https://… --format anthropic|openai`, `--key -` reads the key from stdin — and `model remove` removes it; a Claude login token is refused for any account not allowed the platform's own credential. `export` downloads everything the platform holds about the account — workspaces, runs, members and the whole ledger — as one JSON file (owner only; `--file <path>`, `--force`). Singular — `accounts` below is the list of logins on this machine |
|
|
147
148
|
| `foldrun workspace` | one deployed workspace's own settings (`--to <workspace>`): description, timezone, budget and notify, read from its `AGENTS.md` — unset means the account's. `set timezone <tz>`, `set budget 20/week`, `set notify email <address>` / `set notify url <https://…>` (`--events`; merged with what the file says), `set network_enforce on` (refuse a site off an agent's `network:` list here; `off` or `clear` goes back to the account's), `set owner`, `set alerts <threshold> <value>`, `set judge model|effort <value>`, and `<job>.<field>` as on `account`, `pause` (`--reason`, `--stop`) and `resume` — this workspace's kill switch, `set description "…"`, `set name <new>` — a rename changes every webhook URL, so it asks first and names the flows whose URLs changed — and `clear <key>`. Singular — `workspaces` is the list |
|
|
@@ -187,7 +188,7 @@ claude.ai or Claude Code there, nor any MCP configuration on disk.
|
|
|
187
188
|
| `--schedule "<cron>"`, `--timezone <zone>` | `flow trigger schedule`: the cron line (5 fields, or `@daily`) and the zone it is read in |
|
|
188
189
|
| `--kind <k>` | `secrets set`: `file`, `ssh`, `api`, `service-account` or `m2m` — each with its own flags, in the `secrets set NAME --kind` row above |
|
|
189
190
|
| `--client <name>` | `connect`: run the consent from that OAuth client saved on the platform (`secrets clients` lists them) |
|
|
190
|
-
| `--subagent`, `--consult`, `--can-ask`, `--description` | `agent link`, `agent unlink`: which list to edit,
|
|
191
|
+
| `--subagent`, `--consult`, `--can-ask`, `--description`, `--isolated` | `agent link`, `agent unlink`: which list to edit, a worker's description when it has none, and (`link --subagent` only) `sandbox: own` on the worker |
|
|
191
192
|
| `--eval <name>`, `--expect "<line>"`, `--case <name>` | `promote`: the eval file, an assertion (repeatable), the case's name |
|
|
192
193
|
| `--tag <t>` | `invoke`, `flow run`: a label on the run — repeatable |
|
|
193
194
|
| `--inputs <name>` | `invoke`, `flow run`: the task is that saved input set; not with `--task` |
|
|
@@ -216,7 +217,9 @@ claude.ai or Claude Code there, nor any MCP configuration on disk.
|
|
|
216
217
|
| `--wait` | `invoke`, `agent run`: hold on and print the result. Asked in 25-second pieces, so a flow that runs for many minutes is never cut by whatever sits in front of the platform; a run parked on a person prints its page and exits 2 |
|
|
217
218
|
| `--from <n>` | `invoke`, `flow run`, `rerun`: start at step n; earlier steps are recorded as skipped. A bulk `rerun` defaults to 1. (One flag, two meanings: a template on `init`, a step everywhere else) |
|
|
218
219
|
| `--no-browser` | `login`, `connect`: print the address instead of opening it |
|
|
219
|
-
| `--
|
|
220
|
+
| `--authorize-url` / `--token-url` | `connect`, `secrets clients add`: the provider's own OAuth addresses |
|
|
221
|
+
| `--authorize-param name=value` | `connect`, `secrets clients add`: an extra authorize-URL parameter the provider documents, repeatable — Google wants `access_type=offline` and `prompt=consent` before it issues a refresh token. The flow's own parameters (`client_id`, `redirect_uri`, `state`, `scope`, PKCE) cannot be set this way |
|
|
222
|
+
| `--provider <name>` | (deprecated — use `--authorize-url`, `--token-url` and `--authorize-param`) `connect`, `secrets clients add`: filled the URLs for google, github, microsoft or linkedin |
|
|
220
223
|
| `--scopes "<a b c>"` | `connect`: space-separated, as OAuth defines it |
|
|
221
224
|
| `--client-id` / `--client-secret` | `connect`: the OAuth app; prompted when omitted, the secret never echoed |
|
|
222
225
|
| `--port <n>` | `connect`: the loopback port (default 8642) |
|
package/docs/four-jobs.md
CHANGED
|
@@ -38,7 +38,7 @@ top. Either way works, and the two can be mixed in one file.
|
|
|
38
38
|
| File | `governance:` | `guardrail:` | `observability:` | `evaluation:` |
|
|
39
39
|
|---|---|---|---|---|
|
|
40
40
|
| `AGENTS.md` | `owner`, `budget`, `priority`, `concurrency`, `deploy_block` | `limits`, `network`, `network_enforce`, `hooks` | `alerts`, `telemetry`, `notify`, `capture` | `judge` |
|
|
41
|
-
| an agent | `owner`, `budget` | `limits`, `network`, `hooks`, `disallowedTools`, `permissionMode`, `secrets` | — | — |
|
|
41
|
+
| an agent | `owner`, `budget` | `limits`, `network`, `hooks`, `disallowedTools`, `permissionMode`, `secrets`, `sandbox` | — | — |
|
|
42
42
|
| a flow | `owner`, `approvers`, `approve_within`, `budget`, `priority` | `throttle`, `debounce`, `overlap`, `disable_after`, `live` | `issues`, `sla` | — |
|
|
43
43
|
|
|
44
44
|
A field that does none of the four, such as `name`, `model`, `tools`,
|
package/docs/secrets.md
CHANGED
|
@@ -30,9 +30,36 @@ PUT /api/secrets { "name": "STRIPE_KEY", "value": "sk_live_…" }
|
|
|
30
30
|
| `file` | a PEM, a certificate, a service-account JSON |
|
|
31
31
|
| `oauth2` | a connected third-party account (see below) |
|
|
32
32
|
| `service_account`, `ssh`, `api` | structured credentials the platform knows how to present |
|
|
33
|
+
| `client_cert` | a certificate and key for mutual TLS, presented by the egress proxy |
|
|
34
|
+
| `identity` | keyless cloud access: an audience, and tokens the platform signs (below) |
|
|
33
35
|
|
|
34
36
|
Reads return which secrets exist, never their values.
|
|
35
37
|
|
|
38
|
+
A **service account** is a key file that signs its own token request. It
|
|
39
|
+
acts as itself, or — with **Act as** (`subject`) — as a user the provider
|
|
40
|
+
lets it act for (RFC 7523's grant subject; for Google, domain-wide
|
|
41
|
+
delegation, which a Workspace admin grants in their own console). Each
|
|
42
|
+
subject gets its own token.
|
|
43
|
+
|
|
44
|
+
An **`api`** connection is a base URL and the headers that go with it. For
|
|
45
|
+
an old system that wants **HTTP Basic**, give a username and password
|
|
46
|
+
instead of an `Authorization` header: the platform builds the header (RFC
|
|
47
|
+
7617, UTF-8), refuses a colon in the username, needs an https base URL, and
|
|
48
|
+
masks the password and the encoded header in every trace — as it does every
|
|
49
|
+
header value of an `api` connection.
|
|
50
|
+
|
|
51
|
+
A **client certificate** (mutual TLS) is a PEM certificate, its key and an
|
|
52
|
+
optional passphrase; the key must belong to the certificate or it is
|
|
53
|
+
refused. Bind it to the **exact hosts** it is for (`foldrun secrets sites
|
|
54
|
+
NAME api.bank.example` — no wildcards): the egress proxy presents it on its
|
|
55
|
+
own connection to those hosts, and the sandbox never holds the key. Two
|
|
56
|
+
certificates bound to one host is refused, not guessed. A redirect to
|
|
57
|
+
another site never carries it. Unbound, it stays out of the sandbox; save it
|
|
58
|
+
with **export** only for a step that runs without the egress proxy, and it
|
|
59
|
+
arrives as files (`$NAME_CERT`, `$NAME_KEY`, `$NAME_PASSPHRASE_FILE`). This
|
|
60
|
+
is client authentication with a certificate — not OAuth's certificate-bound
|
|
61
|
+
tokens (RFC 8705), which foldrun does not do.
|
|
62
|
+
|
|
36
63
|
## Scopes
|
|
37
64
|
|
|
38
65
|
A secret lives at the **account** or in one **workspace**. An agent looks in
|
|
@@ -173,6 +200,85 @@ anything OAuth. Settings → Connections walks the consent flow once and stores
|
|
|
173
200
|
the result as an `oauth2` secret; the platform refreshes it. An agent names
|
|
174
201
|
it in `secrets:` like any other.
|
|
175
202
|
|
|
203
|
+
You bring the OAuth app: register it once with the provider (its client id
|
|
204
|
+
and secret, and the redirect URL the form shows), save it, and connect. The
|
|
205
|
+
form asks for the provider's own authorize and token URLs — foldrun keeps no
|
|
206
|
+
list of providers — and **Extra authorize parameters**, one `name=value` per
|
|
207
|
+
line, for whatever else the provider's docs say its authorize URL takes:
|
|
208
|
+
Google issues a refresh token only with `access_type=offline` and
|
|
209
|
+
`prompt=consent`, and without one the connection stops when the first access
|
|
210
|
+
token expires. The flow's own parameters (`client_id`, `redirect_uri`,
|
|
211
|
+
`state`, `scope`, PKCE) cannot be set there. Every
|
|
212
|
+
later connection can use the same saved app — choose it under **App** and
|
|
213
|
+
type the scopes this connection needs, as the provider spells them. Each
|
|
214
|
+
connection keeps its own scopes, so one app serves any number of APIs from
|
|
215
|
+
the same provider (an ads API in one secret, a search API in another), and
|
|
216
|
+
**Reconnect** asks for that connection's scopes again. The provider decides
|
|
217
|
+
what the app may ask for; foldrun only carries the words. From a terminal:
|
|
218
|
+
`foldrun connect NAME --client <saved app> --scopes "<scopes>" --to <workspace>`.
|
|
219
|
+
|
|
220
|
+
How a connection is kept safe, whatever the provider:
|
|
221
|
+
|
|
222
|
+
- **PKCE** (S256) is on for every connection: a code stolen on its way back
|
|
223
|
+
is useless without the verifier only foldrun holds. An app with no client
|
|
224
|
+
secret (a *public* app) is allowed — PKCE is then what protects it, and
|
|
225
|
+
its refreshes send no secret. Turn PKCE off only for an app with a secret
|
|
226
|
+
whose provider refuses the parameters.
|
|
227
|
+
- **The browser that starts a connection is the one it must come back to.**
|
|
228
|
+
The consent link carries a cookie the callback checks; a link opened in
|
|
229
|
+
someone else's browser connects nothing.
|
|
230
|
+
- **From a terminal**, `foldrun connect --client` opens a page in the
|
|
231
|
+
dashboard where you, signed in, see the secret, the app, the scopes and
|
|
232
|
+
where it will be stored, and accept before the provider is asked.
|
|
233
|
+
- **Issuer.** Give a saved app its provider's issuer and the `iss` the
|
|
234
|
+
provider sends back (RFC 9207) must match it; without one, a returned
|
|
235
|
+
`iss` must at least be the authorize URL's own site.
|
|
236
|
+
- The flow's own parameters (`client_id`, `redirect_uri`, `scope`, `state`,
|
|
237
|
+
the PKCE pair) cannot be overridden by extra parameters or by a query
|
|
238
|
+
string in the authorize URL.
|
|
239
|
+
- A provider that **rotates refresh tokens** sends a new one with each
|
|
240
|
+
refresh; it replaces the old one in the vault, where that secret is kept.
|
|
241
|
+
|
|
242
|
+
## Keyless cloud identity
|
|
243
|
+
|
|
244
|
+
The safest credential is none at all. A cloud that supports workload
|
|
245
|
+
identity federation — AWS (`AssumeRoleWithWebIdentity`), Google Cloud
|
|
246
|
+
(Workload Identity Federation), Microsoft Entra (federated credentials) —
|
|
247
|
+
can trust **foldrun's signature** instead of a key you store: a step proves
|
|
248
|
+
which account, workspace and agent it is, and the cloud gives it a
|
|
249
|
+
short-lived credential of its own.
|
|
250
|
+
|
|
251
|
+
1. In your cloud, trust this install as an OIDC provider: its issuer is
|
|
252
|
+
shown by `foldrun identity` (and in the Connections form). Pin the
|
|
253
|
+
subject — `account:<account>:workspace:<workspace>:agent:<agent>` — at
|
|
254
|
+
least to your account, and the audience you choose.
|
|
255
|
+
2. `foldrun secrets set AWS_ID --kind identity --audience sts.amazonaws.com`
|
|
256
|
+
(or Connections → Identity). Nothing secret is stored: only the audience.
|
|
257
|
+
3. Name it in an agent's `secrets:`. The step gets **`$AWS_ID_FILE`**: a
|
|
258
|
+
token signed RS256, valid ten minutes, renewed while the step runs. Point
|
|
259
|
+
your cloud's SDK at it — for AWS, `AWS_ROLE_ARN` and
|
|
260
|
+
`AWS_WEB_IDENTITY_TOKEN_FILE=$AWS_ID_FILE`; for Google, an
|
|
261
|
+
`external_account` credential file whose `credential_source.file` is it;
|
|
262
|
+
for Entra, the token as the client assertion.
|
|
263
|
+
|
|
264
|
+
What it is and is not:
|
|
265
|
+
|
|
266
|
+
- A **workload-federation issuer**, not a sign-in provider: discovery
|
|
267
|
+
publishes what federation reads (issuer, JWKS, RS256, public subjects),
|
|
268
|
+
and nothing more.
|
|
269
|
+
- The subject and claims come from the platform's own record of the run,
|
|
270
|
+
never from the sandbox. A token is issued only to the step the identity
|
|
271
|
+
was granted to, only while its driver checks in, and never after the step
|
|
272
|
+
stops or ends.
|
|
273
|
+
- Stopping a step stops renewal; it cannot recall a token already issued,
|
|
274
|
+
which expires within ten minutes on its own.
|
|
275
|
+
- Within an account, a workspace or agent recreated under the same name
|
|
276
|
+
is the same subject: pin your trust policy to the account at least.
|
|
277
|
+
- Who may run or edit an agent that names the identity may use it — as
|
|
278
|
+
with every secret.
|
|
279
|
+
- Not on a laptop: only a sandboxed step on a platform whose operator set
|
|
280
|
+
`FOLDRUN_OIDC_ISSUER` gets a token.
|
|
281
|
+
|
|
176
282
|
## How they are kept
|
|
177
283
|
|
|
178
284
|
Every account has its own encryption key. Secrets are sealed under it, and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "foldrun",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.0",
|
|
4
4
|
"description": "Agents are just folders. Write them, check them, run them — on your machine.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"type": "module",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"node": ">=22"
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@foldrun/core": "^2.
|
|
14
|
+
"@foldrun/core": "^2.8.0"
|
|
15
15
|
},
|
|
16
16
|
"files": [
|
|
17
17
|
"bin",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
],
|
|
22
22
|
"scripts": {
|
|
23
23
|
"e2e": "FOLDRUN_E2E=1 FOLDRUN_DATA=$(mktemp -d /tmp/foldrun-e2e-XXXXXX) node --test --test-timeout=300000 tests/e2e.test.ts",
|
|
24
|
-
"test": "FOLDRUN_DATA=$(mktemp -d /tmp/foldrun-test-XXXXXX) node --test tests/cli-account.test.ts tests/model-credential.test.ts tests/cli-approvals.test.ts tests/cli-issues.test.ts tests/cli-capture.test.ts tests/cli-stop.test.ts tests/cli-account-settings.test.ts tests/cli-schedule.test.ts tests/cli-billing.test.ts tests/cli-storage.test.ts tests/cli-scaffold.test.ts tests/cli-secrets.test.ts tests/cli-site-login.test.ts tests/cli-check-library.test.ts tests/cli-check-timezone.test.ts tests/cli-check-frontmatter.test.ts tests/cli-agent-import.test.ts tests/cli-check-subagents.test.ts tests/cli-connect.test.ts tests/cli-accounts.test.ts tests/cli-remote.test.ts tests/cli-stream.test.ts tests/cli-doctor.test.ts tests/cli-tool-test.test.ts tests/cli-agent-run.test.ts tests/cli-check-remote.test.ts tests/cli-shares.test.ts tests/cli-triggers.test.ts tests/cli-providers.test.ts tests/cli-rerun-put.test.ts tests/cli-guide.test.ts tests/cli-runtimes.test.ts tests/cli-deploy-safety.test.ts tests/cli-operator.test.ts tests/cli-flow-patterns.test.ts tests/cli-flow-rm-step.test.ts tests/cli-flow-dup-step.test.ts tests/cli-flow-write-stale.test.ts tests/cli-agent-link-stale.test.ts tests/cli-parity.test.ts tests/cli-invoke-inputs.test.ts tests/cli-audit-keys.test.ts tests/cli-version.test.ts tests/cli-check-web-warnings.test.ts tests/cli-report-image.test.ts tests/cli-report-cache.test.ts tests/cli-check-workspace-paths.test.ts tests/cli-parity-dashboard.test.ts tests/cli-api-standards.test.ts tests/cli-backups-onboarding.test.ts tests/cli-check-limits.test.ts tests/cli-gallery.test.ts tests/cli-status-platform.test.ts tests/cli-webhooks-notifications.test.ts tests/cli-flags.test.ts tests/cli-api-coverage.test.ts tests/cli-hooks.test.ts tests/cli-release.test.ts tests/cli-terminal.test.ts tests/cli-network.test.ts tests/cli-assistant.test.ts tests/cli-check-scan.test.ts tests/cli-published.test.ts tests/cli-init-target.test.ts",
|
|
24
|
+
"test": "FOLDRUN_DATA=$(mktemp -d /tmp/foldrun-test-XXXXXX) node --test tests/cli-account.test.ts tests/model-credential.test.ts tests/cli-approvals.test.ts tests/cli-issues.test.ts tests/cli-capture.test.ts tests/cli-stop.test.ts tests/cli-account-settings.test.ts tests/cli-schedule.test.ts tests/cli-billing.test.ts tests/cli-storage.test.ts tests/cli-scaffold.test.ts tests/cli-secrets.test.ts tests/cli-site-login.test.ts tests/cli-check-library.test.ts tests/cli-check-timezone.test.ts tests/cli-check-frontmatter.test.ts tests/cli-agent-import.test.ts tests/cli-check-subagents.test.ts tests/cli-connect.test.ts tests/cli-accounts.test.ts tests/cli-remote.test.ts tests/cli-stream.test.ts tests/cli-doctor.test.ts tests/cli-tool-test.test.ts tests/cli-agent-run.test.ts tests/cli-check-remote.test.ts tests/cli-shares.test.ts tests/cli-triggers.test.ts tests/cli-providers.test.ts tests/cli-rerun-put.test.ts tests/cli-guide.test.ts tests/cli-runtimes.test.ts tests/cli-deploy-safety.test.ts tests/cli-operator.test.ts tests/cli-flow-patterns.test.ts tests/cli-flow-rm-step.test.ts tests/cli-flow-dup-step.test.ts tests/cli-flow-write-stale.test.ts tests/cli-agent-link-stale.test.ts tests/cli-agent-link-isolated.test.ts tests/cli-parity.test.ts tests/cli-invoke-inputs.test.ts tests/cli-audit-keys.test.ts tests/cli-version.test.ts tests/cli-check-web-warnings.test.ts tests/cli-report-image.test.ts tests/cli-report-cache.test.ts tests/cli-check-workspace-paths.test.ts tests/cli-parity-dashboard.test.ts tests/cli-api-standards.test.ts tests/cli-backups-onboarding.test.ts tests/cli-check-limits.test.ts tests/cli-gallery.test.ts tests/cli-status-platform.test.ts tests/cli-webhooks-notifications.test.ts tests/cli-flags.test.ts tests/cli-api-coverage.test.ts tests/cli-hooks.test.ts tests/cli-release.test.ts tests/cli-terminal.test.ts tests/cli-network.test.ts tests/cli-assistant.test.ts tests/cli-check-scan.test.ts tests/cli-published.test.ts tests/cli-init-target.test.ts",
|
|
25
25
|
"release": "node scripts/release.mjs",
|
|
26
26
|
"typecheck": "tsc -p tsconfig.json",
|
|
27
27
|
"docs:sync": "node scripts/sync-docs.mjs",
|
package/src/commands.mjs
CHANGED
|
@@ -1340,7 +1340,8 @@ async function checkProblems(workspace, flags = {}) {
|
|
|
1340
1340
|
note("warn", `agents/${a.name}`, `subagents: [${s}] — an agent delegating to itself; it is skipped at run time`);
|
|
1341
1341
|
} else if (!sub.description?.trim()) {
|
|
1342
1342
|
note("error", `agents/${s}`, `no description — ${a.name} delegates to it (subagents:), and the model picks a sub-agent by its description`);
|
|
1343
|
-
} else if (sub.tools.length && !checkOverlap(a.tools, sub.tools).length) {
|
|
1343
|
+
} else if (!ownSandboxOf(sub) && sub.tools.length && !checkOverlap(a.tools, sub.tools).length) {
|
|
1344
|
+
// sandbox: own — its tools are its own, not cut to the parent's.
|
|
1344
1345
|
note("warn", `agents/${a.name}`, `subagents: [${s}] — none of ${s}'s tools are in ${a.name}'s, and a sub-agent never gets more than its parent: it would have no tools`);
|
|
1345
1346
|
}
|
|
1346
1347
|
}
|
|
@@ -3609,13 +3610,38 @@ const CONNECT_PORT = 8642;
|
|
|
3609
3610
|
* a provider that issues none (GitHub, most LinkedIn apps) gets its access
|
|
3610
3611
|
* token stored as a static value and the expiry is said out loud.
|
|
3611
3612
|
*/
|
|
3613
|
+
/**
|
|
3614
|
+
* `--authorize-param name=value`, repeatable: extra query parameters on the
|
|
3615
|
+
* provider's authorize URL — what a provider wants beyond OAuth's own
|
|
3616
|
+
* (Google's access_type=offline and prompt=consent, for a refresh token).
|
|
3617
|
+
* foldrun knows no provider; the person types what theirs documents.
|
|
3618
|
+
*/
|
|
3619
|
+
function authorizeParams(flags) {
|
|
3620
|
+
const out = {};
|
|
3621
|
+
for (const p of [flags["authorize-param"]].flat().filter((x) => x !== undefined)) {
|
|
3622
|
+
const i = typeof p === "string" ? p.indexOf("=") : -1;
|
|
3623
|
+
if (i < 1) throw new Error(`--authorize-param takes name=value, got "${p}"`);
|
|
3624
|
+
out[p.slice(0, i).trim()] = p.slice(i + 1).trim();
|
|
3625
|
+
}
|
|
3626
|
+
return out;
|
|
3627
|
+
}
|
|
3628
|
+
|
|
3629
|
+
/** --provider is deprecated: a runner keeps no provider list. Said once,
|
|
3630
|
+
* on stderr, so a script reading stdout is not disturbed. */
|
|
3631
|
+
function warnProviderDeprecated(flags) {
|
|
3632
|
+
if (flags.provider !== undefined) {
|
|
3633
|
+
console.error(c.dim(" --provider is deprecated — pass the provider's own --authorize-url and --token-url, and any --authorize-param it documents"));
|
|
3634
|
+
}
|
|
3635
|
+
}
|
|
3636
|
+
|
|
3612
3637
|
async function connect(positional, flags) {
|
|
3613
3638
|
const name = positional[0];
|
|
3614
|
-
if (!name) throw new Error("usage: foldrun connect NAME
|
|
3639
|
+
if (!name) throw new Error("usage: foldrun connect NAME --authorize-url <url> --token-url <url> [--authorize-param name=value …] [--scopes …] [--workspace <name>] [--new-client]");
|
|
3615
3640
|
if (!/^[A-Z][A-Z0-9_]*$/.test(name)) throw new Error(`secret name "${name}" must be UPPER_SNAKE_CASE`);
|
|
3616
3641
|
const { OAUTH_PRESETS } = await core();
|
|
3617
3642
|
|
|
3618
3643
|
const providerName = flags.provider;
|
|
3644
|
+
warnProviderDeprecated(flags);
|
|
3619
3645
|
const preset = providerName ? OAUTH_PRESETS[providerName] : undefined;
|
|
3620
3646
|
if (providerName && !preset) {
|
|
3621
3647
|
throw new Error(`unknown provider "${providerName}" — one of ${Object.keys(OAUTH_PRESETS).join(", ")}, or pass --authorize-url and --token-url`);
|
|
@@ -3668,14 +3694,18 @@ async function connect(positional, flags) {
|
|
|
3668
3694
|
}
|
|
3669
3695
|
}
|
|
3670
3696
|
|
|
3671
|
-
if (!authorizeUrl || !tokenUrl) throw new Error("--
|
|
3697
|
+
if (!authorizeUrl || !tokenUrl) throw new Error("--authorize-url and --token-url — the provider's own addresses, from its OAuth docs");
|
|
3698
|
+
const extra = { ...(preset?.authorize_extra ?? {}), ...authorizeParams(flags) };
|
|
3672
3699
|
if (!/^https:\/\//.test(tokenUrl) && !/^http:\/\/(127\.0\.0\.1|localhost)[:/]/.test(tokenUrl)) {
|
|
3673
3700
|
throw new Error("token URL must be https — a refresh token over http is a leaked one");
|
|
3674
3701
|
}
|
|
3675
3702
|
|
|
3676
3703
|
const clientId = flags["client-id"] ?? env("OAUTH_CLIENT_ID") ?? (await promptVisible(" client_id: "));
|
|
3677
|
-
const
|
|
3678
|
-
|
|
3704
|
+
const pkce = flags["no-pkce"] !== true;
|
|
3705
|
+
const clientSecret = flags["client-secret"] ?? env("OAUTH_CLIENT_SECRET") ?? (await promptHidden(` client_secret${pkce ? " (empty for a public app)" : ""}: `));
|
|
3706
|
+
if (!clientId) throw new Error("client_id is required");
|
|
3707
|
+
// No secret is a public app, which PKCE alone protects (RFC 9700 §2.1.1).
|
|
3708
|
+
if (!clientSecret && !pkce) throw new Error("an app with no client_secret needs PKCE — drop --no-pkce");
|
|
3679
3709
|
const scopes = flags.scopes ?? preset?.scopes_example ?? "";
|
|
3680
3710
|
|
|
3681
3711
|
// One fixed loopback address, the same on every developer's machine, so an
|
|
@@ -3691,13 +3721,21 @@ async function connect(positional, flags) {
|
|
|
3691
3721
|
const crypto = await import("node:crypto");
|
|
3692
3722
|
const http = await import("node:http");
|
|
3693
3723
|
const state = crypto.randomBytes(24).toString("hex");
|
|
3724
|
+
// PKCE (RFC 7636, S256): 32 random bytes as the verifier, its hash as the challenge.
|
|
3725
|
+
const verifier = pkce ? crypto.randomBytes(32).toString("base64url") : null;
|
|
3694
3726
|
const authorize = new URL(authorizeUrl);
|
|
3727
|
+
// Extras first: the flow's own parameters are set after, and win.
|
|
3728
|
+
for (const [k, v] of Object.entries(extra)) authorize.searchParams.set(k, v);
|
|
3729
|
+
for (const k of ["client_id", "redirect_uri", "response_type", "scope", "state", "code_challenge", "code_challenge_method"]) authorize.searchParams.delete(k);
|
|
3695
3730
|
authorize.searchParams.set("response_type", "code");
|
|
3696
3731
|
authorize.searchParams.set("client_id", clientId);
|
|
3697
3732
|
authorize.searchParams.set("redirect_uri", redirectUri);
|
|
3698
3733
|
authorize.searchParams.set("state", state);
|
|
3699
3734
|
if (scopes) authorize.searchParams.set("scope", scopes);
|
|
3700
|
-
|
|
3735
|
+
if (verifier) {
|
|
3736
|
+
authorize.searchParams.set("code_challenge", crypto.createHash("sha256").update(verifier).digest("base64url"));
|
|
3737
|
+
authorize.searchParams.set("code_challenge_method", "S256");
|
|
3738
|
+
}
|
|
3701
3739
|
|
|
3702
3740
|
// One request, then the listener closes. The state is the whole authority:
|
|
3703
3741
|
// a callback with any other value is answered and ignored.
|
|
@@ -3733,7 +3771,11 @@ async function connect(positional, flags) {
|
|
|
3733
3771
|
const exchange = await fetch(tokenUrl, {
|
|
3734
3772
|
method: "POST",
|
|
3735
3773
|
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
3736
|
-
body: new URLSearchParams({
|
|
3774
|
+
body: new URLSearchParams({
|
|
3775
|
+
grant_type: "authorization_code", code, redirect_uri: redirectUri, client_id: clientId,
|
|
3776
|
+
...(clientSecret ? { client_secret: clientSecret } : {}),
|
|
3777
|
+
...(verifier ? { code_verifier: verifier } : {}),
|
|
3778
|
+
}).toString(),
|
|
3737
3779
|
});
|
|
3738
3780
|
const payload = /** @type {any} */ (await exchange.json().catch(() => ({})));
|
|
3739
3781
|
if (!exchange.ok || (!payload.refresh_token && !payload.access_token)) {
|
|
@@ -3743,7 +3785,7 @@ async function connect(positional, flags) {
|
|
|
3743
3785
|
// Store: the refresh recipe when there is one, the bare token otherwise.
|
|
3744
3786
|
/** @type {any} */
|
|
3745
3787
|
const body = payload.refresh_token
|
|
3746
|
-
? { name, oauth2: { token_url: tokenUrl, client_id: clientId, client_secret: clientSecret, refresh_token: payload.refresh_token }, workspace: workspaceName, scopes, ...(payload.refresh_token_expires_in ? { refresh_token_expires_in: payload.refresh_token_expires_in } : {}) }
|
|
3788
|
+
? { name, oauth2: { token_url: tokenUrl, client_id: clientId, ...(clientSecret ? { client_secret: clientSecret } : {}), refresh_token: payload.refresh_token }, workspace: workspaceName, scopes, ...(payload.refresh_token_expires_in ? { refresh_token_expires_in: payload.refresh_token_expires_in } : {}) }
|
|
3747
3789
|
: { name, value: payload.access_token, workspace: workspaceName };
|
|
3748
3790
|
if (url) {
|
|
3749
3791
|
// The same PUT `secrets set` makes. A store that fails here has spent the
|
|
@@ -5738,6 +5780,30 @@ function shortValue(v) {
|
|
|
5738
5780
|
return typeof v === "object" ? JSON.stringify(v).replace(/"([a-z_]+)":/g, "$1: ").replace(/,/g, ", ") : String(v);
|
|
5739
5781
|
}
|
|
5740
5782
|
|
|
5783
|
+
/**
|
|
5784
|
+
* `foldrun identity [--to <workspace>]` — what a cloud must trust to accept
|
|
5785
|
+
* this account's keyless identities: the issuer, and each agent's subject.
|
|
5786
|
+
*/
|
|
5787
|
+
async function identityCmd(flags) {
|
|
5788
|
+
const url = platformFor(flags, "identity");
|
|
5789
|
+
const ws = typeof flags.to === "string" ? flags.to : null;
|
|
5790
|
+
const info = await remoteCall(url, flags, `/api/identity${ws ? `?workspace=${enc(ws)}` : ""}`);
|
|
5791
|
+
if (flags.json === true) {
|
|
5792
|
+
console.log(JSON.stringify(info, null, 2));
|
|
5793
|
+
return 0;
|
|
5794
|
+
}
|
|
5795
|
+
if (!info.enabled) {
|
|
5796
|
+
console.log(`\n ${c.yellow("This install does not issue identity tokens")} ${c.dim("— its operator sets FOLDRUN_OIDC_ISSUER")}\n`);
|
|
5797
|
+
return 1;
|
|
5798
|
+
}
|
|
5799
|
+
console.log(`\n ${c.bold("Keyless cloud identity")}\n`);
|
|
5800
|
+
console.log(` issuer ${info.issuer}`);
|
|
5801
|
+
console.log(` subject ${info.subject}`);
|
|
5802
|
+
console.log(` tokens ${Math.round(info.tokenSeconds / 60)} minutes, renewed while a step runs`);
|
|
5803
|
+
console.log(`\n ${c.dim("Trust the issuer in your cloud and pin at least the account in the subject; then `foldrun secrets set NAME --kind identity --audience <aud>` and name it in an agent's secrets:")}\n`);
|
|
5804
|
+
return 0;
|
|
5805
|
+
}
|
|
5806
|
+
|
|
5741
5807
|
/**
|
|
5742
5808
|
* `foldrun jobs [--to <workspace>]` — the four jobs and every AGENTS.md
|
|
5743
5809
|
* field of each: the account's value, the workspace's, and how the two
|
|
@@ -8117,6 +8183,27 @@ async function flowRmStepCmd(positional, flags, layout) {
|
|
|
8117
8183
|
}
|
|
8118
8184
|
|
|
8119
8185
|
/** Whether an agent file says what the agent is for. */
|
|
8186
|
+
/** `sandbox: own` on an agent as core's listAgents reports it — from core
|
|
8187
|
+
* 2.8 on; an older core does not say, and the agent reads as not own. */
|
|
8188
|
+
function ownSandboxOf(agent) {
|
|
8189
|
+
return /** @type {{ sandbox?: string } | undefined} */ (agent)?.sandbox === "own";
|
|
8190
|
+
}
|
|
8191
|
+
|
|
8192
|
+
/** An agent file's frontmatter as a run reads it (core's configMatter, which
|
|
8193
|
+
* folds the job blocks), from its text. {} when it does not parse. */
|
|
8194
|
+
async function effectiveFront(raw) {
|
|
8195
|
+
const { readFrontmatter } = await core();
|
|
8196
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "foldrun-front-"));
|
|
8197
|
+
try {
|
|
8198
|
+
fs.writeFileSync(path.join(dir, "agent.md"), raw);
|
|
8199
|
+
return readFrontmatter(path.join(dir, "agent.md")) ?? {};
|
|
8200
|
+
} catch {
|
|
8201
|
+
return {};
|
|
8202
|
+
} finally {
|
|
8203
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
8204
|
+
}
|
|
8205
|
+
}
|
|
8206
|
+
|
|
8120
8207
|
function hasDescription(raw) {
|
|
8121
8208
|
const front = /^---\r?\n([\s\S]*?)\r?\n---/.exec(raw)?.[1] ?? "";
|
|
8122
8209
|
const lines = front.split(/\r?\n/);
|
|
@@ -8228,7 +8315,7 @@ async function agentImportCmd(positional, flags, layout) {
|
|
|
8228
8315
|
}
|
|
8229
8316
|
|
|
8230
8317
|
/**
|
|
8231
|
-
* `foldrun agent link <agent> --subagent <worker> | --consult <agent> |
|
|
8318
|
+
* `foldrun agent link <agent> --subagent <worker> [--isolated] | --consult <agent> |
|
|
8232
8319
|
* --can-ask`, and `unlink` — an agent's team, edited in its frontmatter
|
|
8233
8320
|
* list and nowhere else: `subagents:`, `agents:`, `ask` in `tools:`.
|
|
8234
8321
|
*/
|
|
@@ -8236,7 +8323,7 @@ async function agentLinkCmd(positional, flags, layout) {
|
|
|
8236
8323
|
const verb = positional[0];
|
|
8237
8324
|
const add = verb === "link";
|
|
8238
8325
|
const name = positional[1];
|
|
8239
|
-
const usage = `\`foldrun agent ${verb} <agent> --subagent <worker> | --consult <agent> | --can-ask\``;
|
|
8326
|
+
const usage = `\`foldrun agent ${verb} <agent> --subagent <worker> [--isolated] | --consult <agent> | --can-ask\``;
|
|
8240
8327
|
if (!name) throw new Error(usage);
|
|
8241
8328
|
const picked = [
|
|
8242
8329
|
typeof flags.subagent === "string" ? ["subagents", flags.subagent] : null,
|
|
@@ -8245,6 +8332,10 @@ async function agentLinkCmd(positional, flags, layout) {
|
|
|
8245
8332
|
].filter(Boolean);
|
|
8246
8333
|
if (picked.length !== 1) throw new Error(`${usage} — exactly one of them`);
|
|
8247
8334
|
const [key, member] = /** @type {[string, string]} */ (picked[0]);
|
|
8335
|
+
// --isolated: the worker runs each job in its own sandbox (sandbox: own on
|
|
8336
|
+
// its file) — its own tools, secrets and network, not cut to the parent's.
|
|
8337
|
+
const isolated = flags.isolated === true;
|
|
8338
|
+
if (isolated && (key !== "subagents" || !add)) throw new Error("--isolated goes with `agent link <agent> --subagent <worker>`");
|
|
8248
8339
|
|
|
8249
8340
|
const desk = await openDesk(flags, layout, `agent ${verb} ${name}`);
|
|
8250
8341
|
const { editFrontmatterList, setFrontmatterScalar } = await patterns();
|
|
@@ -8258,8 +8349,11 @@ async function agentLinkCmd(positional, flags, layout) {
|
|
|
8258
8349
|
|
|
8259
8350
|
const raw = desk.files.get(fileOf(name));
|
|
8260
8351
|
const next = editFrontmatterList(raw, key, member, add ? "add" : "remove");
|
|
8261
|
-
const said = key === "subagents" ? `${member} as a sub-agent` : key === "agents" ? `${member} as a consult` : "asking you mid-step";
|
|
8262
|
-
|
|
8352
|
+
const said = key === "subagents" ? `${member} as a sub-agent${isolated ? " in its own sandbox" : ""}` : key === "agents" ? `${member} as a consult` : "asking you mid-step";
|
|
8353
|
+
// What a run reads, through core's parser: a `guardrail:` block counts,
|
|
8354
|
+
// and "sandbox: own" inside a description does not.
|
|
8355
|
+
const workerOwn = isolated && (await effectiveFront(desk.files.get(fileOf(member)) ?? "")).sandbox === "own";
|
|
8356
|
+
if (next === raw && (!isolated || workerOwn)) {
|
|
8263
8357
|
console.log(`\n ${c.dim(add ? `${name} already has ${said}` : `${name} does not have ${said} — nothing to remove`)}\n`);
|
|
8264
8358
|
return 0;
|
|
8265
8359
|
}
|
|
@@ -8277,9 +8371,22 @@ async function agentLinkCmd(positional, flags, layout) {
|
|
|
8277
8371
|
if (!description) throw new Error("no description given — nothing written");
|
|
8278
8372
|
}
|
|
8279
8373
|
const w = desk.files.get(fileOf(member));
|
|
8280
|
-
edits.push({ agent: member, rel: fileOf(member), before: w, after: setFrontmatterScalar(w, "description", description),
|
|
8374
|
+
edits.push({ agent: member, rel: fileOf(member), before: w, after: setFrontmatterScalar(w, "description", description), bodies: [{ set: { key: "description", value: description } }] });
|
|
8375
|
+
}
|
|
8376
|
+
if (isolated && !workerOwn) {
|
|
8377
|
+
// The worker's file once, whatever is set on it: one diff, one write,
|
|
8378
|
+
// one PATCH per key on the platform.
|
|
8379
|
+
const prior = edits.find((e) => e.agent === member);
|
|
8380
|
+
const set = { set: { key: "sandbox", value: "own" } };
|
|
8381
|
+
if (prior) {
|
|
8382
|
+
prior.after = setFrontmatterScalar(prior.after, "sandbox", "own");
|
|
8383
|
+
prior.bodies.push(set);
|
|
8384
|
+
} else {
|
|
8385
|
+
const w = desk.files.get(fileOf(member));
|
|
8386
|
+
edits.push({ agent: member, rel: fileOf(member), before: w, after: setFrontmatterScalar(w, "sandbox", "own"), bodies: [set] });
|
|
8387
|
+
}
|
|
8281
8388
|
}
|
|
8282
|
-
edits.push({ agent: name, rel: fileOf(name), before: raw, after: next,
|
|
8389
|
+
if (next !== raw) edits.push({ agent: name, rel: fileOf(name), before: raw, after: next, bodies: [{ list: { key, name: member, action: add ? "add" : "remove" } }] });
|
|
8283
8390
|
|
|
8284
8391
|
console.log(`\n ${c.bold(name)} ${c.dim(`· ${add ? "add" : "remove"} ${said} · ${desk.label}`)}\n`);
|
|
8285
8392
|
for (const e of edits) {
|
|
@@ -8302,7 +8409,9 @@ async function agentLinkCmd(positional, flags, layout) {
|
|
|
8302
8409
|
if (!desk.platform) for (const e of edits) assertUnchanged(desk, e.rel, e.before);
|
|
8303
8410
|
for (const e of edits) {
|
|
8304
8411
|
if (desk.platform) {
|
|
8305
|
-
|
|
8412
|
+
for (const body of e.bodies) {
|
|
8413
|
+
await remoteCall(desk.url, flags, `/api/workspaces/${enc(desk.ws)}/agents/${enc(e.agent)}`, { method: "PATCH", body: JSON.stringify(body) });
|
|
8414
|
+
}
|
|
8306
8415
|
} else {
|
|
8307
8416
|
writeIfUnchanged(desk, e.rel, e.before, e.after);
|
|
8308
8417
|
}
|
|
@@ -8487,7 +8596,7 @@ async function flowShowCmd(positional, flags, layout) {
|
|
|
8487
8596
|
const a = byAgent.get(s.agent);
|
|
8488
8597
|
if (!a) return;
|
|
8489
8598
|
if (a.model) console.log(`${under}${p.dim(`model ${a.model}${a.tools?.length ? ` · ${a.tools.length} tool${a.tools.length === 1 ? "" : "s"}` : ""}`)}`);
|
|
8490
|
-
for (const w of a.subagents ?? []) console.log(`${under}↳ sub-agent ${w}`);
|
|
8599
|
+
for (const w of a.subagents ?? []) console.log(`${under}↳ sub-agent ${w}${ownSandboxOf(byAgent.get(w)) ? p.dim(" (own sandbox)") : ""}`);
|
|
8491
8600
|
for (const w of a.consults ?? []) console.log(`${under}· consults ${w}`);
|
|
8492
8601
|
if ((a.tools ?? []).includes("ask")) console.log(`${under}${p.amber("may ask you")}`);
|
|
8493
8602
|
for (const x of ofAgent(s.agent)) console.log(`${under}${mark(x)}`);
|
|
@@ -10485,17 +10594,27 @@ async function oauthClientsCmd(positional, flags) {
|
|
|
10485
10594
|
return 0;
|
|
10486
10595
|
}
|
|
10487
10596
|
if (verb === "add") {
|
|
10488
|
-
if (!name || !/^[a-z0-9][a-z0-9-]*$/.test(name)) throw new Error("`foldrun secrets clients add <name> --
|
|
10597
|
+
if (!name || !/^[a-z0-9][a-z0-9-]*$/.test(name)) throw new Error("`foldrun secrets clients add <name> --authorize-url … --token-url … --client-id … [--authorize-param name=value …] [--scopes …]` — the name in kebab-case");
|
|
10598
|
+
warnProviderDeprecated(flags);
|
|
10489
10599
|
const { OAUTH_PRESETS } = await core();
|
|
10490
10600
|
const preset = flags.provider ? OAUTH_PRESETS[flags.provider] : undefined;
|
|
10491
10601
|
if (flags.provider && !preset) throw new Error(`unknown provider "${flags.provider}" — one of ${Object.keys(OAUTH_PRESETS).join(", ")}, or pass --authorize-url and --token-url`);
|
|
10492
10602
|
const authorize_url = flags["authorize-url"] ?? preset?.authorize_url;
|
|
10493
10603
|
const token_url = flags["token-url"] ?? preset?.token_url;
|
|
10494
|
-
if (!authorize_url || !token_url) throw new Error("--
|
|
10604
|
+
if (!authorize_url || !token_url) throw new Error("--authorize-url and --token-url — the provider's own addresses, from its OAuth docs");
|
|
10605
|
+
const authorize_extra = { ...(preset?.authorize_extra ?? {}), ...authorizeParams(flags) };
|
|
10495
10606
|
const client_id = flags["client-id"] ?? env("OAUTH_CLIENT_ID") ?? (await promptVisible(" client_id: "));
|
|
10496
|
-
const
|
|
10497
|
-
|
|
10498
|
-
|
|
10607
|
+
const pkce = flags["no-pkce"] !== true;
|
|
10608
|
+
const client_secret = flags["client-secret"] ?? env("OAUTH_CLIENT_SECRET") ?? (await promptHidden(` client_secret${pkce ? " (empty for a public app)" : ""}: `));
|
|
10609
|
+
if (!client_id) throw new Error("client_id is required");
|
|
10610
|
+
if (!client_secret && !pkce) throw new Error("an app with no client_secret needs PKCE — drop --no-pkce");
|
|
10611
|
+
if (flags.issuer !== undefined && (typeof flags.issuer !== "string" || !/^https:\/\/[^?#]+$/.test(flags.issuer))) throw new Error("--issuer is the provider's https issuer URL, no query or fragment");
|
|
10612
|
+
const config = {
|
|
10613
|
+
authorize_url, token_url, client_id, client_secret: client_secret ?? "", scopes: flags.scopes ?? preset?.scopes_example ?? "",
|
|
10614
|
+
...(Object.keys(authorize_extra).length ? { authorize_extra } : {}),
|
|
10615
|
+
...(pkce ? {} : { pkce: false }),
|
|
10616
|
+
...(typeof flags.issuer === "string" ? { issuer: flags.issuer } : {}),
|
|
10617
|
+
};
|
|
10499
10618
|
await remoteCall(url, flags, "/api/oauth/clients", { method: "POST", body: JSON.stringify({ name, config }) });
|
|
10500
10619
|
console.log(`\n ${c.green("✓")} saved ${name} ${c.dim(`— \`foldrun connect NAME --client ${name}\` runs a consent from it`)}\n`);
|
|
10501
10620
|
return 0;
|
|
@@ -10512,9 +10631,15 @@ async function connectFromClient(name, flags) {
|
|
|
10512
10631
|
const url = platformFor(flags, "connect --client");
|
|
10513
10632
|
const workspace = typeof flags.to === "string" ? flags.to : undefined;
|
|
10514
10633
|
const before = (await remoteCall(url, flags, "/api/oauth/connections")).connections?.find((s) => s.name === name && (s.workspace ?? undefined) === workspace)?.connectedAt ?? null;
|
|
10515
|
-
|
|
10634
|
+
// --scopes: this connection's own, instead of the saved client's default;
|
|
10635
|
+
// the platform keeps them with the connection for its reconnects.
|
|
10636
|
+
const scopes = typeof flags.scopes === "string" && flags.scopes.trim() ? { scopes: flags.scopes } : {};
|
|
10637
|
+
const started = await remoteCall(url, flags, "/api/oauth/start", { method: "POST", body: JSON.stringify({ client: flags.client, secret: name, ...(workspace ? { workspace } : {}), ...scopes }) });
|
|
10516
10638
|
const opened = flags["no-browser"] === true || !process.stdout.isTTY ? false : await openInBrowser(started.url);
|
|
10517
|
-
|
|
10639
|
+
// The platform answers a hand-off page: a signed-in person accepts the
|
|
10640
|
+
// connection there, then the provider's consent runs in that browser.
|
|
10641
|
+
const what = started.handoff ? "accept the connection, then consent" : "consent";
|
|
10642
|
+
console.log(opened ? `\n Opened your browser to ${what} with the saved ${c.bold(flags.client)} app.\n` : `\n Open this address in your browser to ${what}:\n\n ${started.url}\n`);
|
|
10518
10643
|
console.log(` ${c.dim("Waiting for the platform to store it…")}`);
|
|
10519
10644
|
const deadline = Date.now() + 10 * 60 * 1000;
|
|
10520
10645
|
while (Date.now() < deadline) {
|
|
@@ -10549,7 +10674,11 @@ async function secretOfKind(kind, flags) {
|
|
|
10549
10674
|
}
|
|
10550
10675
|
case "api": {
|
|
10551
10676
|
const lines = [].concat(flags.header ?? []);
|
|
10552
|
-
|
|
10677
|
+
// --basic-user: HTTP Basic from a username and password (prompted, or
|
|
10678
|
+
// --basic-password); the platform builds the Authorization header.
|
|
10679
|
+
const basicUser = typeof flags["basic-user"] === "string" ? flags["basic-user"] : null;
|
|
10680
|
+
const basic = basicUser ? { basic: { username: basicUser, password: typeof flags["basic-password"] === "string" ? flags["basic-password"] : await promptHidden(" password: ") } } : {};
|
|
10681
|
+
if (!lines.length && !basicUser) lines.push(await promptHidden(" header (Name: value): "));
|
|
10553
10682
|
const headers = Object.fromEntries(
|
|
10554
10683
|
lines.filter(Boolean).map((l) => {
|
|
10555
10684
|
const i = String(l).indexOf(":");
|
|
@@ -10558,7 +10687,23 @@ async function secretOfKind(kind, flags) {
|
|
|
10558
10687
|
}),
|
|
10559
10688
|
);
|
|
10560
10689
|
const base = typeof flags["base-url"] === "string" ? { base_url: flags["base-url"] } : {};
|
|
10561
|
-
return { body: { api: { ...base, headers } }, local: (k, n, b, s) => k.setApiSecret("default", n, b.api, s) };
|
|
10690
|
+
return { body: { api: { ...base, headers, ...basic } }, local: (k, n, b, s) => k.setApiSecret("default", n, b.api, s) };
|
|
10691
|
+
}
|
|
10692
|
+
case "identity": {
|
|
10693
|
+
// A keyless cloud identity: only the audience; the platform signs the
|
|
10694
|
+
// tokens (`foldrun identity` prints what the cloud must trust).
|
|
10695
|
+
if (typeof flags.audience !== "string" || !flags.audience.trim()) throw new Error("--kind identity needs --audience <value your cloud's trust expects in aud>");
|
|
10696
|
+
return { body: { identity: { audience: flags.audience } }, local: (k, n, b, s) => k.setIdentitySecret("default", n, b.identity, s) };
|
|
10697
|
+
}
|
|
10698
|
+
case "client-cert": {
|
|
10699
|
+
// A client certificate for mutual TLS. Bind it to the exact hosts it is
|
|
10700
|
+
// for with `foldrun secrets sites NAME host`; the egress proxy presents
|
|
10701
|
+
// it there. --export also puts it in the sandbox as files.
|
|
10702
|
+
const cert = read(flags["cert-file"], "--cert-file <cert.pem> — the certificate, chain included");
|
|
10703
|
+
const key = read(flags["key-file"], "--key-file <key.pem> — its private key");
|
|
10704
|
+
const passphrase = typeof flags.passphrase === "string" ? flags.passphrase : undefined;
|
|
10705
|
+
const client_cert = { cert, key, ...(passphrase ? { passphrase } : {}), ...(flags.export === true ? { export: true } : {}) };
|
|
10706
|
+
return { body: { client_cert }, local: (k, n, b, s) => k.setClientCertSecret("default", n, b.client_cert, s) };
|
|
10562
10707
|
}
|
|
10563
10708
|
case "service-account": {
|
|
10564
10709
|
let j;
|
|
@@ -10568,7 +10713,9 @@ async function secretOfKind(kind, flags) {
|
|
|
10568
10713
|
if (err instanceof SyntaxError) throw new Error(`${flags.file} is not JSON — the key file Google gives you`);
|
|
10569
10714
|
throw err;
|
|
10570
10715
|
}
|
|
10571
|
-
|
|
10716
|
+
// --subject: the user it acts as (domain-wide delegation, granted by the
|
|
10717
|
+
// provider's admin); without it the account acts as itself.
|
|
10718
|
+
const sa = { token_url: j.token_uri ?? "https://oauth2.googleapis.com/token", issuer: j.client_email, private_key: j.private_key, scope: flags.scopes ?? "", ...(typeof flags.subject === "string" ? { subject: flags.subject } : {}) };
|
|
10572
10719
|
return { body: { service_account: sa }, local: (k, n, b, s) => k.setServiceAccountSecret("default", n, b.service_account, s) };
|
|
10573
10720
|
}
|
|
10574
10721
|
case "m2m": {
|
|
@@ -10579,7 +10726,7 @@ async function secretOfKind(kind, flags) {
|
|
|
10579
10726
|
return { body: { oauth2 }, local: (k, n, b, s) => k.setOAuth2Secret("default", n, b.oauth2, s) };
|
|
10580
10727
|
}
|
|
10581
10728
|
default:
|
|
10582
|
-
throw new Error(`unknown --kind "${kind}" — file, ssh, api, service-account or
|
|
10729
|
+
throw new Error(`unknown --kind "${kind}" — file, ssh, api, service-account, m2m, client-cert or identity (a plain value needs no --kind; --oauth2 a refresh token; \`foldrun connect\` a browser sign-in)`);
|
|
10583
10730
|
}
|
|
10584
10731
|
}
|
|
10585
10732
|
|
|
@@ -11235,6 +11382,8 @@ export async function run(command, positional, flags, workspace, layout) {
|
|
|
11235
11382
|
return rerunCmd(positional[0], flags, layout);
|
|
11236
11383
|
case "account":
|
|
11237
11384
|
return accountCmd(positional, flags);
|
|
11385
|
+
case "identity":
|
|
11386
|
+
return identityCmd(flags);
|
|
11238
11387
|
case "jobs":
|
|
11239
11388
|
return jobsCmd(flags, layout);
|
|
11240
11389
|
case "schedule":
|
package/src/guide.mjs
CHANGED
|
@@ -30,7 +30,7 @@ export function agentRulesBlock() {
|
|
|
30
30
|
|
|
31
31
|
# Read foldrun's docs before you edit this folder
|
|
32
32
|
|
|
33
|
-
This is a foldrun account: agents, flows, tools and knowledge as markdown, run by foldrun. Its formats — frontmatter fields, step options, \`verify:\` prefixes, which folder a file goes in — are foldrun's own and may differ from your training data. Before you write or change an agent, flow, tool or skill, read the relevant guide: \`foldrun docs\` lists them and \`foldrun docs <page>\` prints one (start with \`foldrun docs coding-agents\`). They ship with the CLI, so they match the version you are driving. Check every edit with \`foldrun check\` — offline, no model calls; it is also the security scanner (a key written into a file, hidden text, an agent that can leak), and \`foldrun check --security\` fails on what it finds. Never paste a credential into a file: \`foldrun secrets set NAME\` and write \`\${NAME}\`. For a flow's shape, \`foldrun flow show <flow>\` draws it, and \`foldrun flow add\`, \`foldrun flow rm-step\`, \`foldrun flow dup-step\` and \`foldrun agent link\` make the same edits as the dashboard canvas (patterns, sub-agents, consults, \`ask\`). An agent with \`tools: [peers]\` can message the steps running beside it in its parallel group (\`peers_message\`) and ask for what they have instead of guessing or failing (\`foldrun docs agents\`). A script tool's own \`secrets: [NAMES]\` are granted with the tool; list on an agent only the secrets it uses directly. A hook (\`hooks/<name>/hook.md\` + \`run.mjs\`, turned on with \`hooks: [name]\`) is code at every tool call, step start and step end — \`foldrun hook add\`/\`test\` and \`foldrun docs hooks\`. Paths to the workspace's own folders are written \`workspace/storage/…\` and \`workspace/state/…\` — in an agent's prose, a flow's options and a script's arguments — not \`../../\`, which still works but is the older spelling. Which sites a step's sandbox may reach is \`network:\` — open for an agent that may fetch or browse the web, the trusted list plus what it declares for every other; add a site with \`foldrun agent network <agent> --add <site>\` (\`foldrun docs network\`). Bind a secret to the sites it is for (\`foldrun secrets sites NAME <site>\`) and no sandbox ever holds its value.
|
|
33
|
+
This is a foldrun account: agents, flows, tools and knowledge as markdown, run by foldrun. Its formats — frontmatter fields, step options, \`verify:\` prefixes, which folder a file goes in — are foldrun's own and may differ from your training data. Before you write or change an agent, flow, tool or skill, read the relevant guide: \`foldrun docs\` lists them and \`foldrun docs <page>\` prints one (start with \`foldrun docs coding-agents\`). They ship with the CLI, so they match the version you are driving. Check every edit with \`foldrun check\` — offline, no model calls; it is also the security scanner (a key written into a file, hidden text, an agent that can leak), and \`foldrun check --security\` fails on what it finds. Never paste a credential into a file: \`foldrun secrets set NAME\` and write \`\${NAME}\`. For a flow's shape, \`foldrun flow show <flow>\` draws it, and \`foldrun flow add\`, \`foldrun flow rm-step\`, \`foldrun flow dup-step\` and \`foldrun agent link\` make the same edits as the dashboard canvas (patterns, sub-agents, consults, \`ask\`). An agent with \`tools: [peers]\` can message the steps running beside it in its parallel group (\`peers_message\`) and ask for what they have instead of guessing or failing (\`foldrun docs agents\`). A sub-agent that reads untrusted content (web pages, mail, uploads) gets \`sandbox: own\` in its own file: each job then runs in a sandbox of its own with its own tools, secrets and network, and only its reply reaches the agent that delegated (\`foldrun agent link <agent> --subagent <worker> --isolated\`). A script tool's own \`secrets: [NAMES]\` are granted with the tool; list on an agent only the secrets it uses directly. A hook (\`hooks/<name>/hook.md\` + \`run.mjs\`, turned on with \`hooks: [name]\`) is code at every tool call, step start and step end — \`foldrun hook add\`/\`test\` and \`foldrun docs hooks\`. Paths to the workspace's own folders are written \`workspace/storage/…\` and \`workspace/state/…\` — in an agent's prose, a flow's options and a script's arguments — not \`../../\`, which still works but is the older spelling. Which sites a step's sandbox may reach is \`network:\` — open for an agent that may fetch or browse the web, the trusted list plus what it declares for every other; add a site with \`foldrun agent network <agent> --add <site>\` (\`foldrun docs network\`). Bind a secret to the sites it is for (\`foldrun secrets sites NAME <site>\`) and no sandbox ever holds its value.
|
|
34
34
|
|
|
35
35
|
This block is for coding tools. It is written and re-added by the foldrun CLI, and the runtime strips it, so the agents that run here never see it; the rest of this file is the account's own context, which they do read. Committing the block with your work keeps the tree clean.
|
|
36
36
|
|