@oxy-hq/cli 0.9.0 → 0.10.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.
@@ -50,6 +50,7 @@ works), but hand-rolling means owning the base-path + dev-proxy wiring yourself.
50
50
 
51
51
  ```json
52
52
  {
53
+ "$schema": "https://unpkg.com/@oxy-hq/sdk/schema/oxy-app.schema.json",
53
54
  "schemaVersion": 2,
54
55
  "slug": "url-slug",
55
56
  "orgSlug": "your-org",
@@ -57,18 +58,38 @@ works), but hand-rolling means owning the base-path + dev-proxy wiring yourself.
57
58
  }
58
59
  ```
59
60
 
60
- Required: `schemaVersion` (must be `2`), `slug` (non-empty).
61
+ Required: `schemaVersion` (must be `2`), `slug` (1–63 lowercase
62
+ letters, digits and single hyphens — it becomes the served path and
63
+ the OLTP schema name).
61
64
  Strongly recommended: `orgSlug` (lets `@oxy-hq/vite-plugin` derive
62
65
  the asset base path automatically, so plain `pnpm build` produces
63
- a bundle ready to drop into the linked org with no env var).
64
- Optional: `name` (display), `projectId` (dev-time hint).
66
+ a bundle ready to drop into the linked org with no env var, and lets
67
+ `pnpm dev` and `oxyc publish` find the app without `--org` / `OXY_ORG`;
68
+ `create-oxy-app --org <slug>` writes it).
69
+ Optional: `name` (display), `description`, `icon`, `art`, `status`
70
+ (launcher card), `projectId` (dev-time hint), `build` (what `oxyc
71
+ publish` runs — `install`, `command`, `outDir`; the plugin reads
72
+ `outDir` too), `functions`, `env`, `storage`, `notifications`, …
73
+
74
+ **One contract.** The file is validated against the JSON Schema
75
+ `@oxy-hq/sdk` ships (`schema/oxy-app.schema.json`, generated from the
76
+ SDK's `OxyAppManifest` type; `$schema` gives your editor completion)
77
+ by `oxyc validate`, `oxyc publish`, the Vite plugin and the SDK's own
78
+ loader — the same check, the same words. A wrong type, a bad name or
79
+ a missing required field is refused; a field the schema does not know
80
+ is a **warning** (the platform ignores what it does not read — so a
81
+ typo'd `timeoutSecond` is said, not silently dropped). Every field and
82
+ what the server does with it: `OxyAppManifest` in
83
+ `@oxy-hq/sdk/manifest`, which also exports the schema, `isValidSlug`
84
+ / `isValidFunctionName` and `validateOxyAppManifest` for a tool of
85
+ your own.
65
86
 
66
87
  At serve time, oxy injects the authoritative org + project from
67
88
  the admin row via `window.__OXY_APP__` — the manifest's `orgSlug`
68
89
  and `projectId` are advisory hints only, never enforcement.
69
90
 
70
91
  Do not invent `products`, `writers`, or `from:`-blocks. Those
71
- don't exist in v2 and the probe rejects them.
92
+ don't exist in v2 and the schema rejects them.
72
93
 
73
94
  File location: **`<root>/oxy-app.json`**, sibling to
74
95
  `vite.config.ts`. The vite-plugin reads it from there, and so does
@@ -304,13 +325,68 @@ Two more that read as bugs — `useTrackEvent("oxy-…")` is refused (`400`,
304
325
  the prefix is reserved), and a hovered-but-never-opened app records no view —
305
326
  are in [references/performance-and-usage.md](references/performance-and-usage.md).
306
327
 
328
+ ## Run a function locally
329
+
330
+ `oxyc fn dev <name>` runs one handler from your source in-process, in seconds,
331
+ with no publish. It bundles the handler with the same esbuild call `oxyc publish`
332
+ uses, then invokes it on `createTestContext` seeded from `oxy-app.json`: the
333
+ function's capabilities, `ctx.env` from the env file, and its destinations. It
334
+ prints the response, then the host ops the handler made.
335
+
336
+ ```bash
337
+ oxyc fn dev notify --input '{"name":"Ada"}' # a literal, @file, or - for stdin
338
+ oxyc fn dev notify --input @sample.json --env-file .env.dev --watch
339
+ oxyc fn dev report --read-through --env dev # reads hit that deployment; writes stay stubbed
340
+ ```
341
+
342
+ - **Run it from the app directory** (the one with `oxy-app.json`). `--env-file`
343
+ defaults to `.env`; `ctx.env` holds that file's keys and nothing from your
344
+ shell. A `required` secret the file lacks is a warning.
345
+ - **`--watch` re-runs** when a file in the app changes, reading the manifest,
346
+ env file and `@file` input afresh. A broken edit prints its error and waits
347
+ for the next save. Exit with Ctrl-C.
348
+ - **Exit codes:** `0` for a 2xx `Response`; `1` when the handler throws or
349
+ answers non-2xx (as `fn call`); `2` for a function the manifest does not
350
+ declare, or a missing env file.
351
+ - **A local run is not the isolate.** There is no timeout, memory or log cap;
352
+ `Buffer`, `TextEncoder` and `process` exist where the isolate lacks them; and
353
+ every warehouse, semantic, storage, fetch and email call is a stub in memory,
354
+ so (without `--read-through`) nothing reaches a deployment. The context does refuse what the manifest
355
+ does not grant, in the host's words. A `ctx.warehouse` destination's engine is
356
+ assumed (a `customerWarehouseWrites` entry is a customer Postgres, any other
357
+ is Airhouse), because the workspace config is not read.
358
+ - **`--read-through` reads real data, and only reads.** It sends `ctx.query`,
359
+ `ctx.warehouse.query` and `ctx.semantic.query` to the deployment `--env <name|url>`
360
+ (or `--target`) names, through the app's own `POST /api/projects/{project}/query`
361
+ and `/semantic-query`, with your `oxyc login` bearer or `OXY_TOKEN` (the way
362
+ `oxyc api` authenticates). What you may read is the server's decision. The
363
+ project is `--project` / `OXY_PROJECT`, else looked up from `oxy-app.json`
364
+ `slug` + `orgSlug`. Every other call (warehouse writes, `ctx.tx`, `ctx.oltp`,
365
+ `ctx.airhouse`, storage, fetch, email, notify, and `ctx.queryStream`) stays a
366
+ stub in memory and never leaves your machine. Each call in the report is marked
367
+ `real` or `stubbed`. `--env` defaults to production, so name a dev environment
368
+ when you can. A sandbox agent token is refused: use a person's login.
369
+ `ctx.semantic.query` row keys are `view__member` in the isolate but the route
370
+ strips the prefix, so read-through puts it back from your spec; a key it could
371
+ not map unambiguously stays as the route named it, so confirm that one with
372
+ `fn call` on a sandbox. Still not the isolate.
373
+ - Before you ship, run it where it really runs: publish to your sandbox and
374
+ `oxyc fn call`, or `oxyc checks run`. For a committed regression test use
375
+ `createTestContext` from `@oxy-hq/sdk/testing` in vitest, as the functions
376
+ template does.
377
+
307
378
  ## Publishing
308
379
 
309
- **These commands are a person's.** `oxyc login` is the engineer's own browser
310
- login, and a publish to a channel or a promote is refused under a sandbox
311
- agent token. As an agent, publish into your own sandbox
312
- ([references/sandbox-agent-loop.md](references/sandbox-agent-loop.md)) and
313
- leave the ship step to your engineer.
380
+ **These commands are a person's, or an agent's on a token that person
381
+ approved.** `oxyc login` is the engineer's own browser login, and a publish to
382
+ a channel or a promote is refused under a sandbox agent token. As an agent,
383
+ iterate in your own sandbox
384
+ ([references/sandbox-agent-loop.md](references/sandbox-agent-loop.md)). Ship
385
+ only when your engineer asked you to, on an agent token they approved
386
+ (`oxyc tokens create --agent`, plus `--standing` for staff access): it
387
+ publishes and promotes as them. With no browser on your machine, see
388
+ [Headless and cloud sessions](#headless-and-cloud-sessions): a token approved
389
+ there never carries staff access.
314
390
 
315
391
  Ship with `oxyc publish` (`npm i -g @oxy-hq/cli`) — **no CI step, no S3 sync,
316
392
  no folder picker**. From the app directory:
@@ -318,24 +394,45 @@ no folder picker**. From the app directory:
318
394
  ```bash
319
395
  oxyc login --env production # once; browser flow, caches a token,
320
396
  # and prints whether you're an app-admin
321
- oxyc validate # data-placement and manifest checks first
322
- oxyc publish --env production # build → bundle functions → tar → upload (draft channel)
323
- oxyc publish --env production --promote # …straight to live
397
+ oxyc validate # schema, data-placement and function checks first
398
+ oxyc publish --env production # build → bundle functions → tar → upload (draft = staging)
399
+ oxyc promote <org>/<app> --env production --dry-run # the plan: changes, migrations, blockers
400
+ oxyc promote <org>/<app> --env production # staging's build → production (asks first)
401
+ oxyc rollback <org>/<app> --env production --app-env production # back to the build before
324
402
  ```
325
403
 
404
+ Production only ever serves a build staging has served, and every way of
405
+ moving it — `oxyc promote`, `oxyc rollback`, the console's **Promote draft**
406
+ and **Roll back to…**, a promoting publish — applies the build's pending migrations **before** it moves
407
+ (a failure leaves the old build live). `promote` and `rollback` print the plan
408
+ first and refuse on a blocker: `staging_never_served`, `required_secrets_unset`
409
+ (set the key in production first), `schedule_removal_unconfirmed` (pass
410
+ `--remove-schedule <function>`). Off a terminal pass `--yes`.
411
+ `oxyc publish --promote` still works and warns that it is deprecated: it shows
412
+ nobody the plan.
413
+
326
414
  - **Identity**, for each half, is `--app` / `--org`, then `OXY_APP` /
327
415
  `OXY_ORG`, then the manifest's `slug` + `orgSlug`, then an
328
416
  `apps/<org>/<app>/` working directory. `--org` takes a slug or a UUID; pass
329
417
  the slug whenever `oxyc` runs the build, because the asset base path is
330
418
  baked from what you pass. With no org from any of those, the build stops
331
419
  with `missing org: the app's base path needs it` (exit `2`): add
332
- `--org <org-slug>`, and `--app <slug>` when the directory has no manifest.
333
- The server upserts the app row — there is no separate create step.
334
- - **First publish of a new app** needs `--project <workspace-uuid>` (unless
335
- the app was registered in the admin UI): the build-config lookup can't
336
- resolve a project until the app exists. A pinned workspace also supplies
337
- the org. Drop the flag after the first publish — it's auto-resolved from
338
- then on.
420
+ `"orgSlug"` to `oxy-app.json` (`create-oxy-app --org <slug>` writes it)
421
+ or `--org <org-slug>`, and `--app <slug>` when the directory has no
422
+ manifest. The server upserts the app row — there is no separate create
423
+ step.
424
+ - **First publish of a new app is one step.** The app is not registered
425
+ yet, so the build-config lookup 404s; with your login `oxyc publish` then
426
+ lists the org's workspaces, and when there is exactly one (a freshly
427
+ provisioned org) publishes into it and says which. With several it prints
428
+ them with their uuids beside the flag to pass — `--project
429
+ <workspace-uuid>` (or `OXY_PROJECT`), which also supplies the org. Drop
430
+ the flag after the first publish — it's auto-resolved from then on.
431
+ Trusted publishing (CI) only ever publishes a registered app.
432
+ - **Old kit.** Before the build, an app whose `@oxy-hq/sdk` or
433
+ `@oxy-hq/vite-plugin` is more than one minor behind the versions this
434
+ `oxyc` was built with gets one warning naming the `pnpm add` — the
435
+ manifest contract and the function runtime move with them.
339
436
  - **`--env` targets:** `local` → a local Oxy web-app Vite dev server `:5173`
340
437
  (it serves `/cli-auth` and proxies `/api` to oxy on `:3000`), `dev` →
341
438
  `aip.dev.oxy.tech`, `staging` → `aip.staging.oxy.tech`, `production` (the
@@ -365,10 +462,87 @@ oxyc publish --env production --promote # …straight to live
365
462
  `oxyc apps builds <org>/<app>` is the history. `oxyc apps drift <org>/<app>`
366
463
  lists the commits in your checkout that touch the app after the live build's
367
464
  commit — `unknown` there means it could not compare, not that nothing changed.
465
+ - **A customer's own domain** (`app.acme.com`) serves production only:
466
+ `oxyc apps domains add <org>/<app> app.acme.com` prints a CNAME and a TXT
467
+ record for the customer to create, then `oxyc apps domains verify …`. Build
468
+ nothing differently — the bundle keeps its `/customer-apps/<org>/<app>/` base
469
+ and its same-origin `/api` calls work on the domain. A visitor signs in once
470
+ through the Oxygen login; on that domain the session reaches only the app's
471
+ workspace, so a call to another workspace there is refused by design.
368
472
 
369
473
  The build is not the check: after a publish, drive the page
370
474
  ([references/verifying-in-the-browser.md](references/verifying-in-the-browser.md)).
371
475
 
476
+ ## Headless and cloud sessions
477
+
478
+ A Claude Code cloud session (claude.ai/code), a CI runner or any machine with
479
+ no browser can mint a token, iterate in a sandbox and publish. A person
480
+ approves once, from any device.
481
+
482
+ 1. **The cloud environment.** Its owner sets this once in the environment's
483
+ settings. Under **Network access → Custom**, allow `app.oxygen-hq.com` and
484
+ `*.oxy.tech`, and keep the default package-manager list ticked so npm can
485
+ install `oxyc`. Optionally add `OXY_APPROVER=<their email>` under
486
+ environment variables, so each approval request is pushed to them.
487
+ 2. **The repo's SessionStart hook,** `.claude/hooks/oxyc-cloud-session.sh`,
488
+ with its entry in `.claude/settings.json`. In a cloud session it installs
489
+ `oxyc` and routes it through the session's proxy. It also makes a minted
490
+ token reach every later command, which an `export` in one shell call cannot
491
+ do. What it prints at session start says what is missing.
492
+ - A customer repo that `oxyc new` or `oxyc adopt` made has both files.
493
+ - In an existing customer repo, `oxyc update --apply` adds the script but
494
+ only reports the settings entry, because the team may have their own
495
+ settings. Add the entry by hand:
496
+ ```json
497
+ { "hooks": { "SessionStart": [ { "matcher": "", "hooks": [ { "type": "command",
498
+ "command": "\"${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/oxyc-cloud-session.sh\"",
499
+ "timeout": 120 } ] } ] } }
500
+ ```
501
+ - Any other repo can copy both from the `template/.claude/` directory of the
502
+ installed `@oxy-hq/cli` package.
503
+ 3. **The credential.** Run this in the background, because the approval can
504
+ outlast a two-minute shell limit:
505
+ ```bash
506
+ mkdir -p ~/.config/oxy && (umask 077; oxyc tokens create --agent --no-browser \
507
+ --env production >~/.config/oxy/agent-token.env)
508
+ ```
509
+ Give your engineer the URL and the code it prints on stderr. The token goes
510
+ to that file and never into your context. The hook loads the file before
511
+ every later command. To iterate in a sandbox, mint
512
+ `--sandbox-agent --app <org>/<app>` into the same file instead. A token
513
+ lasts 8 hours by default (`--hours`, up to 168). On exit `4`, mint again.
514
+ 4. **Ship** with the commands above, each with `--token-env OXY_TOKEN`.
515
+ `promote` and `rollback` need `--yes`, because there is no terminal to ask.
516
+
517
+ **A token approved by device code never carries staff or partner access.**
518
+ `--standing --no-browser` is a usage error (exit `2`). The token from step 3
519
+ reaches only what the approver's org memberships do. That is enough when they
520
+ belong to the app's org. Staff shipping a customer's app have three ways:
521
+
522
+ - **Let the repo's CI publish a draft, and a person promote it.** This works
523
+ only where the repo turned CI publishing on, with the repository variable
524
+ `OXY_CI_PUBLISH=on`. Without it, the publish job is skipped and the run
525
+ still shows green.
526
+ - `gh workflow run publish.yaml -f ref=<branch>` publishes that ref as
527
+ production's draft, which is staging. A push to `main` publishes to `dev`
528
+ instead.
529
+ - Check that the publish job ran, not that the run is green.
530
+ - Then your engineer runs `oxyc promote <org>/<app> --dry-run`, then
531
+ `oxyc promote <org>/<app>`, and sees the plan before it ships.
532
+ - Never pass `-f promote=true`. It rebuilds the ref and puts it live with no
533
+ plan, the deprecated promoting publish.
534
+ - **Mint the standing token where a browser opens.** On the engineer's own
535
+ machine, run `oxyc tokens create --agent --standing --hours <n>`. They put
536
+ the value in the cloud environment's variables as `OXY_TOKEN`. Anyone who
537
+ uses that environment can read it, so keep `--hours` short and revoke the
538
+ token when the task is done.
539
+ - **Leave the ship step to the engineer.** Hand back the sandbox's build and
540
+ the `oxyc promote --from dev-<handle>` command.
541
+
542
+ Without the hook, start each command with `. ~/.config/oxy/agent-token.env &&`.
543
+ If a command fails with `fetch failed` (exit `7`) while `HTTPS_PROXY` is set,
544
+ `oxyc` is too old to use the proxy. Update it, or set `NODE_USE_ENV_PROXY=1`.
545
+
372
546
  ## Sandboxes — try a change on real data before shipping it
373
547
 
374
548
  A sandbox (`dev-<handle>`, 1-12 lowercase letters/digits/hyphens, no leading,
@@ -377,10 +551,16 @@ own build pointer, storage silo, secrets and Airhouse sibling schema. Reads
377
551
  hit production; most writes are isolated or *held* (recorded, not performed —
378
552
  `ctx.oltp`/`ctx.warehouse` stay shared; a held write's `note` field in
379
553
  `invocations held` names the manifest key that would make it land instead).
380
- Schedules and webhooks do not fire in a sandbox: call the function. It starts
381
- with no build, costs nothing idle, and is gone after 7 idle days or an
382
- explicit delete — safe to use for every iteration that would otherwise touch
383
- staging. Limit: 20 per app, counting ones still being deleted.
554
+ A sandbox's schedules are **off by default** (staging's too): staff turn them
555
+ on per environment with `oxyc env schedules <app> dev-x --on`, and a scheduled
556
+ fire is not activity. To test a scheduled function without that, run it once
557
+ as its cron would — `oxyc fn run <app> <fn> --app-env dev-x --as-schedule` (no
558
+ input, system identity). Webhooks reach a sandbox on their own route,
559
+ `/api/webhooks/apps-env/dev-x/<org>/<app>/<fn>`, verified with the sandbox's
560
+ own `secretVar` value — set it with `env secret set`; staging's does not count.
561
+ It starts with no build, costs nothing idle, and is gone after 7 idle days or
562
+ an explicit delete — safe to use for every iteration that would otherwise
563
+ touch staging. Limit: 20 per app, counting ones still being deleted.
384
564
 
385
565
  ```bash
386
566
  oxyc env create acme/store dev-x --env dev --json # starts with no build
@@ -388,17 +568,21 @@ oxyc publish --env dev --app-env dev-x --json # build + publish to
388
568
  oxyc env secret set acme/store KEY --app-env dev-x --env dev --value-env VAR # optional: its own secret
389
569
  oxyc fn call acme/store submit-order --app-env dev-x --env dev --data '{}' --json # call it
390
570
  oxyc checks run acme/store --app-env dev-x --env dev --json # run its checks
571
+ oxyc fn run acme/store nightly-sync --app-env dev-x --as-schedule --env dev --json # a scheduled function, once
391
572
  oxyc invocations list acme/store --app-env dev-x --env dev --json # what ran; a row's id is `id`
392
573
  oxyc invocations held acme/store <invocation-id> --env dev --json # what it held, not wrote
393
574
  oxyc logs acme/store --app-env dev-x --env dev --json # its function logs
575
+ oxyc logs acme/store --app-env dev-x --env dev --follow --timeout 300 --json # live, as printed
394
576
  oxyc env delete acme/store dev-x --env dev --yes --wait --json # done; tears its homes down
395
577
  ```
396
578
 
397
579
  (`--data` has no short form — don't write `-d`.)
398
580
 
399
581
  Iterate from `publish` as the code changes — each publish is a new build, and
400
- the sandbox always serves the newest. A sandbox build is **never promoted**:
401
- ship by publishing the same tree to staging, then promoting from the console.
582
+ the sandbox always serves the newest. A sandbox build reaches production
583
+ only through staging: a person runs `oxyc promote <app> --from dev-<handle>`
584
+ (the build you verified becomes staging's), checks it there, then `oxyc
585
+ promote <app>`.
402
586
  Staff only (needs `develop_apps` + `manage_apps`) — or a sandbox agent token
403
587
  such staff approved; a publish token is refused on every sandbox operation,
404
588
  so CI cannot use one.
@@ -15,9 +15,17 @@ my-app/
15
15
  └── tsconfig.json
16
16
  ```
17
17
 
18
- `oxy-app.json`:
18
+ `oxy-app.json` (`$schema` is optional — editor completion against the SDK's
19
+ generated schema; `oxyc validate` and the Vite plugin check the file against it
20
+ either way):
19
21
  ```json
20
- { "schemaVersion": 2, "slug": "my-app", "orgSlug": "your-org", "name": "My App" }
22
+ {
23
+ "$schema": "https://unpkg.com/@oxy-hq/sdk/schema/oxy-app.schema.json",
24
+ "schemaVersion": 2,
25
+ "slug": "my-app",
26
+ "orgSlug": "your-org",
27
+ "name": "My App"
28
+ }
21
29
  ```
22
30
 
23
31
  `src/main.tsx`:
@@ -94,6 +94,52 @@ false` stops only the *client* runtime — Oxy still records one view row per na
94
94
  server-side, so the Activity tab never goes dark. Reach for these rarely: the defaults are
95
95
  the fast, observable path.
96
96
 
97
+ ### A tab left open across a publish (automatic — and what it can cost you)
98
+
99
+ Oxy's injected runtime also watches for a newer build. Every app gets this with no bundle
100
+ code, so an app that keeps work in memory has to know about it:
101
+
102
+ - **It notices a publish within ~5 minutes.** A visible tab polls `__oxy/build`, and checks
103
+ again when it comes back to the foreground or online. When the live build differs, it
104
+ fires a cancelable `oxy:update-available` event on `window`.
105
+ - **A broken lazy chunk after a publish reloads the page — in every mode except the
106
+ platform's `off`.** A route the tab never opened asks for a chunk the new build no
107
+ longer has; rather than white-screen, the runtime checks `__oxy/build` and, if the
108
+ build moved, reloads onto it. That reload **discards in-memory state** unless the app
109
+ holds `useBusyWhile` / `holdUpdates`, an input is focused, or it cancels the
110
+ `oxy:chunk-stale` event.
111
+ - **Whether a tab reloads by itself is `performance.autoUpdate`.** The platform default is
112
+ set by the operator (announce only, today). Set it per app:
113
+
114
+ ```jsonc
115
+ { "performance": { "autoUpdate": true } } // reload at a safe moment: a navigation,
116
+ // 2 idle minutes with nothing focused, or a
117
+ // return after 10+ minutes away
118
+ { "performance": { "autoUpdate": false } } // never reload by itself; offer it with useAppUpdate
119
+ { "performance": { "autoUpdate": "prompt" } } // shell shows "Update ready"; reloads at the
120
+ // next safe moment if nobody taps it in 24 h
121
+ ```
122
+
123
+ **The rule: wrap unsaved in-memory work.** A checklist half filled, a form not yet saved,
124
+ a write in flight — hold updates while it is dirty, and the runtime will not reload under
125
+ it (a held token older than 12 h is ignored, so a leak cannot pin a tab forever):
126
+
127
+ ```tsx
128
+ import { useAppUpdate, useBusyWhile } from "@oxy-hq/sdk";
129
+
130
+ useBusyWhile(isDirty, "checklist not saved"); // or: const release = holdUpdates("…")
131
+
132
+ const { available, apply } = useAppUpdate(); // offer the update in your own UI
133
+ {available && <button onClick={() => apply()}>Update now</button>}
134
+ ```
135
+
136
+ `useFunction` already holds updates for the life of each call. `UpdatePill` (from
137
+ `@oxy-hq/sdk/shell`) is the ready-made "Update ready" chip; `useKioskIdle` signs a kiosk
138
+ out after the device's idle timeout, which is also a full reload onto the live build.
139
+ An app's own `beforeunload` "leave this page?" prompt still applies. When the viewer
140
+ chooses to stay, the runtime does not reload onto that build by itself again, but
141
+ `apply()` and the chip still work.
142
+
97
143
  **Two gotchas that read as bugs:**
98
144
  - **`oxy-` is a reserved event-name prefix.** `useTrackEvent("oxy-…")` is refused with a
99
145
  400 — that namespace is the platform's own instrumentation. Name your events anything
@@ -23,11 +23,29 @@ oxyc tokens create --sandbox-agent --app <org>/<app> --env <deployment>
23
23
  browser. The command waits five minutes for the click, so run it with a
24
24
  timeout at least that long: a shell tool that stops a command after two
25
25
  minutes kills it first. Never open or click that page with a browser tool.
26
+ - **Where no browser can reach your machine** (a cloud container, a CI job, a
27
+ remote box), add `--no-browser`. `oxyc` prints a URL and an eight-letter
28
+ code on stderr instead of opening a page, and waits up to ten minutes. Give
29
+ your engineer both: they open the URL on any device where they are signed
30
+ in, type the code the terminal shows, and approve. The token is printed
31
+ exactly as above. The rule does not change: never open or approve that page
32
+ yourself, with a browser tool or any other way.
33
+ - **Name who approves with `--approver <email>`** (or `OXY_APPROVER`), only
34
+ with `--no-browser`: the engineer is told by push, email and Slack; they type
35
+ the code from your output on the page. Use the email your engineer gave you,
36
+ and still show them the code `oxyc` printed, since the notice does not carry
37
+ it. Only that person can open the request: if you were given no address, omit
38
+ the flag and hand them the URL and the code as before. Never open or click
39
+ the page yourself.
26
40
  - **Evaluate that line and work with the token.** In a shell that persists:
27
41
  `eval "$(oxyc tokens create --sandbox-agent --app <org>/<app> --env <deployment>)"`.
28
- In one that does not, keep the value and set `OXY_TOKEN` on each command.
29
- `oxyc` writes nothing to the credentials file. Never write the token to a
30
- file, a commit or your report.
42
+ In one that does not, such as an agent's shell tool, redirect it to
43
+ `~/.config/oxy/agent-token.env` under `umask 077`, and start each command
44
+ with `. ~/.config/oxy/agent-token.env &&`. In a Claude Code cloud session,
45
+ the repo's SessionStart hook does that for you
46
+ ([SKILL.md → Headless and cloud sessions](../SKILL.md#headless-and-cloud-sessions)).
47
+ `oxyc` writes nothing to the credentials file. That file is the only place
48
+ the token goes. Never put it in a commit or your report.
31
49
  - **Make the variable the only credential.** Put `--token-env OXY_TOKEN` on
32
50
  every command after the mint, as the loop below does. With it, a command
33
51
  that lost the variable exits `4` (`OXY_TOKEN is not set`): set it and run the
@@ -127,9 +145,21 @@ Reads hit production data. A write lands in a home of the sandbox's own, or it
127
145
  is *held*: recorded in `invocations held` and not performed. A held write is
128
146
  the expected answer outside production. Report it. Unless the task asks for
129
147
  it, do not edit `nonProduction` in the manifest to make the write land:
130
- staging and every other sandbox share that map. Schedules and webhooks do not
131
- fire in a sandbox: call the function. The `fn call` result carries the
132
- function's `logs`; `oxyc logs` shows the same lines about 30 seconds later.
148
+ staging and every other sandbox share that map. A sandbox's schedules are off
149
+ unless staff turned them on, and your token cannot turn them on: to test a
150
+ scheduled function, run it once as its schedule would — `oxyc fn run
151
+ <org>/<app> <function> --app-env dev-x --as-schedule --env <deployment>
152
+ --token-env OXY_TOKEN --json` (no `--data`; `oxy_fn_run` with `asSchedule:
153
+ true` over MCP). It runs as the system, with the function's retry policy, and
154
+ answers `409 not_scheduled` (exit `6`) for a function with no `schedule`. The
155
+ `fn call` result carries the function's `logs`; `oxyc logs` shows the same
156
+ lines about 30 seconds later.
157
+ To watch a run you do not call yourself — a check on the worker fleet, a slow
158
+ function — start `oxyc logs <org>/<app> --app-env dev-x --follow --timeout 300
159
+ --env <deployment> --token-env OXY_TOKEN --json` in the background first. It
160
+ prints one JSON event per line (`open`, then `start` / `log` / `finish`),
161
+ needs no observability capture, and exits `0` on `done`. A `dropped` event
162
+ means lines were lost, not that the function failed.
133
163
 
134
164
  ## When a command fails
135
165
 
@@ -160,9 +190,11 @@ Branch on the exit code and the JSON fields, not on the text:
160
190
  value shaped like one (`400 credential_shaped_value`). Set a secret with
161
191
  `oxyc env secret set` (`oxy_env_secret_set`), never a hand-built request,
162
192
  and never try to read a value back.
163
- - **Never pass `--promote`, and never publish without `--app-env`.** A sandbox
164
- build is never promoted. When it is right, say so and leave the ship step to
165
- your engineer. Never use `production` as `--app-env` to "check" something:
193
+ - **Never pass `--promote`, never run `oxyc promote` or `oxyc rollback`, and
194
+ never publish without `--app-env`.** When the sandbox build is right, say so
195
+ and leave the ship step to your engineer: they promote that build to staging
196
+ (`oxyc promote <app> --from dev-<handle>`), then to production. Your token is
197
+ refused both verbs anyway. Never use `production` as `--app-env` to "check" something:
166
198
  reads already hit production.
167
199
  - **A held write is information to report**, not an error to engineer around.
168
200
  - **Clean up, even when a step failed.** Delete every sandbox you created,
@@ -211,16 +243,24 @@ cases:
211
243
  - **Exit `8`.** The deployment did not return the token you asked for: it
212
244
  predates sandbox agent tokens, or returned one for other apps. Nothing was
213
245
  printed, and what it minted was revoked.
214
- - **Exit `7`.** Nobody approved within five minutes. Ask your engineer what
215
- the page said. Only Oxy staff holding `develop_apps` and `manage_apps` over
216
- the app's org can approve: if the page refused, report what it said and
217
- stop. If they missed it, run the command once more.
246
+ - **Exit `7`.** Nobody approved in time (five minutes, or ten with
247
+ `--no-browser`). Ask your engineer what the page said. Only Oxy staff
248
+ holding `develop_apps` and `manage_apps` over the app's org can approve: if
249
+ the page refused, report what it said and stop. If they missed it, run the
250
+ command once more. If you named an `--approver`, say so: the request went to
251
+ that person's phone and inbox, so they may have missed the notice rather
252
+ than the page.
218
253
  - **Exit `4`.** The approval code was rejected and nothing was minted. Run the
219
254
  command once more, with fewer `--hours` if an organization caps a token's
220
- lifetime.
221
- - **No browser on this machine can open the page** (a remote container, a CI
222
- job). The page answers to a local port on the machine `oxyc` runs on, so it
223
- cannot be approved from another one.
255
+ lifetime. With `--no-browser`, exit `4` with `access_denied` means your
256
+ engineer declined, or the page refused the request: the reason is printed.
257
+ Report it and stop.
258
+ - **`--no-browser` is an unknown option (exit `2`), or the deployment has no
259
+ device-code approval (exit `8`).** The CLI or the deployment is too old for
260
+ a machine no browser can reach. Ask your engineer to update, or to run the
261
+ command on a machine with a browser and hand you the token.
262
+ - **`--approver` is an unknown option (exit `2`).** The CLI predates it. Run
263
+ without it and give your engineer the URL and the code yourself.
224
264
 
225
265
  ## Over MCP
226
266
 
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env bash
2
+ # SessionStart hook for Claude Code cloud sessions (claude.ai/code): makes
3
+ # `oxyc` usable with no browser on this machine — installed, routed through the
4
+ # session's egress proxy, and given a credential that survives between Bash
5
+ # calls. A local session exits at once.
6
+ #
7
+ # stdout is read by Claude, so it says only what is missing and the one thing
8
+ # to do about it. The cloud *environment* still needs the Oxy hosts allowed
9
+ # (Network access → Custom): the oxy-custom-apps skill, "Headless and cloud
10
+ # sessions".
11
+ #
12
+ # Two copies, kept identical by a test: oxygen-internal's own, and the one in
13
+ # sdk/cli/template/ that `oxyc update` writes into every customer repo (it is
14
+ # managed there — edit it in oxygen-internal, not in the customer repo).
15
+
16
+ [ "${CLAUDE_CODE_REMOTE:-}" = "true" ] || exit 0
17
+ cat >/dev/null 2>&1 || true
18
+
19
+ # Where a minted agent token is kept: the one `export OXY_TOKEN=…` line that
20
+ # `oxyc tokens create --agent` prints on stdout.
21
+ TOKEN_FILE="$HOME/.config/oxy/agent-token.env"
22
+ ENV="${OXY_ENV:-production}"
23
+ case "$ENV" in
24
+ production) HOST=app.oxygen-hq.com ;;
25
+ staging) HOST=aip.staging.oxy.tech ;;
26
+ dev) HOST=aip.dev.oxy.tech ;;
27
+ *) HOST="" ;;
28
+ esac
29
+
30
+ if ! command -v oxyc >/dev/null 2>&1; then
31
+ npm install -g @oxy-hq/cli >/dev/null 2>&1 \
32
+ || echo "oxyc: 'npm install -g @oxy-hq/cli' failed — run it before any oxyc command."
33
+ fi
34
+
35
+ if [ -n "${CLAUDE_ENV_FILE:-}" ]; then
36
+ {
37
+ # Node's fetch ignores HTTPS_PROXY without this, and every request out of
38
+ # this VM goes through one. A current oxyc sets it itself; older ones don't.
39
+ echo 'export NODE_USE_ENV_PROXY=1'
40
+ # Runs before every Bash command, so a token minted mid-session reaches
41
+ # every command after it — an `export` in one call would not.
42
+ echo "[ -f \"$TOKEN_FILE\" ] && . \"$TOKEN_FILE\""
43
+ } >>"$CLAUDE_ENV_FILE"
44
+ fi
45
+
46
+ if [ -n "$HOST" ] && ! curl -fsS --max-time 10 -o /dev/null "https://$HOST/api/health" 2>/dev/null; then
47
+ echo "oxyc: https://$HOST is unreachable from this session. The cloud environment's network"
48
+ echo " access must allow it (Custom: app.oxygen-hq.com, *.oxy.tech) — ask the user to add it."
49
+ fi
50
+
51
+ if [ -n "${OXY_TOKEN:-}" ]; then
52
+ echo "oxyc: credential is OXY_TOKEN from the environment."
53
+ elif [ -s "$TOKEN_FILE" ]; then
54
+ echo "oxyc: credential is the agent token in $TOKEN_FILE (it expires: on exit 4, mint again)."
55
+ else
56
+ cat <<EOF
57
+ oxyc: no Oxy credential yet. Before you publish or promote, mint an agent token — a person
58
+ approves it once from any device, nothing opens here. Run this in the background:
59
+ mkdir -p ~/.config/oxy && (umask 077; oxyc tokens create --agent --no-browser --env $ENV >$TOKEN_FILE)
60
+ and give the user the URL and code it prints. --approver <their email> (or OXY_APPROVER) also
61
+ pushes the request to them. Once approved, every later command has OXY_TOKEN.
62
+ It reaches what the approver's org memberships do, never staff access: staff shipping a
63
+ customer's app need another path. See the oxy-custom-apps skill, "Headless and cloud sessions".
64
+ EOF
65
+ fi
66
+ exit 0
@@ -0,0 +1,16 @@
1
+ {
2
+ "hooks": {
3
+ "SessionStart": [
4
+ {
5
+ "matcher": "",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "\"${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/oxyc-cloud-session.sh\"",
10
+ "timeout": 120
11
+ }
12
+ ]
13
+ }
14
+ ]
15
+ }
16
+ }
@@ -160,7 +160,7 @@ env:
160
160
  # service account are honoured from 0.7.0, which tries them first and falls
161
161
  # back to the publisher. A pin older than that still publishes, through the
162
162
  # publisher alone.
163
- OXYC_VERSION: "0.9.0"
163
+ OXYC_VERSION: "0.10.0"
164
164
 
165
165
  jobs:
166
166
  build:
@@ -180,6 +180,11 @@ managed .github/scripts/*.sh
180
180
  # app locally", and it is meant to improve for everyone at once.
181
181
  managed scripts/*.sh
182
182
 
183
+ # The Claude Code cloud-session hook: installs oxyc and wires its proxy and
184
+ # credential so an agent with no browser can publish. It tracks oxyc's own
185
+ # behaviour, and a test keeps it byte-identical to oxygen-internal's copy.
186
+ managed .claude/hooks/*.sh
187
+
183
188
  # The merge rule for the generated memory index. It is a statement about how
184
189
  # oxyc's own generated file behaves, so oxyc owns it.
185
190
  managed .gitattributes
@@ -246,6 +251,10 @@ mixed .gitignore
246
251
  # script somebody decided on is the customer's.
247
252
  mixed pnpm-workspace.yaml
248
253
 
254
+ # The SessionStart entry that runs the hook above is the tool's; any other
255
+ # hook, permission or plugin a team adds beside it is theirs.
256
+ mixed .claude/settings.json
257
+
249
258
  # --- scope: which tree each file describes ----------------------------------
250
259
  #
251
260
  # Grouped by answer rather than written beside the role above, so that the two
@@ -263,6 +272,10 @@ scope-repo .gitattributes
263
272
  scope-repo .oxyc-managed
264
273
  scope-repo .oxyc-template.json
265
274
 
275
+ # Claude Code reads `.claude/` from the root of the repo it opened.
276
+ scope-repo .claude/hooks/*.sh
277
+ scope-repo .claude/settings.json
278
+
266
279
  # The PNPM workspace and the git working tree, which are root things in a repo
267
280
  # whose Oxy workspace is somewhere else. `pnpm dev` is run from the repo root;
268
281
  # `packages: apps/*` names directories at the repo root, which is also where