@oxy-hq/cli 0.6.0 → 0.8.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`, `login-link`, `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,131 @@ 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 login-link [--next <path>] [--json] [--open] # a one-time URL that signs a browser in
156
+ oxyc logout # revoke the cached token, then forget it
157
+ oxyc tokens list [--json] # your personal access tokens
158
+ oxyc tokens revoke <id>
159
+ oxyc tokens revoke --current # end the token in OXY_TOKEN
160
+ oxyc tokens create # opens Account → Personal access tokens
161
+ oxyc tokens create --sandbox-agent --app <org>/<app> [--app …] [--hours 8] [--name …]
151
162
  ```
152
163
 
153
- `--login-env` is repeatable and comma-separated (`--login-env dev,staging`);
154
- the browser opens once per environment, in sequence.
164
+ **Every command resolves its credential the same way, first match wins:**
165
+
166
+ 1. **`OXY_TOKEN`** (or the variable `--token-env` names). It holds any
167
+ credential, and all are sent as a bearer: an API token — a personal access
168
+ token (`oxy_pat_…`), a service account token (`oxy_sat_…`), a CI token
169
+ (`oxy_ci_…`) or a sandbox agent token (`oxy_sbx_…`) — a legacy API key
170
+ (`oxy_…`), a publish token, or a session token. A blank value counts as
171
+ unset.
172
+ 2. **The login cache** — what `oxyc login` stored for this deployment.
173
+ 3. **GitHub OIDC**, in a GitHub Actions job granted `id-token: write`: the job's
174
+ OIDC token is exchanged, once per process, for a fifteen-minute token acting
175
+ as a service account, and that token is revoked when the command ends. See
176
+ [CI without a stored secret](#ci-without-a-stored-secret).
177
+
178
+ **Naming the variable stops the list at 1.** With `--token-env <VAR>`, and
179
+ always in `oxyc mcp`, an unset or empty variable is an auth error (exit `4`):
180
+ the login cache and the OIDC exchange are not tried. The flag says which
181
+ credential runs the command — an agent's scoped token, typically — so a typo in
182
+ the name must not quietly run it as whoever last ran `oxyc login` on the
183
+ machine. A person who wants `oxyc mcp` on their own login starts it with
184
+ `--login`. `OXY_API_KEY` is unaffected: a command that can use one on its own
185
+ (`checks run`, the sandbox verbs) still does.
186
+
187
+ `OXY_API_KEY` holds a legacy API key or an API token, and is honoured where it
188
+ always was — the `/external/api` surface, and `checks run`.
189
+
190
+ **Legacy API keys and API tokens are separate things.** A legacy API key is the
191
+ older `oxy_…` key: it reaches everything its owner can and can't be limited to
192
+ workspaces. It keeps working wherever it works today, but it is not a token —
193
+ `oxyc tokens` never lists or revokes one, and `oxyc whoami` calls it a
194
+ `Legacy API key`. They are extended and revoked in the web app, under
195
+ Settings → Workspace → Legacy API keys. Use an API token for anything new.
196
+
197
+ **`login`** runs a browser loopback flow. Against a current deployment it
198
+ receives a one-time code and exchanges it (PKCE) for a **personal access
199
+ token** named `oxyc on <hostname>`: revocable, ninety days, and replacing that
200
+ machine's previous one. No credential travels in a URL. Against a deployment
201
+ that predates the exchange it stores the session token the page hands back,
202
+ exactly as before, and says so — nothing needs upgrading first, in either
203
+ direction. `--login-env` is repeatable and comma-separated
204
+ (`--login-env dev,staging`); the browser opens once per environment, in
205
+ sequence.
206
+
207
+ **`logout`** revokes the cached token on the server, best-effort, then removes
208
+ it from this machine whatever the server said. It never touches `OXY_TOKEN`.
209
+
210
+ **`whoami`** makes a live call, so an expired or revoked credential fails here
211
+ rather than reading as fine from the cache. For a token it also prints what the
212
+ token is and what it can reach — `all access`, or one line per grant with the
213
+ role it is capped at, and any organization whose policy blocks it. For a legacy
214
+ API key it prints `Legacy API key`, and that it reaches everything its owner
215
+ can.
216
+
217
+ **`token`** prints the bearer on stdout and nothing else. Inside a GitHub
218
+ Actions job with nothing stored it prints the token the job's OIDC identity
219
+ exchanges for — like `gh auth token` — and that one is **not** revoked on exit,
220
+ since the output is the token. It still expires in fifteen minutes.
221
+
222
+ **`tokens`** manages personal access tokens, and the server allows that from a
223
+ **browser session only**: a token that could list, mint or revoke tokens could
224
+ outlive its own revocation. Since `login` now stores a token, `tokens list` and
225
+ `tokens revoke` usually answer with a pointer to the web app (Account →
226
+ Personal access tokens, `<deployment>/?settings=account.tokens`); they work
227
+ from a login that stored a session token. `tokens create` always opens that
228
+ page — a new token's secret is shown once, and a terminal is the wrong place.
229
+
230
+ **`tokens create --sandbox-agent`** is the exception, and the one command an
231
+ agent runs to get its own credential. It mints a **sandbox agent token**
232
+ (`oxy_sbx_…`): the [sandbox loop](#sandboxes) on one to five named apps, in
233
+ sandboxes the token created, for 8 hours by default (`--hours`, 1 to 168) —
234
+ and nothing else on the deployment.
235
+
236
+ ```bash
237
+ eval "$(oxyc tokens create --sandbox-agent --app acme/store --env dev)"
238
+ ```
239
+
240
+ It opens the same browser loopback as `login`, with the apps and the lifetime
241
+ on the page; the agent's operator approves once, under their own session.
242
+ Stdout is the single line `export OXY_TOKEN=oxy_sbx_…`, printed once, and
243
+ everything else is on stderr. It holds no credential while it runs, never
244
+ writes the credentials file, and revokes no other token. `--app` is
245
+ `<org>/<app>` and repeats; arguments are checked before the browser opens
246
+ (exit `2`). Only staff who can open the app's sandboxes can approve.
247
+
248
+ What comes back is checked before anything is printed: it must be an
249
+ `oxy_sbx_`, described as `sandbox_agent` and not all-access, for exactly the
250
+ apps named, expiring no later than the hours asked for. Anything the check
251
+ cannot read — an app entry with no slugs, an expiry that is missing or not a
252
+ time — is refused rather than skipped. A deployment that predates sandbox
253
+ agent tokens would otherwise hand back an ordinary login token with the
254
+ approver's whole reach. On any mismatch the command prints nothing, revokes
255
+ what was minted, and exits `8`.
256
+
257
+ With that token in `OXY_TOKEN`, `oxyc` behaves differently in five ways:
258
+
259
+ - **An app resolves against the token's own list** (`GET /api/auth/token`),
260
+ never `/api/admin/apps`. An app it was not minted for is exit `5`.
261
+ - **Read-back uses `/api/customer-apps/{id}/…`** — `invocations list`,
262
+ `invocations held` and `checks run` — since the token never reaches `/admin`.
263
+ - **Refused before any request, exit `2`:** an `--app-env` that is not
264
+ `dev-<handle>`; `publish` without `--app-env`, or with `--promote`;
265
+ `fn call`, `logs`, `invocations list` and `checks run` without `--app-env`;
266
+ `env show` of production or staging; `oxyc apps`, `tokens list` and
267
+ `tokens revoke <id>`; and `oxyc api` against any path.
268
+ - **`whoami`** prints the token's description — `kind`, `expires_at`, the
269
+ `apps` it reaches and its `minter` — and `--json` is that document as the
270
+ server sent it.
271
+ - **`tokens revoke --current`** ends it (`DELETE /api/auth/token`). Exit `0`
272
+ when it is revoked or was already dead. It works for any API token in the
273
+ variable; a cached login is ended with `logout` instead.
274
+
275
+ A refusal from the server carries a hint an agent can act on — never "try
276
+ `oxyc login` again". Exit `4` under this token means it expired, was revoked,
277
+ or its minter lost access: stop and report.
155
278
 
156
279
  Credentials live in the OS config directory under **`oxy`** — the same file the
157
280
  Rust `oxy login` wrote before it was removed, so an existing login still works:
@@ -159,8 +282,135 @@ Rust `oxy login` wrote before it was removed, so an existing login still works:
159
282
  - macOS: `~/Library/Application Support/oxy/credentials.json`
160
283
  - Linux: `$XDG_CONFIG_HOME/oxy/credentials.json`
161
284
 
162
- `OXY_CREDENTIALS_PATH` overrides the path. Caches are separate, under `oxyc`
163
- (`~/.cache/oxyc`). In CI set `OXY_TOKEN` instead of logging in.
285
+ An entry may now also carry `token_id` and `expires_at`; both are optional, and
286
+ a file written before they existed loads unchanged. `OXY_CREDENTIALS_PATH`
287
+ overrides the path. Caches are separate, under `oxyc` (`~/.cache/oxyc`).
288
+
289
+ ### A signed-in browser for an agent
290
+
291
+ Sign-in is passwordless — Google or GitHub OAuth, or a magic link — and an
292
+ automation agent can finish neither. **`login-link`** prints a one-time URL
293
+ that signs a browser in with the credential `oxyc` is running on, so the whole
294
+ sign-in is one navigation:
295
+
296
+ ```bash
297
+ browser_navigate("$(oxyc login-link --env dev --next /ide)") # Playwright MCP
298
+ ```
299
+
300
+ ```ts
301
+ // a Playwright script
302
+ const link = execFileSync("oxyc", ["login-link", "--env", "dev", "--next", "/ide"], { encoding: "utf8" });
303
+ await page.goto(link.trim());
304
+ ```
305
+
306
+ Stdout is the URL and nothing else; what the link is goes to stderr.
307
+
308
+ - **The link works once and expires in minutes** (five, at the time of
309
+ writing — the server's `expires_at`). The bearer is never in it: `oxyc`
310
+ trades the token for a single-use ticket (`POST /api/auth/browser-ticket`),
311
+ and the ticket and `--next` ride the URL's fragment, which is not sent to a
312
+ server and so reaches no access log.
313
+ - **The session is bounded by the token.** It reaches what the token reaches
314
+ and nothing more, lasts at most the `session_seconds` the server answers
315
+ (stderr states it in hours), ends sooner if the token is revoked or expires,
316
+ and cannot manage tokens.
317
+ - **It needs a personal access token** (`oxy_pat_…`) — what `oxyc login`
318
+ stores. Any other credential is exit `4` with the next step: a session token
319
+ from an older login, a service account, CI or publish token, a legacy API
320
+ key. A sandbox agent token is exit `2`, before any request. A deployment that
321
+ predates sign-in links is exit `5`.
322
+ - **`--next <path>`** is where the browser lands: a path on the deployment
323
+ starting with a single `/`. A URL, `//host` or a backslash is exit `2`, and
324
+ no ticket is spent on it.
325
+ - **`--json`** prints `{ "url", "expires_at", "session_seconds" }` instead.
326
+ **`--open`** also opens the link in your default browser — which uses it, so
327
+ the printed URL will not work a second time.
328
+
329
+ ### CI without a stored secret
330
+
331
+ In a GitHub Actions job granted `id-token: write`, with no `OXY_TOKEN` set,
332
+ **any** `oxyc` command signs itself in — **as the service account the workflow
333
+ names, by its ID,** in `OXY_SERVICE_ACCOUNT` (or `--service-account`). It asks
334
+ GitHub for an OIDC token whose audience is `oxy:<the deployment's host>`, posts
335
+ it with the account's ID to `/api/auth/oidc/exchange`, and uses the
336
+ fifteen-minute `oxy_ci_…` token that comes back, revoking it on exit.
337
+
338
+ The audience is **worked out from the URL `oxyc` is pointed at** — the host of
339
+ the resolved `--env` / `--target`, lowercased, with a port only when it is not
340
+ the scheme's default: `oxy:app.oxygen-hq.com`, `oxy:aip.staging.oxy.tech`. It is
341
+ never asked of the deployment, and nothing a server says can change it. So the
342
+ token a job gives to a host is good at the deployment that calls itself by that
343
+ host and at no other: one handed to staging cannot be replayed at production,
344
+ and a server cannot talk the job into minting a token for somewhere else.
345
+ There is no shared fallback, and plain `oxy` is never requested.
346
+
347
+ A deployment that answers to a different address than the one `oxyc` was
348
+ pointed at — reached through an alias or a port-forward — refuses with
349
+ `wrong_audience` and names the address it does answer to: point `--target` at
350
+ that. One with no public URL configured (`OXY_API_URL`) takes no GitHub
351
+ sign-in at all; use `OXY_TOKEN` there.
352
+
353
+ What makes the deployment honour the job is a
354
+ **trust policy** on that service account, naming the repository, the workflow
355
+ file and the job's `environment:` — registered by `oxyc init-ci`, or in the web
356
+ app under Organization settings → API access → Service accounts → *the
357
+ account* → Trusted access.
358
+
359
+ **The account is always named, never guessed.** Anyone can register a trust
360
+ policy that names a repository they do not own, so the deployment matches a
361
+ run against the policies of the one account its workflow asked for and no
362
+ other. With no account named, `oxyc` attempts no exchange at all: `publish` and
363
+ `checks run` go straight to the app's registered publisher (below), and every
364
+ other command fails with "not authenticated", saying `OXY_SERVICE_ACCOUNT` is
365
+ what is missing.
366
+
367
+ **By ID, never by `<org>/<name>`.** An org's slug can be changed, and once the
368
+ org renames or is deleted the slug is free for anyone — who could create the
369
+ same account name under it and register a policy on your repository's public
370
+ ids. A workflow that said `acme/deployer` would then be naming *their*
371
+ account. An ID never changes hands, so it is the only thing the deployment
372
+ takes: anything else answers `400 service_account_required`, and `oxyc` does
373
+ not fall back from that. The ID is shown in the web app (Organization settings
374
+ → API access → Service accounts → *the account*), and `oxyc init-ci` writes it
375
+ for you. `oxyc` passes the value through untouched; the answer still says
376
+ `<org>/<name>`, so the log reads as it did.
377
+
378
+ ```yaml
379
+ permissions: { id-token: write, contents: read }
380
+ jobs:
381
+ deploy:
382
+ environment: production # the trust policy requires it
383
+ runs-on: ubuntu-latest
384
+ steps:
385
+ - uses: actions/checkout@v4
386
+ - uses: actions/setup-node@v4
387
+ with: { node-version: 20 }
388
+ # or any oxyc command the grants allow
389
+ - run: npx --yes @oxy-hq/cli publish --promote
390
+ env:
391
+ OXY_SERVICE_ACCOUNT: 3f2504e0-4f89-41d3-9a0c-0305e82c3301 # acme/deployer
392
+ ```
393
+
394
+ Each command exchanges for itself, which is all a job running one command
395
+ needs. Several policies of the named account matching one run is not an error:
396
+ the oldest is used. The exchange is rate-limited per client address (60 a
397
+ minute); a `429` is waited out once, for its `Retry-After` (at most 60 s), and
398
+ retried before it fails.
399
+
400
+ The `setup-oxyc` action (`sdk/setup-oxyc`) does the exchange once for a whole
401
+ job: it installs `oxyc`, exports `OXY_TOKEN` for the later steps and revokes
402
+ the token in a post step. **It is not published yet** — `uses:
403
+ oxy-hq/setup-oxyc@v1` does not resolve, and a job naming it fails at "Set up
404
+ job" — so nothing here depends on it.
405
+
406
+ A refused exchange says what to fix: `no_matching_policy` prints the
407
+ repository, workflow, ref and event the run presented and where to register a
408
+ policy on the named account; `missing_environment`, `self_hosted_runner` and
409
+ `pull_request_target` name the rule that was not met. A deployment with no
410
+ exchange route (404) reads as "not authenticated", with that reason — except
411
+ in `publish` and `checks run`, which fall back to the app's registered
412
+ publisher, as they also do on `no_matching_policy` and when no account is
413
+ named.
164
414
 
165
415
  ## Acting as an organization
166
416
 
@@ -243,7 +493,7 @@ the current directory while granting access to the customer's repo.
243
493
 
244
494
  ```bash
245
495
  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]
496
+ oxyc init-ci [--app <org>/<app>] [--environment <name>] [--promote] [--force] [--no-register] [--setup-action]
247
497
  oxyc proxy [--port <n>] [--allow-writes] [--allow-events] [--yes]
248
498
  ```
249
499
 
@@ -262,11 +512,16 @@ channel unless `--promote`. `--env` defaults to **production**; name it.
262
512
  **`--prebuilt`** (with `--dir`) skips esbuild and refuses if a declared
263
513
  function's `functions/<name>.js` is missing. The pair splits CI so the job that
264
514
  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`).
515
+ - **Auth**: `OXY_TOKEN`, then the login cache — or, in a GitHub Actions job
516
+ with `id-token: write` and neither set, the job's OIDC token. (A variable
517
+ named with `--token-env` is the only source: unset, the publish stops.) Two exchanges are tried, in order, and the exchange happens last, after
518
+ the build. First the general one (`/api/auth/oidc/exchange`, audience `oxy:<the deployment's host>`):
519
+ a **trust policy** on a service account (see `init-ci`). Then, only when the
520
+ deployment has no such route (404) or answers `no_matching_policy`, the app's
521
+ own **registered publisher** (`/api/customer-apps/publish/oidc-exchange`,
522
+ audience `oxy-publish`) — the registration every workflow written before trust
523
+ policies relies on; it needs the org **slug**. Any other refusal is final: it
524
+ names something wrong with the run, which the older path would only hide.
270
525
  - **Provenance**: the checkout's `origin`, `HEAD` and branch (else `GITHUB_SHA` /
271
526
  `GITHUB_REF_NAME`) are recorded; a missing half or a dirty app directory is a
272
527
  warning, never a failure. The build id defaults to
@@ -287,12 +542,49 @@ channel unless `--promote`. `--env` defaults to **production**; name it.
287
542
  skipped. **`--allow-function-lint`** is the way past a false positive: every
288
543
  finding becomes a warning that names its rule — please open an issue with it.
289
544
 
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.
545
+ **`init-ci`** does two things: writes the workflow, and registers the trust that
546
+ lets it publish.
547
+
548
+ *The workflow* is `.github/workflows/oxy-publish.yml` at the repo root: a
549
+ `build` job (no id-token) that runs `publish --build-only`, and a `publish` job
550
+ bound to a GitHub `environment:` (`--environment`, default `oxy-publish`) whose
551
+ only work is `publish --prebuilt`. That job uses no third-party action: each
552
+ step runs `npx @oxy-hq/cli@<this version>` and `oxyc` exchanges the job's OIDC
553
+ token for itself, acting as the service account `--service-account <org>/<name>`
554
+ names — by default `<app's org>/deployer`. **Here, and only here, the account is
555
+ given by name:** `init-ci` is run by a person, looks the account's ID up as that
556
+ person, and writes the ID into the workflow with the name beside it as a
557
+ comment (`OXY_SERVICE_ACCOUNT: 3f25…3301 # acme/deployer`). When it cannot
558
+ look the ID up — nobody logged in, no such account yet — it writes a
559
+ `<service-account-id>` placeholder and says so; it never writes the name as
560
+ the value. `--promote` makes the publish go live. **No `checks run` step is
561
+ written**, even when the manifest declares a check: the job's service-account
562
+ token is answered `403` on the check routes, which sit behind platform gates a
563
+ service account never passes, so the step could not succeed. The workflow
564
+ carries a one-line comment where it would be, and `init-ci` says so when it
565
+ writes one. Run the checks with a staff credential until `ci` tokens may.
566
+ `--setup-action` writes the same workflow around `uses: oxy-hq/setup-oxyc@v1`
567
+ instead, which exchanges once for the whole job. That action is not published
568
+ yet, so a workflow written with the flag cannot start until it is; `init-ci`
569
+ says so when it writes one.
570
+
571
+ *The registration* is a trust policy on that service account — this repository,
572
+ the workflow file, the environment — granted publishing this one app and
573
+ nothing else. `init-ci` creates the `deployer` account if none was named and it
574
+ does not exist, then the policy, and is idempotent: a second run finds both. A
575
+ named account is never created, and must be one of the app's own org — an
576
+ account's grants never reach another org, so `init-ci` refuses one. A service
577
+ account's name is lowercase words joined by single hyphens, starting with a
578
+ letter, 2–40 characters.
579
+
580
+ It can only register **when the caller can**: an org admin, logged in with a
581
+ browser-session credential — the server refuses those two creations to a token,
582
+ which is what a fresh `oxyc login` stores. When it cannot (not an admin, a token
583
+ login, offline, a deployment without trusted access, `--no-register`), the
584
+ workflow is still written and the command prints the exact steps instead: where
585
+ in the web app, the `oxyc api` calls with every id it managed to learn filled
586
+ in, and the older `…/publishers` registration for a deployment without trust
587
+ policies. Registration never fails the command.
296
588
 
297
589
  **`--app-env <dev-handle>`** moves one sandbox's build pointer instead of the
298
590
  draft/live channel — see [Sandboxes](#sandboxes). It needs a staff credential
@@ -420,13 +712,21 @@ difference: `checks run` talks to `/api/admin/**`, which is not the
420
712
  `oxyc login` (or `--token-env`) always wins when one is present.
421
713
 
422
714
  **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.
715
+ holding `id-token: write` signs itself in from its GitHub OIDC token — the same
716
+ two exchanges `oxyc publish` tries, in the same order: a service account's
717
+ trust policy first, the app's registered publisher second — and drives
718
+ `/api/customer-apps/**` instead: the same three handlers, mounted where a
719
+ machine credential may reach them. A service account's token (`oxy_sat_…`,
720
+ `oxy_ci_…`) or a publish token set in `OXY_TOKEN` takes that surface too,
721
+ without minting; a personal token, a legacy API key or a session takes the admin
722
+ one. `OXY_API_KEY` is read **before** OIDC here: a job that set one has said
723
+ which credential it means.
724
+
725
+ The app id comes from the credential where it can. The publisher exchange
726
+ returns it, so `<app>` must be `<org-slug>/<app-slug>` on that path (a UUID
727
+ names an app it cannot verify a publisher for). A service account's token names
728
+ the apps it may publish, so a slug resolves against its own grants — and when
729
+ it cannot, the publisher exchange is tried before asking for a UUID.
430
730
 
431
731
  What that credential may do is deliberately small, and enforced server-side
432
732
  rather than by the client. An app-scoped token is **confined to its app**: it
@@ -444,24 +744,27 @@ it admitted any function.
444
744
  **A check runs against the app's live build** — the server resolves
445
745
  `published_build_id`, else `draft_build_id`, for the pre-flight lookup and for
446
746
  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`**
747
+ *previously promoted* code, not the draft just uploaded. (`oxyc init-ci` writes
748
+ no checks step at all today: a service account's `oxy_ci_` token is answered
749
+ `403` on these routes — the token's scope admits them for its own app, and the
750
+ routes' platform gates then refuse a caller with no platform standing.) **With
751
+ `--app-env`**
450
752
  it runs against that environment's own build instead — a sandbox's own
451
753
  publish, or staging's — and the report gains `environment` and each check's
452
754
  `invocationId`; the publish-token and OIDC paths are unchanged and work only
453
755
  against production, since every non-production operation needs a staff
454
756
  credential (see [Sandboxes](#sandboxes)).
455
757
 
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.
758
+ With a publish token (`oxypublish_…`), `<app>` must be the **UUID** — resolving
759
+ a slug means listing every app, which such a token may not do. A service
760
+ account's token takes either: the UUID, or a slug it can match to one of its
761
+ own app-publish grants. The publisher exchange needs no id at all (it returns
762
+ it) but does need `<org-slug>/<app-slug>`, since it is registered by slug.
460
763
 
461
764
  **Exit codes:** `0` every check passed; `9` (`CHECK_FAILED`) at least one check
462
765
  failed or timed out; `1` the app declares no checks; `5` the app was not
463
766
  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
767
+ expired legacy API key or API token reads this way, not as a missing one); `2` a bad
465
768
  `--timeout`, or a slug where the credential needs a UUID; `7` (`UNAVAILABLE`)
466
769
  the deployment answered the OIDC exchange without an `app_id`, meaning it
467
770
  predates trusted checks.
@@ -481,6 +784,10 @@ oxyc checks run <app> --app-env <name> # see "Checks" above
481
784
  oxyc invocations list <app> [--app-env] [--build] [--function] [--limit]
482
785
  oxyc invocations held <app> <invocation-id>
483
786
  oxyc logs <app> [--app-env] [--invocation] [--request] [--hours] [--limit]
787
+
788
+ oxyc env secret list <app> --app-env <name> # keys and flags, never a value
789
+ oxyc env secret set <app> <key> --app-env <name> (--value <text> | --value-env <VAR>)
790
+ oxyc env secret delete <app> <key> --app-env <name>
484
791
  ```
485
792
 
486
793
  A **sandbox** is a named, short-lived `dev-<handle>` environment of one custom
@@ -503,7 +810,8 @@ staging's tables (rows up to a size cap), then the build's OLTP migrations.
503
810
  The loop: `env create` → `publish --app-env` → `fn call` / `checks run
504
811
  --app-env` → `invocations list` / `held` to read back what ran → iterate from
505
812
  `publish` → `env delete --yes --wait` when done. `oxyc guide` has the same six
506
- lines, meant to sit in an agent's context.
813
+ lines — between the two an agent adds to mint and revoke its own token — meant
814
+ to sit in an agent's context.
507
815
 
508
816
  **`--app-env <environment>`** (`production`, `staging` or `dev-<handle>`,
509
817
  validated client-side — exit `2` on a malformed name) is a *different axis*
@@ -515,6 +823,21 @@ take it. A publish-token credential is refused before any request for
515
823
  `logs` refuse one outright, for any environment, because sandbox management
516
824
  is a staff console surface.
517
825
 
826
+ **An unattended agent drives this loop with a sandbox agent token**
827
+ (`oxy_sbx_…`), not a staff credential: `oxyc tokens create --sandbox-agent
828
+ --app <org>/<app>` mints one after a browser approval — see
829
+ [Authentication](#authentication) for what `oxyc` then refuses, and
830
+ `internal-docs/custom-app-sandboxes.md` §2 for the agent's procedure. With
831
+ it, `--app-env dev-<handle>` is required on every verb above that takes one.
832
+
833
+ **`env secret`** reads and writes one sandbox's own secrets, for any
834
+ credential that reaches the sandbox, and only a `dev-<handle>` environment —
835
+ never staging's or production's. `list` prints each key with `is_set`,
836
+ `required` and `inherits_staging`; no verb returns a value. `set` takes the
837
+ value from `--value`, or from an environment variable with `--value-env` so it
838
+ stays off the command line. A sandbox with no value of its own reads
839
+ staging's.
840
+
518
841
  **`env delete`** without `--yes` asks on a terminal and exits `8` (refused)
519
842
  off one, same as `oltp reset`. `--wait [seconds]` (default 120) polls until
520
843
  the teardown finishes and prints `{"name","status":"deleted"}`; past the
@@ -674,10 +997,29 @@ no terminal to ask on inside an MCP server), and `oxy_publish_sandbox`
674
997
  requires a `dev-<handle>` `appEnv` and refuses production, staging or a
675
998
  promote client-side — it can never reach the live channel.
676
999
 
1000
+ **Its credential is the token variable and nothing else.** An agent runtime
1001
+ starts `oxyc mcp` with the agent's own token in `OXY_TOKEN` (or the
1002
+ `--token-env` variable); with the variable unset the server exits `4` before
1003
+ serving a tool, rather than running on the `oxyc login` of whoever owns the
1004
+ machine. To serve on your own login, start it with `--login`.
1005
+
677
1006
  ```bash
678
- claude mcp add oxyc -- npx -y @oxy-hq/cli mcp --env production
1007
+ # an agent, on its own token
1008
+ claude mcp add oxyc -e OXY_TOKEN=oxy_sbx_… -- npx -y @oxy-hq/cli mcp --env dev
1009
+ # you, on your login
1010
+ claude mcp add oxyc -- npx -y @oxy-hq/cli mcp --login --env production
679
1011
  ```
680
1012
 
1013
+ **A sandbox agent token (`oxy_sbx_…`) is served a different, smaller list**:
1014
+ the ten sandbox-loop tools, `oxy_whoami` (the token's `kind`, `expires_at`,
1015
+ `apps` and `minter`), and four that exist only for it — `oxy_env_secret_list`,
1016
+ `oxy_env_secret_set` and `oxy_env_secret_delete`, which accept only a
1017
+ `dev-<handle>` `appEnv`, and `oxy_token_revoke`, which needs `confirm: true`.
1018
+ `oxy_request`, `oxy_routes`, `oxy_schema` and the preview tools are dropped,
1019
+ and refused if called by name: the server answers that token `404` on all of
1020
+ them, and a tool that can only fail still costs its schema every turn. A dead
1021
+ token ends the server at startup with exit `4`.
1022
+
681
1023
  **`guide`** prints a page to paste into `AGENTS.md` / `CLAUDE.md`.
682
1024
  **`skills install`** symlinks the six bundled Claude skills into
683
1025
  `~/.claude/skills`; it refuses to run from an `npx` cache, whose symlinks dangle
@@ -703,8 +1045,10 @@ once npm reclaims it.
703
1045
 
704
1046
  | Variable | Default | Meaning |
705
1047
  | --- | --- | --- |
706
- | `OXY_TOKEN` | — | bearer token, overriding the login cache (the CI path) |
707
- | `OXY_API_KEY` | — | key for the `/external/api` surface |
1048
+ | `OXY_TOKEN` | — | any credential, sent as a bearer; overrides the login cache and GitHub OIDC |
1049
+ | `OXY_API_KEY` | — | a legacy API key or an API token, for the `/external/api` surface and for `checks run` |
1050
+ | `OXY_SERVICE_ACCOUNT` | — | the **ID** of the service account a GitHub OIDC exchange acts as (`--service-account`); never `<org>/<name>` |
1051
+ | `OXY_AGENT` | detected | the name of the agent driving `oxyc`, sent in the user agent — see below |
708
1052
  | `OXY_CREDENTIALS_PATH` | OS config dir | the shared `credentials.json` |
709
1053
  | `OXYC_ORG` | `oxy-hq` | GitHub org the customer repos live in |
710
1054
  | `OXYC_CUSTOMER_TOPIC` | `oxy-customer` | topic that registers a customer repo |
@@ -724,6 +1068,17 @@ once npm reclaims it.
724
1068
  | `OXYC_DRY_RUN` | — | `1` makes `launch` print the command instead of running it |
725
1069
  | `NO_COLOR` / `FORCE_COLOR` | — | force plain / coloured output |
726
1070
 
1071
+ **An agent should set `OXY_AGENT`.** Every request `oxyc` makes to a deployment
1072
+ carries the user agent `oxyc/<version>`, with `agent/<label>` after it when an
1073
+ agent is driving, and `mcp` last when the call came through `oxyc mcp` —
1074
+ `oxyc/0.6.0 agent/release-bot mcp`. The server records it on audit rows and in
1075
+ each token's usage, and it is the one thing there that tells an agent from the
1076
+ engineer running the same commands. The label is `OXY_AGENT`, held to
1077
+ lowercase letters, digits, `.`, `_` and `-` and to 32 characters (anything else
1078
+ becomes `-`). With `OXY_AGENT` unset, Claude Code is recognised by the
1079
+ `CLAUDECODE` variable it exports and labelled `claude-code`. It identifies
1080
+ honest callers in a log; it is not a credential and grants nothing.
1081
+
727
1082
  ## Output contract
728
1083
 
729
1084
  **stdout carries the response body and nothing else.** Progress, warnings,