foldrun 2.5.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
@@ -203,7 +203,7 @@ const HELP = `foldrun — agents are just folders
203
203
  foldrun backups requests every restore asked of the platform team, and where each stands (--json)
204
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
205
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
206
- 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>
207
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
208
208
  foldrun deploy [dir] push the whole account, or deploy <workspace> for one of them
209
209
  foldrun pull [workspace] bring the platform's account down here (refuses to clobber; --force overrides)
@@ -334,13 +334,14 @@ Options
334
334
  --print open: print the URL only
335
335
  --from <n> invoke, flow run, rerun: start at step n; earlier steps are recorded as skipped (a bulk rerun defaults to 1)
336
336
  --no-browser login, connect, billing top-up/card/plan: print the address instead of opening it
337
- --provider <name> connect: google, github, microsoft or linkedin (fills the URLs)
337
+ --provider <name> (deprecated — use --authorize-url, --token-url and --authorize-param) connect: google, github, microsoft or linkedin
338
338
  --no-pkce connect, secrets clients add: leave PKCE off (only for an app with a secret whose provider refuses it)
339
339
  --issuer <url> secrets clients add: the provider's issuer, checked against the iss it sends back (RFC 9207)
340
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)
341
341
  --client-id / --client-secret connect: the OAuth app (prompted if omitted; secret never echoed)
342
342
  --port <n> connect: loopback port for the redirect (default 8642 — register http://localhost:8642/callback on the app)
343
- --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)
344
345
  --role <r> keys create: viewer, editor (default) or admin
345
346
  --expires <span> keys create: when it stops working — 30d, 90d, 365d or never (default never)
346
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)
@@ -384,9 +385,9 @@ if (!command || command === "--help" || command === "-h") {
384
385
  // `--value` as the account's argument and stored an empty secret; `--force
385
386
  // ./dir` swallowed the directory. A flag followed by another flag is also
386
387
  // boolean, so an unlisted switch at least does not eat its neighbour.
387
- 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"]);
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"]);
388
389
  // Flags said more than once collect into a list: `--tag a --tag b`.
389
- 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"]);
390
391
  // `--wait` is a switch everywhere but `flow add <flow> wait`, where it takes
391
392
  // a span: `--wait 30m`, `--wait event`. Only a value that reads as one is
392
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` |
@@ -462,7 +462,7 @@ 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`) |
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
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
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
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 |
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 |
@@ -97,8 +97,8 @@ claude.ai or Claude Code there, nor any MCP configuration on disk.
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
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> --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`; 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) |
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
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 |
@@ -188,7 +188,7 @@ claude.ai or Claude Code there, nor any MCP configuration on disk.
188
188
  | `--schedule "<cron>"`, `--timezone <zone>` | `flow trigger schedule`: the cron line (5 fields, or `@daily`) and the zone it is read in |
189
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 |
190
190
  | `--client <name>` | `connect`: run the consent from that OAuth client saved on the platform (`secrets clients` lists them) |
191
- | `--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 |
192
192
  | `--eval <name>`, `--expect "<line>"`, `--case <name>` | `promote`: the eval file, an assertion (repeatable), the case's name |
193
193
  | `--tag <t>` | `invoke`, `flow run`: a label on the run — repeatable |
194
194
  | `--inputs <name>` | `invoke`, `flow run`: the task is that saved input set; not with `--task` |
@@ -217,7 +217,9 @@ claude.ai or Claude Code there, nor any MCP configuration on disk.
217
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 |
218
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) |
219
219
  | `--no-browser` | `login`, `connect`: print the address instead of opening it |
220
- | `--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 |
221
223
  | `--scopes "<a b c>"` | `connect`: space-separated, as OAuth defines it |
222
224
  | `--client-id` / `--client-secret` | `connect`: the OAuth app; prompted when omitted, the secret never echoed |
223
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
@@ -201,7 +201,14 @@ the result as an `oauth2` secret; the platform refreshes it. An agent names
201
201
  it in `secrets:` like any other.
202
202
 
203
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. Every
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
205
212
  later connection can use the same saved app — choose it under **App** and
206
213
  type the scopes this connection needs, as the provider spells them. Each
207
214
  connection keeps its own scopes, so one app serves any number of APIs from
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "foldrun",
3
- "version": "2.5.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.7.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,7 +3694,8 @@ 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
  }
@@ -3698,7 +3725,7 @@ async function connect(positional, flags) {
3698
3725
  const verifier = pkce ? crypto.randomBytes(32).toString("base64url") : null;
3699
3726
  const authorize = new URL(authorizeUrl);
3700
3727
  // Extras first: the flow's own parameters are set after, and win.
3701
- for (const [k, v] of Object.entries(preset?.authorize_extra ?? {})) authorize.searchParams.set(k, v);
3728
+ for (const [k, v] of Object.entries(extra)) authorize.searchParams.set(k, v);
3702
3729
  for (const k of ["client_id", "redirect_uri", "response_type", "scope", "state", "code_challenge", "code_challenge_method"]) authorize.searchParams.delete(k);
3703
3730
  authorize.searchParams.set("response_type", "code");
3704
3731
  authorize.searchParams.set("client_id", clientId);
@@ -8156,6 +8183,27 @@ async function flowRmStepCmd(positional, flags, layout) {
8156
8183
  }
8157
8184
 
8158
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
+
8159
8207
  function hasDescription(raw) {
8160
8208
  const front = /^---\r?\n([\s\S]*?)\r?\n---/.exec(raw)?.[1] ?? "";
8161
8209
  const lines = front.split(/\r?\n/);
@@ -8267,7 +8315,7 @@ async function agentImportCmd(positional, flags, layout) {
8267
8315
  }
8268
8316
 
8269
8317
  /**
8270
- * `foldrun agent link <agent> --subagent <worker> | --consult <agent> |
8318
+ * `foldrun agent link <agent> --subagent <worker> [--isolated] | --consult <agent> |
8271
8319
  * --can-ask`, and `unlink` — an agent's team, edited in its frontmatter
8272
8320
  * list and nowhere else: `subagents:`, `agents:`, `ask` in `tools:`.
8273
8321
  */
@@ -8275,7 +8323,7 @@ async function agentLinkCmd(positional, flags, layout) {
8275
8323
  const verb = positional[0];
8276
8324
  const add = verb === "link";
8277
8325
  const name = positional[1];
8278
- 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\``;
8279
8327
  if (!name) throw new Error(usage);
8280
8328
  const picked = [
8281
8329
  typeof flags.subagent === "string" ? ["subagents", flags.subagent] : null,
@@ -8284,6 +8332,10 @@ async function agentLinkCmd(positional, flags, layout) {
8284
8332
  ].filter(Boolean);
8285
8333
  if (picked.length !== 1) throw new Error(`${usage} — exactly one of them`);
8286
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>`");
8287
8339
 
8288
8340
  const desk = await openDesk(flags, layout, `agent ${verb} ${name}`);
8289
8341
  const { editFrontmatterList, setFrontmatterScalar } = await patterns();
@@ -8297,8 +8349,11 @@ async function agentLinkCmd(positional, flags, layout) {
8297
8349
 
8298
8350
  const raw = desk.files.get(fileOf(name));
8299
8351
  const next = editFrontmatterList(raw, key, member, add ? "add" : "remove");
8300
- const said = key === "subagents" ? `${member} as a sub-agent` : key === "agents" ? `${member} as a consult` : "asking you mid-step";
8301
- 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)) {
8302
8357
  console.log(`\n ${c.dim(add ? `${name} already has ${said}` : `${name} does not have ${said} — nothing to remove`)}\n`);
8303
8358
  return 0;
8304
8359
  }
@@ -8316,9 +8371,22 @@ async function agentLinkCmd(positional, flags, layout) {
8316
8371
  if (!description) throw new Error("no description given — nothing written");
8317
8372
  }
8318
8373
  const w = desk.files.get(fileOf(member));
8319
- 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
+ }
8320
8388
  }
8321
- 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" } }] });
8322
8390
 
8323
8391
  console.log(`\n ${c.bold(name)} ${c.dim(`· ${add ? "add" : "remove"} ${said} · ${desk.label}`)}\n`);
8324
8392
  for (const e of edits) {
@@ -8341,7 +8409,9 @@ async function agentLinkCmd(positional, flags, layout) {
8341
8409
  if (!desk.platform) for (const e of edits) assertUnchanged(desk, e.rel, e.before);
8342
8410
  for (const e of edits) {
8343
8411
  if (desk.platform) {
8344
- 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
+ }
8345
8415
  } else {
8346
8416
  writeIfUnchanged(desk, e.rel, e.before, e.after);
8347
8417
  }
@@ -8526,7 +8596,7 @@ async function flowShowCmd(positional, flags, layout) {
8526
8596
  const a = byAgent.get(s.agent);
8527
8597
  if (!a) return;
8528
8598
  if (a.model) console.log(`${under}${p.dim(`model ${a.model}${a.tools?.length ? ` · ${a.tools.length} tool${a.tools.length === 1 ? "" : "s"}` : ""}`)}`);
8529
- 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)") : ""}`);
8530
8600
  for (const w of a.consults ?? []) console.log(`${under}· consults ${w}`);
8531
8601
  if ((a.tools ?? []).includes("ask")) console.log(`${under}${p.amber("may ask you")}`);
8532
8602
  for (const x of ofAgent(s.agent)) console.log(`${under}${mark(x)}`);
@@ -10524,13 +10594,15 @@ async function oauthClientsCmd(positional, flags) {
10524
10594
  return 0;
10525
10595
  }
10526
10596
  if (verb === "add") {
10527
- 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);
10528
10599
  const { OAUTH_PRESETS } = await core();
10529
10600
  const preset = flags.provider ? OAUTH_PRESETS[flags.provider] : undefined;
10530
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`);
10531
10602
  const authorize_url = flags["authorize-url"] ?? preset?.authorize_url;
10532
10603
  const token_url = flags["token-url"] ?? preset?.token_url;
10533
- 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) };
10534
10606
  const client_id = flags["client-id"] ?? env("OAUTH_CLIENT_ID") ?? (await promptVisible(" client_id: "));
10535
10607
  const pkce = flags["no-pkce"] !== true;
10536
10608
  const client_secret = flags["client-secret"] ?? env("OAUTH_CLIENT_SECRET") ?? (await promptHidden(` client_secret${pkce ? " (empty for a public app)" : ""}: `));
@@ -10539,7 +10611,7 @@ async function oauthClientsCmd(positional, flags) {
10539
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");
10540
10612
  const config = {
10541
10613
  authorize_url, token_url, client_id, client_secret: client_secret ?? "", scopes: flags.scopes ?? preset?.scopes_example ?? "",
10542
- ...(preset?.authorize_extra ? { authorize_extra: preset.authorize_extra } : {}),
10614
+ ...(Object.keys(authorize_extra).length ? { authorize_extra } : {}),
10543
10615
  ...(pkce ? {} : { pkce: false }),
10544
10616
  ...(typeof flags.issuer === "string" ? { issuer: flags.issuer } : {}),
10545
10617
  };
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