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 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> (--provider or --authorize-url/--token-url, --client-id, --scopes), rm <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 (fills the URLs)
337
- --scopes "<a b c>" connect: space-separated scopes (default: the provider's example)
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: a provider with no preset
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 `api`. They go to the vault;
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 | registered providers (`name`, `client`, `config`) |
466
- | `/api/oauth/start` | POST | begin a connection (`client`, `secret`, `workspace`) |
467
- | `/api/oauth/callback` | GET | where the provider returns |
468
- | `/api/oauth/reconnect` | POST | reconnect a secret with the client it was connected with — no client id (`secret`, `workspace`, `provider`) → `{ url }` |
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> --provider google\|github\|microsoft\|linkedin` (or `--authorize-url` / `--token-url`) `--client-id … --scopes …` saves one (secret prompted), `clients rm <name>` removes it |
101
- | `foldrun connect NAME` | OAuth sign-in from the terminal, stored as an auto-refreshing secret — `--provider google\|github\|microsoft\|linkedin`; 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, 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, and a worker's description when it has none |
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
- | `--provider <name>` | `connect`: fills the authorize and token URLs and an example scope string; `--authorize-url` / `--token-url` for a provider with no preset |
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.4.0",
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.6.0"
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 [--provider <google|github|microsoft|linkedin>] [--workspace <name>] [--new-client]");
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("--provider, or both --authorize-url and --token-url");
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 clientSecret = flags["client-secret"] ?? env("OAUTH_CLIENT_SECRET") ?? (await promptHidden(" client_secret: "));
3678
- if (!clientId || !clientSecret) throw new Error("client_id and client_secret are required");
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
- for (const [k, v] of Object.entries(preset?.authorize_extra ?? {})) authorize.searchParams.set(k, v);
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({ grant_type: "authorization_code", code, redirect_uri: redirectUri, client_id: clientId, client_secret: clientSecret }).toString(),
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
- if (next === raw) {
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), body: { set: { key: "description", value: 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, body: { list: { key, name: member, action: add ? "add" : "remove" } } });
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
- await remoteCall(desk.url, flags, `/api/workspaces/${enc(desk.ws)}/agents/${enc(e.agent)}`, { method: "PATCH", body: JSON.stringify(e.body) });
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> --provider google|github|microsoft|linkedin --client-id … [--scopes …]` — the name in kebab-case");
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("--provider, or both --authorize-url and --token-url");
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 client_secret = flags["client-secret"] ?? env("OAUTH_CLIENT_SECRET") ?? (await promptHidden(" client_secret: "));
10497
- if (!client_id || !client_secret) throw new Error("client_id and client_secret are required");
10498
- const config = { authorize_url, token_url, client_id, client_secret, scopes: flags.scopes ?? preset?.scopes_example ?? "", ...(preset?.authorize_extra ? { authorize_extra: preset.authorize_extra } : {}) };
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
- const started = await remoteCall(url, flags, "/api/oauth/start", { method: "POST", body: JSON.stringify({ client: flags.client, secret: name, ...(workspace ? { workspace } : {}) }) });
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
- console.log(opened ? `\n Opened your browser to consent with the saved ${c.bold(flags.client)} client.\n` : `\n Open this address in your browser:\n\n ${started.url}\n`);
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
- if (!lines.length) lines.push(await promptHidden(" header (Name: value): "));
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
- const sa = { token_url: j.token_uri ?? "https://oauth2.googleapis.com/token", issuer: j.client_email, private_key: j.private_key, scope: flags.scopes ?? "" };
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 m2m (a plain value needs no --kind; --oauth2 a refresh token; \`foldrun connect\` a browser sign-in)`);
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