@oxy-hq/cli 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  `oxyc` — a `gh api`-shaped client for the Oxy HTTP API, plus the tooling that
4
4
  manages customer workspace repos.
5
5
 
6
- - **API client** — `api`, `routes`, `schema`, `openapi`, `login`, `whoami`, `assume`, `oltp`
6
+ - **API client** — `api`, `routes`, `schema`, `openapi`, `login`, `whoami`, `token`, `tokens`, `assume`, `oltp`
7
7
  - **Customer workspaces** — `list`, `new`, `import`, `doctor`, `update`, `adopt`, `launch`
8
8
  - **Custom apps** — `publish`, `init-ci`, `proxy`, `apps`
9
9
  - **Checks** — `checks run`
@@ -50,8 +50,9 @@ and `cache`.
50
50
  | --- | --- | --- |
51
51
  | `--env <name\|url>` | `production` | deployment to target |
52
52
  | `--target <url>` | — | explicit base URL; overrides `--env` |
53
- | `--token-env <VAR>` | `OXY_TOKEN` | env var holding the bearer token |
54
- | `--api-key-env <VAR>` | `OXY_API_KEY` | env var holding the key for `/external/api` |
53
+ | `--token-env <VAR>` | `OXY_TOKEN` | env var holding the bearer: any credential. **Named, it is the only source** — an unset variable is exit `4`, never a fallback to your login |
54
+ | `--api-key-env <VAR>` | `OXY_API_KEY` | env var holding the legacy API key or API token for `/external/api` |
55
+ | `--service-account <id>` | `OXY_SERVICE_ACCOUNT` | in GitHub Actions: the **ID** of the service account the OIDC exchange acts as |
55
56
  | `--org <slug>` | — | value for the `{org}` placeholder |
56
57
  | `--workspace <id>` | — | value for the `{workspace}` placeholder |
57
58
  | `--project <id>` | — | value for the `{project}` placeholder |
@@ -112,8 +113,12 @@ to `--env`, or from `--org` / `--workspace` / `--project`. An unresolvable
112
113
  placeholder is an error naming the flag that fills it — never a literal sent to
113
114
  the server.
114
115
 
115
- **Surfaces.** The path selects the credential: `/api/**` sends the bearer from
116
- `oxyc login`; `/external/api/**` sends `X-API-Key` from `$OXY_API_KEY`.
116
+ **Surfaces.** The path selects how the credential is sent. `/api/**` sends it
117
+ as a bearer — whichever one [resolves](#authentication). `/external/api/**`
118
+ sends `X-API-Key` from `$OXY_API_KEY`, which holds a legacy API key (`oxy_…`) or
119
+ an API token (`oxy_pat_…`, `oxy_sat_…`, `oxy_ci_…`). With none set, an
120
+ `OXY_TOKEN` holding either of those stands in for it, so one `OXY_TOKEN` covers
121
+ both surfaces. A session token never does.
117
122
 
118
123
  **`--paginate` is a heuristic.** Oxy sends `Link: rel="next"` on some endpoints
119
124
  and nothing on others; where there is no header, `oxyc` reads `next_offset`
@@ -145,13 +150,130 @@ route's surface and fleet role.
145
150
 
146
151
  ```bash
147
152
  oxyc login [--env <e>] [--login-env <e...>] [--assume <org> -r <why>]
148
- oxyc whoami [--json]
153
+ oxyc whoami [--json] # who the credential is, and what it can reach
149
154
  oxyc token # print the bearer, for a raw curl
150
- oxyc logout
155
+ oxyc logout # revoke the cached token, then forget it
156
+ oxyc tokens list [--json] # your personal access tokens
157
+ oxyc tokens revoke <id>
158
+ oxyc tokens revoke --current # end the token in OXY_TOKEN
159
+ oxyc tokens create # opens Account → Personal access tokens
160
+ oxyc tokens create --sandbox-agent --app <org>/<app> [--app …] [--hours 8] [--name …]
151
161
  ```
152
162
 
153
- `--login-env` is repeatable and comma-separated (`--login-env dev,staging`);
154
- the browser opens once per environment, in sequence.
163
+ **Every command resolves its credential the same way, first match wins:**
164
+
165
+ 1. **`OXY_TOKEN`** (or the variable `--token-env` names). It holds any
166
+ credential, and all are sent as a bearer: an API token — a personal access
167
+ token (`oxy_pat_…`), a service account token (`oxy_sat_…`), a CI token
168
+ (`oxy_ci_…`) or a sandbox agent token (`oxy_sbx_…`) — a legacy API key
169
+ (`oxy_…`), a publish token, or a session token. A blank value counts as
170
+ unset.
171
+ 2. **The login cache** — what `oxyc login` stored for this deployment.
172
+ 3. **GitHub OIDC**, in a GitHub Actions job granted `id-token: write`: the job's
173
+ OIDC token is exchanged, once per process, for a fifteen-minute token acting
174
+ as a service account, and that token is revoked when the command ends. See
175
+ [CI without a stored secret](#ci-without-a-stored-secret).
176
+
177
+ **Naming the variable stops the list at 1.** With `--token-env <VAR>`, and
178
+ always in `oxyc mcp`, an unset or empty variable is an auth error (exit `4`):
179
+ the login cache and the OIDC exchange are not tried. The flag says which
180
+ credential runs the command — an agent's scoped token, typically — so a typo in
181
+ the name must not quietly run it as whoever last ran `oxyc login` on the
182
+ machine. A person who wants `oxyc mcp` on their own login starts it with
183
+ `--login`. `OXY_API_KEY` is unaffected: a command that can use one on its own
184
+ (`checks run`, the sandbox verbs) still does.
185
+
186
+ `OXY_API_KEY` holds a legacy API key or an API token, and is honoured where it
187
+ always was — the `/external/api` surface, and `checks run`.
188
+
189
+ **Legacy API keys and API tokens are separate things.** A legacy API key is the
190
+ older `oxy_…` key: it reaches everything its owner can and can't be limited to
191
+ workspaces. It keeps working wherever it works today, but it is not a token —
192
+ `oxyc tokens` never lists or revokes one, and `oxyc whoami` calls it a
193
+ `Legacy API key`. They are extended and revoked in the web app, under
194
+ Settings → Workspace → Legacy API keys. Use an API token for anything new.
195
+
196
+ **`login`** runs a browser loopback flow. Against a current deployment it
197
+ receives a one-time code and exchanges it (PKCE) for a **personal access
198
+ token** named `oxyc on <hostname>`: revocable, ninety days, and replacing that
199
+ machine's previous one. No credential travels in a URL. Against a deployment
200
+ that predates the exchange it stores the session token the page hands back,
201
+ exactly as before, and says so — nothing needs upgrading first, in either
202
+ direction. `--login-env` is repeatable and comma-separated
203
+ (`--login-env dev,staging`); the browser opens once per environment, in
204
+ sequence.
205
+
206
+ **`logout`** revokes the cached token on the server, best-effort, then removes
207
+ it from this machine whatever the server said. It never touches `OXY_TOKEN`.
208
+
209
+ **`whoami`** makes a live call, so an expired or revoked credential fails here
210
+ rather than reading as fine from the cache. For a token it also prints what the
211
+ token is and what it can reach — `all access`, or one line per grant with the
212
+ role it is capped at, and any organization whose policy blocks it. For a legacy
213
+ API key it prints `Legacy API key`, and that it reaches everything its owner
214
+ can.
215
+
216
+ **`token`** prints the bearer on stdout and nothing else. Inside a GitHub
217
+ Actions job with nothing stored it prints the token the job's OIDC identity
218
+ exchanges for — like `gh auth token` — and that one is **not** revoked on exit,
219
+ since the output is the token. It still expires in fifteen minutes.
220
+
221
+ **`tokens`** manages personal access tokens, and the server allows that from a
222
+ **browser session only**: a token that could list, mint or revoke tokens could
223
+ outlive its own revocation. Since `login` now stores a token, `tokens list` and
224
+ `tokens revoke` usually answer with a pointer to the web app (Account →
225
+ Personal access tokens, `<deployment>/?settings=account.tokens`); they work
226
+ from a login that stored a session token. `tokens create` always opens that
227
+ page — a new token's secret is shown once, and a terminal is the wrong place.
228
+
229
+ **`tokens create --sandbox-agent`** is the exception, and the one command an
230
+ agent runs to get its own credential. It mints a **sandbox agent token**
231
+ (`oxy_sbx_…`): the [sandbox loop](#sandboxes) on one to five named apps, in
232
+ sandboxes the token created, for 8 hours by default (`--hours`, 1 to 168) —
233
+ and nothing else on the deployment.
234
+
235
+ ```bash
236
+ eval "$(oxyc tokens create --sandbox-agent --app acme/store --env dev)"
237
+ ```
238
+
239
+ It opens the same browser loopback as `login`, with the apps and the lifetime
240
+ on the page; the agent's operator approves once, under their own session.
241
+ Stdout is the single line `export OXY_TOKEN=oxy_sbx_…`, printed once, and
242
+ everything else is on stderr. It holds no credential while it runs, never
243
+ writes the credentials file, and revokes no other token. `--app` is
244
+ `<org>/<app>` and repeats; arguments are checked before the browser opens
245
+ (exit `2`). Only staff who can open the app's sandboxes can approve.
246
+
247
+ What comes back is checked before anything is printed: it must be an
248
+ `oxy_sbx_`, described as `sandbox_agent` and not all-access, for exactly the
249
+ apps named, expiring no later than the hours asked for. Anything the check
250
+ cannot read — an app entry with no slugs, an expiry that is missing or not a
251
+ time — is refused rather than skipped. A deployment that predates sandbox
252
+ agent tokens would otherwise hand back an ordinary login token with the
253
+ approver's whole reach. On any mismatch the command prints nothing, revokes
254
+ what was minted, and exits `8`.
255
+
256
+ With that token in `OXY_TOKEN`, `oxyc` behaves differently in five ways:
257
+
258
+ - **An app resolves against the token's own list** (`GET /api/auth/token`),
259
+ never `/api/admin/apps`. An app it was not minted for is exit `5`.
260
+ - **Read-back uses `/api/customer-apps/{id}/…`** — `invocations list`,
261
+ `invocations held` and `checks run` — since the token never reaches `/admin`.
262
+ - **Refused before any request, exit `2`:** an `--app-env` that is not
263
+ `dev-<handle>`; `publish` without `--app-env`, or with `--promote`;
264
+ `fn call`, `logs`, `invocations list` and `checks run` without `--app-env`;
265
+ `env show` of production or staging; `oxyc apps`, `tokens list` and
266
+ `tokens revoke <id>`; and `oxyc api` against any path.
267
+ - **`whoami`** prints the token's description — `kind`, `expires_at`, the
268
+ `apps` it reaches and its `minter` — and `--json` is that document as the
269
+ server sent it.
270
+ - **`tokens revoke --current`** ends it (`DELETE /api/auth/token`). Exit `0`
271
+ when it is revoked or was already dead. It works for any API token in the
272
+ variable; a cached login is ended with `logout` instead.
273
+
274
+ A refusal from the server carries a hint an agent can act on — never "try
275
+ `oxyc login` again". Exit `4` under this token means it expired, was revoked,
276
+ or its minter lost access: stop and report.
155
277
 
156
278
  Credentials live in the OS config directory under **`oxy`** — the same file the
157
279
  Rust `oxy login` wrote before it was removed, so an existing login still works:
@@ -159,8 +281,95 @@ Rust `oxy login` wrote before it was removed, so an existing login still works:
159
281
  - macOS: `~/Library/Application Support/oxy/credentials.json`
160
282
  - Linux: `$XDG_CONFIG_HOME/oxy/credentials.json`
161
283
 
162
- `OXY_CREDENTIALS_PATH` overrides the path. Caches are separate, under `oxyc`
163
- (`~/.cache/oxyc`). In CI set `OXY_TOKEN` instead of logging in.
284
+ An entry may now also carry `token_id` and `expires_at`; both are optional, and
285
+ a file written before they existed loads unchanged. `OXY_CREDENTIALS_PATH`
286
+ overrides the path. Caches are separate, under `oxyc` (`~/.cache/oxyc`).
287
+
288
+ ### CI without a stored secret
289
+
290
+ In a GitHub Actions job granted `id-token: write`, with no `OXY_TOKEN` set,
291
+ **any** `oxyc` command signs itself in — **as the service account the workflow
292
+ names, by its ID,** in `OXY_SERVICE_ACCOUNT` (or `--service-account`). It asks
293
+ GitHub for an OIDC token whose audience is `oxy:<the deployment's host>`, posts
294
+ it with the account's ID to `/api/auth/oidc/exchange`, and uses the
295
+ fifteen-minute `oxy_ci_…` token that comes back, revoking it on exit.
296
+
297
+ The audience is **worked out from the URL `oxyc` is pointed at** — the host of
298
+ the resolved `--env` / `--target`, lowercased, with a port only when it is not
299
+ the scheme's default: `oxy:app.oxygen-hq.com`, `oxy:aip.staging.oxy.tech`. It is
300
+ never asked of the deployment, and nothing a server says can change it. So the
301
+ token a job gives to a host is good at the deployment that calls itself by that
302
+ host and at no other: one handed to staging cannot be replayed at production,
303
+ and a server cannot talk the job into minting a token for somewhere else.
304
+ There is no shared fallback, and plain `oxy` is never requested.
305
+
306
+ A deployment that answers to a different address than the one `oxyc` was
307
+ pointed at — reached through an alias or a port-forward — refuses with
308
+ `wrong_audience` and names the address it does answer to: point `--target` at
309
+ that. One with no public URL configured (`OXY_API_URL`) takes no GitHub
310
+ sign-in at all; use `OXY_TOKEN` there.
311
+
312
+ What makes the deployment honour the job is a
313
+ **trust policy** on that service account, naming the repository, the workflow
314
+ file and the job's `environment:` — registered by `oxyc init-ci`, or in the web
315
+ app under Organization settings → API access → Service accounts → *the
316
+ account* → Trusted access.
317
+
318
+ **The account is always named, never guessed.** Anyone can register a trust
319
+ policy that names a repository they do not own, so the deployment matches a
320
+ run against the policies of the one account its workflow asked for and no
321
+ other. With no account named, `oxyc` attempts no exchange at all: `publish` and
322
+ `checks run` go straight to the app's registered publisher (below), and every
323
+ other command fails with "not authenticated", saying `OXY_SERVICE_ACCOUNT` is
324
+ what is missing.
325
+
326
+ **By ID, never by `<org>/<name>`.** An org's slug can be changed, and once the
327
+ org renames or is deleted the slug is free for anyone — who could create the
328
+ same account name under it and register a policy on your repository's public
329
+ ids. A workflow that said `acme/deployer` would then be naming *their*
330
+ account. An ID never changes hands, so it is the only thing the deployment
331
+ takes: anything else answers `400 service_account_required`, and `oxyc` does
332
+ not fall back from that. The ID is shown in the web app (Organization settings
333
+ → API access → Service accounts → *the account*), and `oxyc init-ci` writes it
334
+ for you. `oxyc` passes the value through untouched; the answer still says
335
+ `<org>/<name>`, so the log reads as it did.
336
+
337
+ ```yaml
338
+ permissions: { id-token: write, contents: read }
339
+ jobs:
340
+ deploy:
341
+ environment: production # the trust policy requires it
342
+ runs-on: ubuntu-latest
343
+ steps:
344
+ - uses: actions/checkout@v4
345
+ - uses: actions/setup-node@v4
346
+ with: { node-version: 20 }
347
+ # or any oxyc command the grants allow
348
+ - run: npx --yes @oxy-hq/cli publish --promote
349
+ env:
350
+ OXY_SERVICE_ACCOUNT: 3f2504e0-4f89-41d3-9a0c-0305e82c3301 # acme/deployer
351
+ ```
352
+
353
+ Each command exchanges for itself, which is all a job running one command
354
+ needs. Several policies of the named account matching one run is not an error:
355
+ the oldest is used. The exchange is rate-limited per client address (60 a
356
+ minute); a `429` is waited out once, for its `Retry-After` (at most 60 s), and
357
+ retried before it fails.
358
+
359
+ The `setup-oxyc` action (`sdk/setup-oxyc`) does the exchange once for a whole
360
+ job: it installs `oxyc`, exports `OXY_TOKEN` for the later steps and revokes
361
+ the token in a post step. **It is not published yet** — `uses:
362
+ oxy-hq/setup-oxyc@v1` does not resolve, and a job naming it fails at "Set up
363
+ job" — so nothing here depends on it.
364
+
365
+ A refused exchange says what to fix: `no_matching_policy` prints the
366
+ repository, workflow, ref and event the run presented and where to register a
367
+ policy on the named account; `missing_environment`, `self_hosted_runner` and
368
+ `pull_request_target` name the rule that was not met. A deployment with no
369
+ exchange route (404) reads as "not authenticated", with that reason — except
370
+ in `publish` and `checks run`, which fall back to the app's registered
371
+ publisher, as they also do on `no_matching_policy` and when no account is
372
+ named.
164
373
 
165
374
  ## Acting as an organization
166
375
 
@@ -243,7 +452,7 @@ the current directory while granting access to the customer's repo.
243
452
 
244
453
  ```bash
245
454
  oxyc publish [--env <e>] [--dir <path>] [--promote] [--build-only | --prebuilt] [--json] [--allow-function-lint] [--app-env <dev-handle>]
246
- oxyc init-ci [--app <org>/<app>] [--environment <name>] [--force]
455
+ oxyc init-ci [--app <org>/<app>] [--environment <name>] [--promote] [--force] [--no-register] [--setup-action]
247
456
  oxyc proxy [--port <n>] [--allow-writes] [--allow-events] [--yes]
248
457
  ```
249
458
 
@@ -262,11 +471,16 @@ channel unless `--promote`. `--env` defaults to **production**; name it.
262
471
  **`--prebuilt`** (with `--dir`) skips esbuild and refuses if a declared
263
472
  function's `functions/<name>.js` is missing. The pair splits CI so the job that
264
473
  runs package scripts never holds the credential.
265
- - **Auth**: the `--token-env` variable (`OXY_TOKEN`), then the login cache — or,
266
- in a GitHub Actions job with `id-token: write` and neither set, **trusted
267
- publishing**: the job's OIDC token is exchanged for a credential scoped to this
268
- one app. That needs the org **slug** and a publisher registered for the
269
- workflow (see `init-ci`).
474
+ - **Auth**: `OXY_TOKEN`, then the login cache — or, in a GitHub Actions job
475
+ with `id-token: write` and neither set, the job's OIDC token. (A variable
476
+ named with `--token-env` is the only source: unset, the publish stops.) Two exchanges are tried, in order, and the exchange happens last, after
477
+ the build. First the general one (`/api/auth/oidc/exchange`, audience `oxy:<the deployment's host>`):
478
+ a **trust policy** on a service account (see `init-ci`). Then, only when the
479
+ deployment has no such route (404) or answers `no_matching_policy`, the app's
480
+ own **registered publisher** (`/api/customer-apps/publish/oidc-exchange`,
481
+ audience `oxy-publish`) — the registration every workflow written before trust
482
+ policies relies on; it needs the org **slug**. Any other refusal is final: it
483
+ names something wrong with the run, which the older path would only hide.
270
484
  - **Provenance**: the checkout's `origin`, `HEAD` and branch (else `GITHUB_SHA` /
271
485
  `GITHUB_REF_NAME`) are recorded; a missing half or a dirty app directory is a
272
486
  warning, never a failure. The build id defaults to
@@ -287,12 +501,49 @@ channel unless `--promote`. `--env` defaults to **production**; name it.
287
501
  skipped. **`--allow-function-lint`** is the way past a false positive: every
288
502
  finding becomes a warning that names its rule — please open an issue with it.
289
503
 
290
- **`init-ci`** writes `.github/workflows/oxy-publish.yml` at the repo root: a
291
- `build` job (no id-token) that runs `publish --build-only`, and an
292
- environment-gated `publish` job whose only work is `publish --prebuilt` with
293
- OIDC. `--promote` makes that publish go live and adds a `checks run` step after
294
- it, when the manifest declares a check. It prints the `oxyc api …/publishers`
295
- call that registers the workflow.
504
+ **`init-ci`** does two things: writes the workflow, and registers the trust that
505
+ lets it publish.
506
+
507
+ *The workflow* is `.github/workflows/oxy-publish.yml` at the repo root: a
508
+ `build` job (no id-token) that runs `publish --build-only`, and a `publish` job
509
+ bound to a GitHub `environment:` (`--environment`, default `oxy-publish`) whose
510
+ only work is `publish --prebuilt`. That job uses no third-party action: each
511
+ step runs `npx @oxy-hq/cli@<this version>` and `oxyc` exchanges the job's OIDC
512
+ token for itself, acting as the service account `--service-account <org>/<name>`
513
+ names — by default `<app's org>/deployer`. **Here, and only here, the account is
514
+ given by name:** `init-ci` is run by a person, looks the account's ID up as that
515
+ person, and writes the ID into the workflow with the name beside it as a
516
+ comment (`OXY_SERVICE_ACCOUNT: 3f25…3301 # acme/deployer`). When it cannot
517
+ look the ID up — nobody logged in, no such account yet — it writes a
518
+ `<service-account-id>` placeholder and says so; it never writes the name as
519
+ the value. `--promote` makes the publish go live. **No `checks run` step is
520
+ written**, even when the manifest declares a check: the job's service-account
521
+ token is answered `403` on the check routes, which sit behind platform gates a
522
+ service account never passes, so the step could not succeed. The workflow
523
+ carries a one-line comment where it would be, and `init-ci` says so when it
524
+ writes one. Run the checks with a staff credential until `ci` tokens may.
525
+ `--setup-action` writes the same workflow around `uses: oxy-hq/setup-oxyc@v1`
526
+ instead, which exchanges once for the whole job. That action is not published
527
+ yet, so a workflow written with the flag cannot start until it is; `init-ci`
528
+ says so when it writes one.
529
+
530
+ *The registration* is a trust policy on that service account — this repository,
531
+ the workflow file, the environment — granted publishing this one app and
532
+ nothing else. `init-ci` creates the `deployer` account if none was named and it
533
+ does not exist, then the policy, and is idempotent: a second run finds both. A
534
+ named account is never created, and must be one of the app's own org — an
535
+ account's grants never reach another org, so `init-ci` refuses one. A service
536
+ account's name is lowercase words joined by single hyphens, starting with a
537
+ letter, 2–40 characters.
538
+
539
+ It can only register **when the caller can**: an org admin, logged in with a
540
+ browser-session credential — the server refuses those two creations to a token,
541
+ which is what a fresh `oxyc login` stores. When it cannot (not an admin, a token
542
+ login, offline, a deployment without trusted access, `--no-register`), the
543
+ workflow is still written and the command prints the exact steps instead: where
544
+ in the web app, the `oxyc api` calls with every id it managed to learn filled
545
+ in, and the older `…/publishers` registration for a deployment without trust
546
+ policies. Registration never fails the command.
296
547
 
297
548
  **`--app-env <dev-handle>`** moves one sandbox's build pointer instead of the
298
549
  draft/live channel — see [Sandboxes](#sandboxes). It needs a staff credential
@@ -420,13 +671,21 @@ difference: `checks run` talks to `/api/admin/**`, which is not the
420
671
  `oxyc login` (or `--token-env`) always wins when one is present.
421
672
 
422
673
  **In CI, nothing is stored.** With neither a token nor an API key set, a job
423
- holding `id-token: write` exchanges its GitHub OIDC token for the same
424
- short-lived, app-scoped publish credential `oxyc publish` uses, and drives
425
- `/api/customer-apps/**` instead — the same three handlers, mounted where a
426
- publish token may reach them. The exchange returns the app id, so no lookup is
427
- needed, and `<app>` must be `<org-slug>/<app-slug>` in this mode (a UUID names
428
- an app the exchange cannot verify a publisher for). A publish token set in
429
- `OXY_TOKEN` takes the same surface without minting.
674
+ holding `id-token: write` signs itself in from its GitHub OIDC token — the same
675
+ two exchanges `oxyc publish` tries, in the same order: a service account's
676
+ trust policy first, the app's registered publisher second — and drives
677
+ `/api/customer-apps/**` instead: the same three handlers, mounted where a
678
+ machine credential may reach them. A service account's token (`oxy_sat_…`,
679
+ `oxy_ci_…`) or a publish token set in `OXY_TOKEN` takes that surface too,
680
+ without minting; a personal token, a legacy API key or a session takes the admin
681
+ one. `OXY_API_KEY` is read **before** OIDC here: a job that set one has said
682
+ which credential it means.
683
+
684
+ The app id comes from the credential where it can. The publisher exchange
685
+ returns it, so `<app>` must be `<org-slug>/<app-slug>` on that path (a UUID
686
+ names an app it cannot verify a publisher for). A service account's token names
687
+ the apps it may publish, so a slug resolves against its own grants — and when
688
+ it cannot, the publisher exchange is tried before asking for a UUID.
430
689
 
431
690
  What that credential may do is deliberately small, and enforced server-side
432
691
  rather than by the client. An app-scoped token is **confined to its app**: it
@@ -444,24 +703,27 @@ it admitted any function.
444
703
  **A check runs against the app's live build** — the server resolves
445
704
  `published_build_id`, else `draft_build_id`, for the pre-flight lookup and for
446
705
  the execution alike. So `checks run` after a plain `oxyc publish` verifies the
447
- *previously promoted* code, not the draft just uploaded. `oxyc init-ci` writes
448
- the step only with `--promote`, where the build just published is the one the
449
- check runs, and only when the manifest declares a check. **With `--app-env`**
706
+ *previously promoted* code, not the draft just uploaded. (`oxyc init-ci` writes
707
+ no checks step at all today: a service account's `oxy_ci_` token is answered
708
+ `403` on these routes — the token's scope admits them for its own app, and the
709
+ routes' platform gates then refuse a caller with no platform standing.) **With
710
+ `--app-env`**
450
711
  it runs against that environment's own build instead — a sandbox's own
451
712
  publish, or staging's — and the report gains `environment` and each check's
452
713
  `invocationId`; the publish-token and OIDC paths are unchanged and work only
453
714
  against production, since every non-production operation needs a staff
454
715
  credential (see [Sandboxes](#sandboxes)).
455
716
 
456
- With a publish token, `<app>` must be the **UUID** — resolving a slug means
457
- listing every app, which such a token may not do. The OIDC path needs no id at
458
- all (the exchange returns it) but does need `<org-slug>/<app-slug>`, since the
459
- exchange is registered by slug.
717
+ With a publish token (`oxypublish_…`), `<app>` must be the **UUID** — resolving
718
+ a slug means listing every app, which such a token may not do. A service
719
+ account's token takes either: the UUID, or a slug it can match to one of its
720
+ own app-publish grants. The publisher exchange needs no id at all (it returns
721
+ it) but does need `<org-slug>/<app-slug>`, since it is registered by slug.
460
722
 
461
723
  **Exit codes:** `0` every check passed; `9` (`CHECK_FAILED`) at least one check
462
724
  failed or timed out; `1` the app declares no checks; `5` the app was not
463
725
  found; `4` no credential resolved, or the API rejected it (401/403 — an
464
- expired 90-day API key reads this way, not as a missing one); `2` a bad
726
+ expired legacy API key or API token reads this way, not as a missing one); `2` a bad
465
727
  `--timeout`, or a slug where the credential needs a UUID; `7` (`UNAVAILABLE`)
466
728
  the deployment answered the OIDC exchange without an `app_id`, meaning it
467
729
  predates trusted checks.
@@ -481,6 +743,10 @@ oxyc checks run <app> --app-env <name> # see "Checks" above
481
743
  oxyc invocations list <app> [--app-env] [--build] [--function] [--limit]
482
744
  oxyc invocations held <app> <invocation-id>
483
745
  oxyc logs <app> [--app-env] [--invocation] [--request] [--hours] [--limit]
746
+
747
+ oxyc env secret list <app> --app-env <name> # keys and flags, never a value
748
+ oxyc env secret set <app> <key> --app-env <name> (--value <text> | --value-env <VAR>)
749
+ oxyc env secret delete <app> <key> --app-env <name>
484
750
  ```
485
751
 
486
752
  A **sandbox** is a named, short-lived `dev-<handle>` environment of one custom
@@ -503,7 +769,8 @@ staging's tables (rows up to a size cap), then the build's OLTP migrations.
503
769
  The loop: `env create` → `publish --app-env` → `fn call` / `checks run
504
770
  --app-env` → `invocations list` / `held` to read back what ran → iterate from
505
771
  `publish` → `env delete --yes --wait` when done. `oxyc guide` has the same six
506
- lines, meant to sit in an agent's context.
772
+ lines — between the two an agent adds to mint and revoke its own token — meant
773
+ to sit in an agent's context.
507
774
 
508
775
  **`--app-env <environment>`** (`production`, `staging` or `dev-<handle>`,
509
776
  validated client-side — exit `2` on a malformed name) is a *different axis*
@@ -515,6 +782,21 @@ take it. A publish-token credential is refused before any request for
515
782
  `logs` refuse one outright, for any environment, because sandbox management
516
783
  is a staff console surface.
517
784
 
785
+ **An unattended agent drives this loop with a sandbox agent token**
786
+ (`oxy_sbx_…`), not a staff credential: `oxyc tokens create --sandbox-agent
787
+ --app <org>/<app>` mints one after a browser approval — see
788
+ [Authentication](#authentication) for what `oxyc` then refuses, and
789
+ `internal-docs/custom-app-sandboxes.md` §2 for the agent's procedure. With
790
+ it, `--app-env dev-<handle>` is required on every verb above that takes one.
791
+
792
+ **`env secret`** reads and writes one sandbox's own secrets, for any
793
+ credential that reaches the sandbox, and only a `dev-<handle>` environment —
794
+ never staging's or production's. `list` prints each key with `is_set`,
795
+ `required` and `inherits_staging`; no verb returns a value. `set` takes the
796
+ value from `--value`, or from an environment variable with `--value-env` so it
797
+ stays off the command line. A sandbox with no value of its own reads
798
+ staging's.
799
+
518
800
  **`env delete`** without `--yes` asks on a terminal and exits `8` (refused)
519
801
  off one, same as `oltp reset`. `--wait [seconds]` (default 120) polls until
520
802
  the teardown finishes and prints `{"name","status":"deleted"}`; past the
@@ -674,10 +956,29 @@ no terminal to ask on inside an MCP server), and `oxy_publish_sandbox`
674
956
  requires a `dev-<handle>` `appEnv` and refuses production, staging or a
675
957
  promote client-side — it can never reach the live channel.
676
958
 
959
+ **Its credential is the token variable and nothing else.** An agent runtime
960
+ starts `oxyc mcp` with the agent's own token in `OXY_TOKEN` (or the
961
+ `--token-env` variable); with the variable unset the server exits `4` before
962
+ serving a tool, rather than running on the `oxyc login` of whoever owns the
963
+ machine. To serve on your own login, start it with `--login`.
964
+
677
965
  ```bash
678
- claude mcp add oxyc -- npx -y @oxy-hq/cli mcp --env production
966
+ # an agent, on its own token
967
+ claude mcp add oxyc -e OXY_TOKEN=oxy_sbx_… -- npx -y @oxy-hq/cli mcp --env dev
968
+ # you, on your login
969
+ claude mcp add oxyc -- npx -y @oxy-hq/cli mcp --login --env production
679
970
  ```
680
971
 
972
+ **A sandbox agent token (`oxy_sbx_…`) is served a different, smaller list**:
973
+ the ten sandbox-loop tools, `oxy_whoami` (the token's `kind`, `expires_at`,
974
+ `apps` and `minter`), and four that exist only for it — `oxy_env_secret_list`,
975
+ `oxy_env_secret_set` and `oxy_env_secret_delete`, which accept only a
976
+ `dev-<handle>` `appEnv`, and `oxy_token_revoke`, which needs `confirm: true`.
977
+ `oxy_request`, `oxy_routes`, `oxy_schema` and the preview tools are dropped,
978
+ and refused if called by name: the server answers that token `404` on all of
979
+ them, and a tool that can only fail still costs its schema every turn. A dead
980
+ token ends the server at startup with exit `4`.
981
+
681
982
  **`guide`** prints a page to paste into `AGENTS.md` / `CLAUDE.md`.
682
983
  **`skills install`** symlinks the six bundled Claude skills into
683
984
  `~/.claude/skills`; it refuses to run from an `npx` cache, whose symlinks dangle
@@ -703,8 +1004,10 @@ once npm reclaims it.
703
1004
 
704
1005
  | Variable | Default | Meaning |
705
1006
  | --- | --- | --- |
706
- | `OXY_TOKEN` | — | bearer token, overriding the login cache (the CI path) |
707
- | `OXY_API_KEY` | — | key for the `/external/api` surface |
1007
+ | `OXY_TOKEN` | — | any credential, sent as a bearer; overrides the login cache and GitHub OIDC |
1008
+ | `OXY_API_KEY` | — | a legacy API key or an API token, for the `/external/api` surface and for `checks run` |
1009
+ | `OXY_SERVICE_ACCOUNT` | — | the **ID** of the service account a GitHub OIDC exchange acts as (`--service-account`); never `<org>/<name>` |
1010
+ | `OXY_AGENT` | detected | the name of the agent driving `oxyc`, sent in the user agent — see below |
708
1011
  | `OXY_CREDENTIALS_PATH` | OS config dir | the shared `credentials.json` |
709
1012
  | `OXYC_ORG` | `oxy-hq` | GitHub org the customer repos live in |
710
1013
  | `OXYC_CUSTOMER_TOPIC` | `oxy-customer` | topic that registers a customer repo |
@@ -724,6 +1027,17 @@ once npm reclaims it.
724
1027
  | `OXYC_DRY_RUN` | — | `1` makes `launch` print the command instead of running it |
725
1028
  | `NO_COLOR` / `FORCE_COLOR` | — | force plain / coloured output |
726
1029
 
1030
+ **An agent should set `OXY_AGENT`.** Every request `oxyc` makes to a deployment
1031
+ carries the user agent `oxyc/<version>`, with `agent/<label>` after it when an
1032
+ agent is driving, and `mcp` last when the call came through `oxyc mcp` —
1033
+ `oxyc/0.6.0 agent/release-bot mcp`. The server records it on audit rows and in
1034
+ each token's usage, and it is the one thing there that tells an agent from the
1035
+ engineer running the same commands. The label is `OXY_AGENT`, held to
1036
+ lowercase letters, digits, `.`, `_` and `-` and to 32 characters (anything else
1037
+ becomes `-`). With `OXY_AGENT` unset, Claude Code is recognised by the
1038
+ `CLAUDECODE` variable it exports and labelled `claude-code`. It identifies
1039
+ honest callers in a log; it is not a credential and grants nothing.
1040
+
727
1041
  ## Output contract
728
1042
 
729
1043
  **stdout carries the response body and nothing else.** Progress, warnings,