foldrun 2.4.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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)
@@ -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:)
@@ -334,7 +335,9 @@ Options
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
337
  --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)
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
343
  --authorize-url / --token-url connect: a provider with no preset
@@ -381,7 +384,7 @@ if (!command || command === "--help" || command === "-h") {
381
384
  // `--value` as the account's argument and stored an empty secret; `--force
382
385
  // ./dir` swallowed the directory. A flag followed by another flag is also
383
386
  // 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"]);
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"]);
385
388
  // Flags said more than once collect into a list: `--tag a --tag b`.
386
389
  const REPEATABLE = new Set(["tag", "expect", "header", "site", "add", "remove", "rule"]);
387
390
  // `--wait` is a switch everywhere but `flow add <flow> wait`, where it takes
package/docs/api.md CHANGED
@@ -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.
@@ -463,9 +463,11 @@ signed-in person, never by a key — mints an ordinary API key at their role.
463
463
  | Route | Methods | |
464
464
  |---|---|---|
465
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 }` |
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/cli.md CHANGED
@@ -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` |
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
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 |
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) |
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 |
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,78 @@ 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. Every
205
+ later connection can use the same saved app — choose it under **App** and
206
+ type the scopes this connection needs, as the provider spells them. Each
207
+ connection keeps its own scopes, so one app serves any number of APIs from
208
+ the same provider (an ads API in one secret, a search API in another), and
209
+ **Reconnect** asks for that connection's scopes again. The provider decides
210
+ what the app may ask for; foldrun only carries the words. From a terminal:
211
+ `foldrun connect NAME --client <saved app> --scopes "<scopes>" --to <workspace>`.
212
+
213
+ How a connection is kept safe, whatever the provider:
214
+
215
+ - **PKCE** (S256) is on for every connection: a code stolen on its way back
216
+ is useless without the verifier only foldrun holds. An app with no client
217
+ secret (a *public* app) is allowed — PKCE is then what protects it, and
218
+ its refreshes send no secret. Turn PKCE off only for an app with a secret
219
+ whose provider refuses the parameters.
220
+ - **The browser that starts a connection is the one it must come back to.**
221
+ The consent link carries a cookie the callback checks; a link opened in
222
+ someone else's browser connects nothing.
223
+ - **From a terminal**, `foldrun connect --client` opens a page in the
224
+ dashboard where you, signed in, see the secret, the app, the scopes and
225
+ where it will be stored, and accept before the provider is asked.
226
+ - **Issuer.** Give a saved app its provider's issuer and the `iss` the
227
+ provider sends back (RFC 9207) must match it; without one, a returned
228
+ `iss` must at least be the authorize URL's own site.
229
+ - The flow's own parameters (`client_id`, `redirect_uri`, `scope`, `state`,
230
+ the PKCE pair) cannot be overridden by extra parameters or by a query
231
+ string in the authorize URL.
232
+ - A provider that **rotates refresh tokens** sends a new one with each
233
+ refresh; it replaces the old one in the vault, where that secret is kept.
234
+
235
+ ## Keyless cloud identity
236
+
237
+ The safest credential is none at all. A cloud that supports workload
238
+ identity federation — AWS (`AssumeRoleWithWebIdentity`), Google Cloud
239
+ (Workload Identity Federation), Microsoft Entra (federated credentials) —
240
+ can trust **foldrun's signature** instead of a key you store: a step proves
241
+ which account, workspace and agent it is, and the cloud gives it a
242
+ short-lived credential of its own.
243
+
244
+ 1. In your cloud, trust this install as an OIDC provider: its issuer is
245
+ shown by `foldrun identity` (and in the Connections form). Pin the
246
+ subject — `account:<account>:workspace:<workspace>:agent:<agent>` — at
247
+ least to your account, and the audience you choose.
248
+ 2. `foldrun secrets set AWS_ID --kind identity --audience sts.amazonaws.com`
249
+ (or Connections → Identity). Nothing secret is stored: only the audience.
250
+ 3. Name it in an agent's `secrets:`. The step gets **`$AWS_ID_FILE`**: a
251
+ token signed RS256, valid ten minutes, renewed while the step runs. Point
252
+ your cloud's SDK at it — for AWS, `AWS_ROLE_ARN` and
253
+ `AWS_WEB_IDENTITY_TOKEN_FILE=$AWS_ID_FILE`; for Google, an
254
+ `external_account` credential file whose `credential_source.file` is it;
255
+ for Entra, the token as the client assertion.
256
+
257
+ What it is and is not:
258
+
259
+ - A **workload-federation issuer**, not a sign-in provider: discovery
260
+ publishes what federation reads (issuer, JWKS, RS256, public subjects),
261
+ and nothing more.
262
+ - The subject and claims come from the platform's own record of the run,
263
+ never from the sandbox. A token is issued only to the step the identity
264
+ was granted to, only while its driver checks in, and never after the step
265
+ stops or ends.
266
+ - Stopping a step stops renewal; it cannot recall a token already issued,
267
+ which expires within ten minutes on its own.
268
+ - Within an account, a workspace or agent recreated under the same name
269
+ is the same subject: pin your trust policy to the account at least.
270
+ - Who may run or edit an agent that names the identity may use it — as
271
+ with every secret.
272
+ - Not on a laptop: only a sandboxed step on a platform whose operator set
273
+ `FOLDRUN_OIDC_ISSUER` gets a token.
274
+
176
275
  ## How they are kept
177
276
 
178
277
  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.5.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.7.0"
15
15
  },
16
16
  "files": [
17
17
  "bin",
package/src/commands.mjs CHANGED
@@ -3674,8 +3674,11 @@ async function connect(positional, flags) {
3674
3674
  }
3675
3675
 
3676
3676
  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");
3677
+ const pkce = flags["no-pkce"] !== true;
3678
+ const clientSecret = flags["client-secret"] ?? env("OAUTH_CLIENT_SECRET") ?? (await promptHidden(` client_secret${pkce ? " (empty for a public app)" : ""}: `));
3679
+ if (!clientId) throw new Error("client_id is required");
3680
+ // No secret is a public app, which PKCE alone protects (RFC 9700 §2.1.1).
3681
+ if (!clientSecret && !pkce) throw new Error("an app with no client_secret needs PKCE — drop --no-pkce");
3679
3682
  const scopes = flags.scopes ?? preset?.scopes_example ?? "";
3680
3683
 
3681
3684
  // One fixed loopback address, the same on every developer's machine, so an
@@ -3691,13 +3694,21 @@ async function connect(positional, flags) {
3691
3694
  const crypto = await import("node:crypto");
3692
3695
  const http = await import("node:http");
3693
3696
  const state = crypto.randomBytes(24).toString("hex");
3697
+ // PKCE (RFC 7636, S256): 32 random bytes as the verifier, its hash as the challenge.
3698
+ const verifier = pkce ? crypto.randomBytes(32).toString("base64url") : null;
3694
3699
  const authorize = new URL(authorizeUrl);
3700
+ // 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);
3702
+ for (const k of ["client_id", "redirect_uri", "response_type", "scope", "state", "code_challenge", "code_challenge_method"]) authorize.searchParams.delete(k);
3695
3703
  authorize.searchParams.set("response_type", "code");
3696
3704
  authorize.searchParams.set("client_id", clientId);
3697
3705
  authorize.searchParams.set("redirect_uri", redirectUri);
3698
3706
  authorize.searchParams.set("state", state);
3699
3707
  if (scopes) authorize.searchParams.set("scope", scopes);
3700
- for (const [k, v] of Object.entries(preset?.authorize_extra ?? {})) authorize.searchParams.set(k, v);
3708
+ if (verifier) {
3709
+ authorize.searchParams.set("code_challenge", crypto.createHash("sha256").update(verifier).digest("base64url"));
3710
+ authorize.searchParams.set("code_challenge_method", "S256");
3711
+ }
3701
3712
 
3702
3713
  // One request, then the listener closes. The state is the whole authority:
3703
3714
  // a callback with any other value is answered and ignored.
@@ -3733,7 +3744,11 @@ async function connect(positional, flags) {
3733
3744
  const exchange = await fetch(tokenUrl, {
3734
3745
  method: "POST",
3735
3746
  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(),
3747
+ body: new URLSearchParams({
3748
+ grant_type: "authorization_code", code, redirect_uri: redirectUri, client_id: clientId,
3749
+ ...(clientSecret ? { client_secret: clientSecret } : {}),
3750
+ ...(verifier ? { code_verifier: verifier } : {}),
3751
+ }).toString(),
3737
3752
  });
3738
3753
  const payload = /** @type {any} */ (await exchange.json().catch(() => ({})));
3739
3754
  if (!exchange.ok || (!payload.refresh_token && !payload.access_token)) {
@@ -3743,7 +3758,7 @@ async function connect(positional, flags) {
3743
3758
  // Store: the refresh recipe when there is one, the bare token otherwise.
3744
3759
  /** @type {any} */
3745
3760
  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 } : {}) }
3761
+ ? { 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
3762
  : { name, value: payload.access_token, workspace: workspaceName };
3748
3763
  if (url) {
3749
3764
  // The same PUT `secrets set` makes. A store that fails here has spent the
@@ -5738,6 +5753,30 @@ function shortValue(v) {
5738
5753
  return typeof v === "object" ? JSON.stringify(v).replace(/"([a-z_]+)":/g, "$1: ").replace(/,/g, ", ") : String(v);
5739
5754
  }
5740
5755
 
5756
+ /**
5757
+ * `foldrun identity [--to <workspace>]` — what a cloud must trust to accept
5758
+ * this account's keyless identities: the issuer, and each agent's subject.
5759
+ */
5760
+ async function identityCmd(flags) {
5761
+ const url = platformFor(flags, "identity");
5762
+ const ws = typeof flags.to === "string" ? flags.to : null;
5763
+ const info = await remoteCall(url, flags, `/api/identity${ws ? `?workspace=${enc(ws)}` : ""}`);
5764
+ if (flags.json === true) {
5765
+ console.log(JSON.stringify(info, null, 2));
5766
+ return 0;
5767
+ }
5768
+ if (!info.enabled) {
5769
+ console.log(`\n ${c.yellow("This install does not issue identity tokens")} ${c.dim("— its operator sets FOLDRUN_OIDC_ISSUER")}\n`);
5770
+ return 1;
5771
+ }
5772
+ console.log(`\n ${c.bold("Keyless cloud identity")}\n`);
5773
+ console.log(` issuer ${info.issuer}`);
5774
+ console.log(` subject ${info.subject}`);
5775
+ console.log(` tokens ${Math.round(info.tokenSeconds / 60)} minutes, renewed while a step runs`);
5776
+ 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`);
5777
+ return 0;
5778
+ }
5779
+
5741
5780
  /**
5742
5781
  * `foldrun jobs [--to <workspace>]` — the four jobs and every AGENTS.md
5743
5782
  * field of each: the account's value, the workspace's, and how the two
@@ -10493,9 +10532,17 @@ async function oauthClientsCmd(positional, flags) {
10493
10532
  const token_url = flags["token-url"] ?? preset?.token_url;
10494
10533
  if (!authorize_url || !token_url) throw new Error("--provider, or both --authorize-url and --token-url");
10495
10534
  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 } : {}) };
10535
+ const pkce = flags["no-pkce"] !== true;
10536
+ const client_secret = flags["client-secret"] ?? env("OAUTH_CLIENT_SECRET") ?? (await promptHidden(` client_secret${pkce ? " (empty for a public app)" : ""}: `));
10537
+ if (!client_id) throw new Error("client_id is required");
10538
+ if (!client_secret && !pkce) throw new Error("an app with no client_secret needs PKCE — drop --no-pkce");
10539
+ 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
+ const config = {
10541
+ 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 } : {}),
10543
+ ...(pkce ? {} : { pkce: false }),
10544
+ ...(typeof flags.issuer === "string" ? { issuer: flags.issuer } : {}),
10545
+ };
10499
10546
  await remoteCall(url, flags, "/api/oauth/clients", { method: "POST", body: JSON.stringify({ name, config }) });
10500
10547
  console.log(`\n ${c.green("✓")} saved ${name} ${c.dim(`— \`foldrun connect NAME --client ${name}\` runs a consent from it`)}\n`);
10501
10548
  return 0;
@@ -10512,9 +10559,15 @@ async function connectFromClient(name, flags) {
10512
10559
  const url = platformFor(flags, "connect --client");
10513
10560
  const workspace = typeof flags.to === "string" ? flags.to : undefined;
10514
10561
  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 } : {}) }) });
10562
+ // --scopes: this connection's own, instead of the saved client's default;
10563
+ // the platform keeps them with the connection for its reconnects.
10564
+ const scopes = typeof flags.scopes === "string" && flags.scopes.trim() ? { scopes: flags.scopes } : {};
10565
+ const started = await remoteCall(url, flags, "/api/oauth/start", { method: "POST", body: JSON.stringify({ client: flags.client, secret: name, ...(workspace ? { workspace } : {}), ...scopes }) });
10516
10566
  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`);
10567
+ // The platform answers a hand-off page: a signed-in person accepts the
10568
+ // connection there, then the provider's consent runs in that browser.
10569
+ const what = started.handoff ? "accept the connection, then consent" : "consent";
10570
+ 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
10571
  console.log(` ${c.dim("Waiting for the platform to store it…")}`);
10519
10572
  const deadline = Date.now() + 10 * 60 * 1000;
10520
10573
  while (Date.now() < deadline) {
@@ -10549,7 +10602,11 @@ async function secretOfKind(kind, flags) {
10549
10602
  }
10550
10603
  case "api": {
10551
10604
  const lines = [].concat(flags.header ?? []);
10552
- if (!lines.length) lines.push(await promptHidden(" header (Name: value): "));
10605
+ // --basic-user: HTTP Basic from a username and password (prompted, or
10606
+ // --basic-password); the platform builds the Authorization header.
10607
+ const basicUser = typeof flags["basic-user"] === "string" ? flags["basic-user"] : null;
10608
+ const basic = basicUser ? { basic: { username: basicUser, password: typeof flags["basic-password"] === "string" ? flags["basic-password"] : await promptHidden(" password: ") } } : {};
10609
+ if (!lines.length && !basicUser) lines.push(await promptHidden(" header (Name: value): "));
10553
10610
  const headers = Object.fromEntries(
10554
10611
  lines.filter(Boolean).map((l) => {
10555
10612
  const i = String(l).indexOf(":");
@@ -10558,7 +10615,23 @@ async function secretOfKind(kind, flags) {
10558
10615
  }),
10559
10616
  );
10560
10617
  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) };
10618
+ return { body: { api: { ...base, headers, ...basic } }, local: (k, n, b, s) => k.setApiSecret("default", n, b.api, s) };
10619
+ }
10620
+ case "identity": {
10621
+ // A keyless cloud identity: only the audience; the platform signs the
10622
+ // tokens (`foldrun identity` prints what the cloud must trust).
10623
+ if (typeof flags.audience !== "string" || !flags.audience.trim()) throw new Error("--kind identity needs --audience <value your cloud's trust expects in aud>");
10624
+ return { body: { identity: { audience: flags.audience } }, local: (k, n, b, s) => k.setIdentitySecret("default", n, b.identity, s) };
10625
+ }
10626
+ case "client-cert": {
10627
+ // A client certificate for mutual TLS. Bind it to the exact hosts it is
10628
+ // for with `foldrun secrets sites NAME host`; the egress proxy presents
10629
+ // it there. --export also puts it in the sandbox as files.
10630
+ const cert = read(flags["cert-file"], "--cert-file <cert.pem> — the certificate, chain included");
10631
+ const key = read(flags["key-file"], "--key-file <key.pem> — its private key");
10632
+ const passphrase = typeof flags.passphrase === "string" ? flags.passphrase : undefined;
10633
+ const client_cert = { cert, key, ...(passphrase ? { passphrase } : {}), ...(flags.export === true ? { export: true } : {}) };
10634
+ return { body: { client_cert }, local: (k, n, b, s) => k.setClientCertSecret("default", n, b.client_cert, s) };
10562
10635
  }
10563
10636
  case "service-account": {
10564
10637
  let j;
@@ -10568,7 +10641,9 @@ async function secretOfKind(kind, flags) {
10568
10641
  if (err instanceof SyntaxError) throw new Error(`${flags.file} is not JSON — the key file Google gives you`);
10569
10642
  throw err;
10570
10643
  }
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 ?? "" };
10644
+ // --subject: the user it acts as (domain-wide delegation, granted by the
10645
+ // provider's admin); without it the account acts as itself.
10646
+ 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
10647
  return { body: { service_account: sa }, local: (k, n, b, s) => k.setServiceAccountSecret("default", n, b.service_account, s) };
10573
10648
  }
10574
10649
  case "m2m": {
@@ -10579,7 +10654,7 @@ async function secretOfKind(kind, flags) {
10579
10654
  return { body: { oauth2 }, local: (k, n, b, s) => k.setOAuth2Secret("default", n, b.oauth2, s) };
10580
10655
  }
10581
10656
  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)`);
10657
+ 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
10658
  }
10584
10659
  }
10585
10660
 
@@ -11235,6 +11310,8 @@ export async function run(command, positional, flags, workspace, layout) {
11235
11310
  return rerunCmd(positional[0], flags, layout);
11236
11311
  case "account":
11237
11312
  return accountCmd(positional, flags);
11313
+ case "identity":
11314
+ return identityCmd(flags);
11238
11315
  case "jobs":
11239
11316
  return jobsCmd(flags, layout);
11240
11317
  case "schedule":