diff --git a/devlog/_plan/260911_l7_docs/000_packet.md b/devlog/_plan/260911_l7_docs/000_packet.md new file mode 100644 index 0000000000..a7e38e9f18 --- /dev/null +++ b/devlog/_plan/260911_l7_docs/000_packet.md @@ -0,0 +1,81 @@ +# Dispatch packet — L7 (revision 5) + +Round unit: `devlog/_plan/260911_lane_dispatch_round` on `dev`. Base freeze: `origin/dev` `6d3ad12e3` (2.51.0). +Five audit rounds shaped this packet. The last one was a seven-lane feasibility check that asked whether each stack is implementable inside its owned paths; three lanes came back with gaps, and the fixes are folded here. `010_lane_partition.md` is the authoritative ownership list; `130_wp4_feasibility.md` records why each path was granted. + + +## Shared frame + +**Repository.** Your worktree is named in your packet, already checked out on your lane branch, cut +from `origin/dev` `6d3ad12e3` (2.51.0). Work only there. Do not add, move, or remove a worktree. + +**Loop.** Run `$codexclaw:cxc-loop` as HOTL for your lane: one work-phase per issue, in order. Your +goal ends when your last PR is green and reported, not when the code looks right. + +**Subagents.** Unlimited `xai/grok-4.6` subagents, read-only, spawned with `spawn_agent` +(`model: "xai/grok-4.6"`). Use them to reproduce, to read the call sites you are about to change, to +find a second caller of a helper you are touching, and to review your staged diff adversarially +before you push. A finding enters your work only with an exact `path:line` anchor. Subagents never +write, commit, push, or call a mutating `gh`. Treat a `fail` verdict the way this round did: fold it +in and re-audit. This packet is at revision 3 because two audit rounds rejected revisions 1 and 2. + +**MUST NOT.** + +- No local product suite: no `bun test`, no `bun run test`, no `bun run test:changed`, no + `bun run typecheck`, no `bun run build:gui`, no `bun install`. Report them as `NOT RUN`. +- No merge, no release, no force-push to a shared branch, no direct push to `dev`. +- No path outside your owned list, including paths a carried PR happens to touch. Dropping a hunk + from a carried PR is expected; report what you dropped. +- No locale key in `gui/src/i18n/*`. If you need one, stop and report. +- No security write-up in `devlog/`; scratch space only, per `AGENTS.md`. + +**MUST.** + +- Prefix every mutating git command with `git -c core.hooksPath=/dev/null`. This repository's hooks + can start a GUI install, typecheck, and build, which the no-local-suite rule forbids. +- Push with `--no-verify`. +- Write the focused regression test `AGENTS.md` requires for a behaviour change, in the domain + directory beside the existing tests for that subsystem, and register it in both + `scripts/test-layout/layout.json` `explicit` and `tests/fixtures/test-layout-expected.json`. You + will not run it; hosted CI will. Those two maps are append-only and other lanes are adding to them + too; the orchestrator resolves the conflicts at merge, so do not skip the entry. +- Fill every section of `.github/PULL_REQUEST_TEMPLATE.md` and put `Closes #` in the body. In + **Verification**, state that the local suite, typecheck, and build were `NOT RUN` by operator + instruction and that hosted CI on the exact pushed head is the proof. +- When you carry another author's PR, add a `Co-authored-by` trailer in a branch commit. Resolve the + address with `gh api users/ --jq '.id'` and use `+@users.noreply.github.com`. +- Keep a devlog unit under `devlog/_plan/260911_l_/`. + +**Stacking.** First PR targets `dev`; the second targets the first PR's head branch, the third the +second. Retarget a child to `dev` after its parent lands. No native GitHub stacks. + +**Decisions already made for you.** Both audit rounds found items where the issue left a real choice +open. Those calls are recorded in your packet in bold. Implement the recorded decision; if you think +it is wrong, report the reason and stop. + +**Stop conditions.** Stop and report when the fix needs a path you do not own, when it needs a policy +no issue has fixed, when a locale key is unavoidable, or when hosted CI fails for a reason outside +your diff. + +**Report format.** Per PR: number, exact head SHA, CI run id and conclusion, the issue it closes, the +co-authors credited, the hunks you dropped from a carried PR, and any decision you made. Say +`NOT RUN` for local checks. + +**Decision boundary.** You do not merge, do not close another author's PR, and do not rank your lane +against another. When your last PR is green, report and stop. + +## L7 — documentation + +Worktree `~/.codex/worktrees/260911-l7/opencodex`, branch `codex/260911-l7-docs`. + +Owned: `docs-site/src/content/docs/guides/providers.md`, +`docs-site/src/content/docs/guides/remote-hub.md`. You are the only lane that may edit +`providers.md`; L2 will send you wording rather than editing it. + +1. **#4215 — state whether each provider login consumes a subscription allowance or bills per + token.** Write the rule per authentication mode, then one explicit line per provider supporting + both. +2. **#4200 — the remote hub guide breaks on a fresh config** (nested `ocx config set` fails when the + parent object is absent) and has no macOS data-plane TLS example. English source first; + translations are a follow-up. + diff --git a/devlog/_plan/260911_l7_docs/010_roadmap.md b/devlog/_plan/260911_l7_docs/010_roadmap.md new file mode 100644 index 0000000000..602be58a70 --- /dev/null +++ b/devlog/_plan/260911_l7_docs/010_roadmap.md @@ -0,0 +1,64 @@ +# L7 roadmap — two documentation units, locked before either guide is edited + +Base: `origin/dev` at rebase time, branch `codex/260911-l7-docs`. Packet: `000_packet.md`. + +This is the docs-first cycle the loop requires. Nothing in `docs-site/` is edited until the two +wordings below are locked, because both issues propose wording that the source does not support and +writing first would have shipped two wrong claims. + +## Unit order + +1. **#4215** — `docs-site/src/content/docs/guides/providers.md`. The rule per authentication mode, + then one explicit line per provider that accepts both a subscription login and an API key. + Wording locked in `020_4215_wording.md`. +2. **#4200** — `docs-site/src/content/docs/guides/remote-hub.md`. Fresh-config object + initialization, field preservation, data-plane versus management-plane separation, and a macOS + Tailscale Serve data-plane TLS path. Recipe locked in `030_4200_recipe.md`. + +PR 1 targets `dev`. PR 2 targets PR 1's head branch and is retargeted to `dev` after PR 1 lands. + +## What this lane may touch + +`docs-site/src/content/docs/guides/providers.md`, `docs-site/src/content/docs/guides/remote-hub.md`, +one new regression test per unit plus its two registrations in `scripts/test-layout/layout.json` and +`tests/fixtures/test-layout-expected.json`, and this devlog unit. Nothing else — no `src/`, no +`gui/src/i18n/`, and none of the seven translated copies of either guide. Translations are a +follow-up, which is what #4200's own review comment asks for. + +## Verification posture + +The local product suite is NOT RUN by operator instruction: no `bun test`, no `bun run test`, no +`bun run test:changed`, no `bun run typecheck`, no `bun run build:gui`, no `bun install`. Hosted CI +on the exact pushed head is the only product evidence this round accepts. Every mutating git command +is prefixed with `git -c core.hooksPath=/dev/null` and every push uses `--no-verify`, because this +repository's hooks can start a GUI install, a typecheck, and a build — the same forbidden work +through a side door. + +Confidence that would normally come from a local run comes instead from read-only `xai/grok-4.6` +subagents: every claim written into either guide carries a `path:line` anchor recorded in the unit +doc, and the staged diff is reviewed adversarially before each push. + +## Regression tests + +Both guards live in `tests/ci-workflows/`, beside `docs-429-failover-claims.test.ts`, which is the +existing precedent for pinning a published claim that drifted away from the runtime. A subagent +confirmed that directory is the docs-guard home and that no test anywhere asserts the +`config parent path not found` behaviour today. + +Each new file is registered in `scripts/test-layout/layout.json` `explicit` and in +`tests/fixtures/test-layout-expected.json`. Both maps are append-only and other lanes are appending +too; the orchestrator resolves the merge conflicts. + +## Open decisions this lane had to make + +- **#4215 form.** The issue left "table or per-section sentence" open. The packet decided: the rule + per authentication mode first, then one explicit line per dual-mode provider. A table carries the + per-provider lines because the reader's question is a lookup. +- **#4215 scope.** The issue left "dual-mode providers only, or the full roster" open. The packet + decided dual-mode only. Providers that offer exactly one mode are already unambiguous. +- **#4215 verification surface.** The issue asks the guide to point at "the account card in the + dashboard". No such per-account badge exists. The guide points at the surface that does exist — + see `020_4215_wording.md`. +- **#4200 product change.** The issue's review comment explicitly leaves auto-creating a missing + parent object out of scope. This lane documents the CLI as it behaves and does not touch + `src/cli/config-command.ts`. diff --git a/devlog/_plan/260911_l7_docs/020_4215_wording.md b/devlog/_plan/260911_l7_docs/020_4215_wording.md new file mode 100644 index 0000000000..5acbc1618a --- /dev/null +++ b/devlog/_plan/260911_l7_docs/020_4215_wording.md @@ -0,0 +1,140 @@ +# #4215 — locked wording: which account a provider login spends + +Target: `docs-site/src/content/docs/guides/providers.md`. Closes #4215. + +Revision 3. Revisions 1 and 2 were each rejected by an adversarial read-only audit that opened every +anchor. Revision 1 had wrong line numbers; revision 2 had a deeper problem, and fixing it changed +what this document promises. + +## The framing correction that revision 3 is built on + +Revision 2 wrote sentences like "spends the Claude subscription allowance" and cited a registry or +quota line. The audit's objection is correct and it matters: **those lines do not state vendor +billing terms.** `registry.ts` states an id, a transport, a base URL and an auth kind. +`quota.ts` states which usage windows OpenCodex reads. Neither knows what Anthropic or xAI charges. +Citing them as proof of a billing claim is exactly the kind of overreach a maintainer should reject, +and `quota.ts:1404`-`1409` says outright that the Anthropic endpoint reports no tier at all. + +So the guide answers the question the reader is actually asking — *which account does this spend?* — +in terms the source can support: + +- which credential the request carries, +- which account that credential authenticates as, +- which usage windows OpenCodex reads back, if any. + +Whether that account is billed per token or against a plan is the vendor's term, not OpenCodex's. The +guide says so once, plainly, and does not pretend otherwise per provider. + +## Why the issue's own suggested wording could not be shipped verbatim + +The issue proposes "an OAuth or subscription login consumes that subscription's allowance, an API key +bills that key's account per token, and OpenCodex does not convert one into the other." + +**"Never converts" is refuted.** `orcarouter-oauth` is a browser-consent PKCE flow whose purpose is +to mint a durable user-owned `sk-orca-…` key and store it in the OAuth credential store +(`src/oauth/orcarouter.ts:12` for the prefix, `:95`-`99` for the long-lived-key comment; +`src/providers/registry.ts:1441`). The login *is* the key delivery mechanism. + +**"Bills per token" is not universally true of a key.** Several key presets are subscriptions bought +as a key: `zai` GLM Coding Plan (`registry.ts:2544`), `zhipu-bigmodel-coding` whose comment says +"this endpoint is the subscription product" (`registry.ts:2619`), `kimi-code` "API-key form of the +same Kimi Code Plan transport" (`registry.ts:2971`). CodeBuddy is blunter: "Free/trial/promotional/ +subscription credits draw from the same official API-key pool" (`registry.ts:3234`). + +**What is true, and is what gets written.** One request carries exactly one credential family. +`DispatchBinding` is either `{kind:"oauth"}` or `{kind:"api-key"}` (`src/server/responses/core.ts:3974`-`3976`). +When an OAuth credential cannot be resolved the request fails back to the caller — the catch returns +`401 authentication_error` and the same block returns 429, 409 and 400 on other paths +(`core.ts:4268`-`4393`) — and at no point does it reach for a stored key. Key-pool 429/401 failover +refuses oauth and forward outright (`src/providers/key-failover.ts:99`). *Audit correction: revision 2 +said 401 was the only outcome of that block.* + +**The key-deletion claim needs a carve-out.** OAuth login deletes `apiKey` and `apiKeyPool` under +the comment "OAuth-only providers must never retain credentials for a different auth mechanism" +(`src/oauth/index.ts:1494`-`1497`). The next block restores them when the preset sets +`allowKeyAuthOverride`, and if the previous `authMode` was `key` or unset it sets `authMode` back to +`"key"` (`oauth/index.ts:1498`-`1519`). On `xai` and `github-copilot`, running `ocx login` over an +existing key configuration can leave the provider still billing the key. The guide says so. + +## Locked rule, per authentication mode + +- `forward` — the ChatGPT/Codex login. The request carries your Codex credential, so it spends the + ChatGPT plan behind that login and reports that plan's Codex quota windows. **Which windows exist + is plan-dependent** — the five-hour window is not universal and some plans report weekly only + (`src/codex/quota.ts:441`-`442`). It never reads an API key: an `openai` provider row with no + `authMode` is backfilled to `forward` in `routedProviderConfig` (`src/router.ts:313`-`319`, `:429`), + and the forward branch of the adapter uses the Codex account override, with `provider.apiKey` only + on the else branch (`src/adapters/openai-responses.ts:2319`-`2367`, `:2363`). *Audit corrections: + revision 2 promised all three windows unconditionally and cited `router.ts:654`, which is a + namespace-check mirror rather than the backfill.* +- `oauth` — a subscription login. The request carries a stored access token, so it spends whatever + account you logged in as, and OpenCodex reports whichever usage windows that provider exposes. +- `key` — the request carries the key you supplied, so usage lands on the account that owns it, on + that key's own terms: metered usage for a pay-as-you-go API account, or a plan allowance when the + key *is* a coding-plan subscription. The guide says "per token" only where the source does, as in + `meta-model` — "every call is metered per token" (`registry.ts:1639`). +- A request never swaps families. + +Two stated exceptions, because both are shipped behaviour a reader can hit: + +- `xai` and `github-copilot` accept `authMode: "key"` on the same provider id + (`allowKeyAuthOverride`, `registry.ts:1274` and `registry.ts:3157`). Which account pays is a + persisted configuration choice, and per the carve-out above, logging in does not necessarily take + you out of key mode. +- `orcarouter-oauth` mints a user-owned API key from the consent flow + (`src/oauth/orcarouter.ts:12`, `:95`-`99`, `registry.ts:1441`). From then on the request carries a + key, so it falls under the `key` rule above: usage lands on the OrcaRouter account that owns it. + +## Locked per-provider lines + +Each cell states which account the credential authenticates as, and nothing about vendor pricing that +the anchor does not carry. + +| Provider | Subscription login | API key | +|---|---|---| +| OpenAI / ChatGPT | `openai` — Codex login, `authKind: "forward"`; spends the ChatGPT plan behind that login (`registry.ts:1222`) | `openai-apikey` — a separate preset; usage lands on the OpenAI Platform account that owns the key (`registry.ts:1573`) | +| Anthropic | `ocx login anthropic` — signs in as your Claude account (`registry.ts:1444`); OpenCodex reads its five-hour and seven-day usage windows, and that endpoint reports no tier (`quota.ts:1404`-`1409`) | `anthropic-apikey`, labelled "Direct Anthropic API billing — no Claude subscription" (`registry.ts:1472`) | +| xAI | `ocx login xai` — the Grok CLI subscription gateway; OpenCodex reads SuperGrok weekly credits, else the legacy monthly pool (`quota.ts:1285`-`1370`) | same `xai` id with `authMode: "key"` (`registry.ts:1274`); key mode targets `https://api.x.ai/v1` (`registry.ts:1272`), so usage lands on that API account | +| Kimi | `ocx login kimi` — "Log in with your Kimi account" (`registry.ts:1481`, note at `:1496`) | `kimi-code`, "API-key form of the same Kimi Code Plan transport" (`registry.ts:2971`) | +| Command Code | `ocx login command-code` — OpenCodex reads five-hour and weekly windows plus a credit balance (`quota.ts:2392`-`2447`) | `commandcode` on `/provider/v1` (`registry.ts:2262`) | +| GitHub Copilot | `ocx login github-copilot` — "Requires an active Copilot subscription" (`registry.ts:3183`) | same `github-copilot` id with `authMode: "key"` (`registry.ts:3157`) | +| OrcaRouter | `ocx login orcarouter-oauth` — consent mints a user-owned, long-lived `sk-orca-…` key (`src/oauth/orcarouter.ts:12`, `:95`); the request then carries a key, so usage lands on that key's account | `orcarouter` — the same key pasted by hand (`registry.ts:1924`) | +| Meta Muse | `ocx login meta-muse` (`registry.ts:1650`) imports the Muse Code CLI key. Meta scopes that credential to its own CLI, so this is an **unsupported** use: how the calls settle is not observable from the API and every call should be treated as billable against your account (`registry.ts:1666`) | `meta-model` is the supported path — "every call is metered per token", and a Muse Code subscription does **not** work there (`registry.ts:1639`) | +| Google Antigravity | `ocx login google-antigravity` — signs in with your Google account over the Cloud Code Assist wire (`registry.ts:1997`) | no key mode on this id. `google` is a separate AI Studio Gemini preset reached with its own key (`registry.ts:1978`), so it is a different product rather than a key mode for this login | + +*Audit corrections folded here: Meta Muse retargeted from `:1673` (which is `umans`) to `:1650` and +`:1666`; Kimi, Antigravity and xAI reworded so the sentence matches what its anchor says. A third +audit pass then removed the last two vendor-billing words the anchors did not carry — OrcaRouter's +"balance" and Google's "billing".* + +Login-only, no API-key equivalent: `cursor` (`registry.ts:1233`), `kiro` (`registry.ts:1513`), +`nous` (`registry.ts:1546`). Two audits searched the registry for a login+key twin this table might +have missed and found none. + +## Where the reader verifies their own setup + +The issue asks the guide to say that "the account card in the dashboard shows which mode an account +is using". It does not. The per-account rows render alias, email, id, health, reauth and active +state, and carry no auth-mode badge (`ProviderAuthPanel.tsx:504`-`527`). + +The mode is a **provider-level** field (`provider-routes.ts:737`) rendered as the Connection block's +**Authentication** row (`ProviderOverview.tsx:167`, label at `:168`). Its `authModeLabel` is `OAuth`, +`API key`, `ChatGPT passthrough` or `Local`, with a `No key needed` fallback for a preset that needs +no credential (`gui/src/i18n/en.ts:1136`-`1194`, `ProviderRail.tsx:34`). *Audit correction: revision 2 +listed only four labels.* + +The guide does **not** claim the separate **AUTHENTICATION** block prints those labels — for forward +it prints "Codex passthrough" (`en.ts:1425`), and for oauth and key it prints login state and +"API key configured". + +**Decision: the guide points at the surface that exists** and names the labels that row actually +renders, rather than repeating the issue's phrasing and sending readers hunting for a badge that was +never built. No locale key is added; every label quoted already ships. + +## Regression guard + +`tests/ci-workflows/docs-provider-billing-claims.test.ts`, beside `docs-429-failover-claims.test.ts`. +It pins the claims that would silently rot: the per-mode rule is present, every dual-mode provider +above has a row naming both of its modes, and the guide does not reintroduce the refuted absolute +"never converts" phrasing. Registered in `scripts/test-layout/layout.json` and +`tests/fixtures/test-layout-expected.json`. NOT RUN locally; hosted CI is the proof. diff --git a/devlog/_plan/260911_l7_docs/030_4200_recipe.md b/devlog/_plan/260911_l7_docs/030_4200_recipe.md new file mode 100644 index 0000000000..4f388c09d7 --- /dev/null +++ b/devlog/_plan/260911_l7_docs/030_4200_recipe.md @@ -0,0 +1,157 @@ +# #4200 — locked recipe: fresh-config initialization and the macOS data plane + +Target: `docs-site/src/content/docs/guides/remote-hub.md`. Closes #4200. + +Revision 2, after the same adversarial audit that rejected revision 1 of `020`. Three anchors here +were wrong; they are corrected and marked below. Every behavioural claim survived. + +## Defect 1 — the nested set the guide tells you to run + +`setPath` walks `segments.slice(0, -1)` and throws when a parent is absent +(`src/cli/config-command.ts:59`-`61`): + +``` +config parent path not found: hub +``` + +`ocx config set runtimeRole hub` does **not** create the object — it assigns one leaf +(`config-command.ts:67`), and neither `runtimeRole` nor `hub` has a default +(`src/config.ts:1144`-`1147`, `getDefaultConfig` at `config.ts:3872`). So the guide's very next +three lines cannot run on the fresh standalone install it just told the reader to make. + +**No test anywhere asserts this behaviour.** The only other place the workaround is documented is +`docs-site/src/content/docs/reference/configuration/server.md:248`. + +### Locked fix + +Two supported forms, and the guide shows both because they are good at different things. + +1. **Initialize the empty parent, then set fields.** `hub` has no parent segments, so + `ocx config set hub '{}'` assigns the leaf directly and succeeds; `{}` is a valid `hub` object + (`tests/server/loopback-listener-admission.test.ts:183`-`185`). Every later `hub.` set + then finds an object parent. This is the form for adapting a config that may already have the + object, because each nested set replaces one key only. +2. **Set the whole object in one call**, which is what the issue proposes, for a fresh config. + +### The warning the issue asked for, stated precisely + +A whole-object set **replaces**, it does not merge: `setPath` ends in `current[leaf] = value` +(`config-command.ts:67`) with no `Object.assign`. Writing `hub '{"managementPublicOrigin":"…"}'` on +a config that already had `hub.managementIngress` silently drops the ingress. + +### Two facts worth stating that the issue did not raise + +- The value argument is parsed as JSON first and falls back to the raw string + (`config-command.ts:70`-`73`). That is why the guide writes `'"https://…"'`. Objects, arrays, + booleans and numbers **must** be valid JSON; a bare URL only works by falling through the catch. +- `hub` and `remoteGui` are `.strict()` (`src/config.ts:1029`-`1045`, `1056`-`1070`), so a mistyped + key is rejected at write time as `schema_invalid: hub.` (`config.ts:2640`), and + `managementPublicOrigin` must be a canonical origin with no path, query or fragment + (`config.ts:1031`-`1037`). A reader who mistypes gets a real error rather than a dead setting. + +## Defect 2 — the macOS data plane has no TLS path + +The guide binds the data listener to the tailnet IP, publishes only the loopback **management** +ingress through Serve, then shows `ocx connect` against an HTTPS origin. The management ingress is +default-deny for `/v1/*`, `/healthz` and `/readyz`, which 404 before any handler +(`src/server/index.ts:851`-`872`, `1083`-`1088`). The guide never closes the data plane. + +**opencodex terminates no TLS of its own.** `Bun.serve` is called with `port`, `hostname`, +`idleTimeout`, `maxRequestBodySize` and `fetch` (`src/server/index.ts:1063`-`1069`, `2405`); there +is no `tls`/`cert`/`key` field anywhere in `OcxConfig`. TLS is always the operator's frontend, and +the guide says so outright. + +### The macOS constraint, from Tailscale's own documentation + +Serve's HTTP reverse-proxy backend is limited to `127.0.0.1`, so it cannot proxy to another address +— including the node's own tailnet IP +([serve CLI reference](https://tailscale.com/docs/reference/tailscale-cli/serve)). The App Store +build adds a sandbox restriction on top: it proxies local ports but not an arbitrary remote +destination ([macOS variants](https://tailscale.com/docs/concepts/macos-variants)). That is exactly +the refusal the issue reports, and it is a platform limitation, not an opencodex defect. + +### The admission predicate that decides the whole recipe + +`isApiAuthRequired` is `!isLoopbackHostname(config.hostname)` — keyed on the **configured bind +address**, not on the socket the request arrived on and not on the `Host` header +(`src/server/auth-cors.ts:288`-`290`; the public listener passes `config` straight through at +`src/server/index.ts:1095`). *Audit correction 8: revision 1 said "via `requestPolicyView`", which +is the separate unauthenticated loopback listener, not this path.* + +`isAllowedRequestOrigin` then branches: the loopback arm is `auth-cors.ts:90`-`94` and the +non-loopback arm is `auth-cors.ts:96`. *Audit correction 10: revision 2 cited `90`-`94` for both +arms, and that range is only the loopback one.* + +- **Loopback bind.** No data credential is required, and the request's `Host` must itself be + loopback. A TLS terminator forwards `Host: hub-name.tailnet-name.ts.net`, so `/v1/catalog` + returns `403 origin_rejected` (`src/server/index.ts:1303`). +- **Non-loopback bind.** A data credential is required and the `Host` check does not apply; a CLI + client sends no `Origin`, so it is admitted. + +Nothing in the request path reads `X-Forwarded-Host` or `Forwarded` — tests send them precisely to +prove they are ignored — so the terminator cannot repair this. + +| `hostname` | Serve can reach it? | `/v1/catalog` | Verdict | +|---|---|---|---| +| `127.0.0.1` | yes, directly | **403 `origin_rejected`** — `Host` is the ts.net name | the trap; `/readyz` still passes and hides it | +| `0.0.0.0` | yes, via loopback | works, credential required | works, but publishes the data port on every interface | +| tailnet IP | **no** — Serve's backend must be `127.0.0.1` | works, credential required | correct bind; needs a loopback forwarder in front | + +`/readyz` does not run the `Host` check (`src/server/index.ts:1222`-`1242`), which is why the trap is +silent: readiness passes and the catalog fails. + +### Locked recipe + +Keep `hostname` on the tailnet IP, put a loopback TCP forwarder in front of it, and point Serve at +the forwarder — which is what the reporter actually deployed successfully. Two Serve mappings: +management on `--https=443` to `127.0.0.1:10101`, data on `--https=8443` to the forwarder. The guide +tells the reader to confirm both with `tailscale serve status` rather than asserting which HTTPS +ports Serve permits. Then: + +``` +ocx connect https://hub-name.tailnet-name.ts.net:8443 \ + --management-url https://hub-name.tailnet-name.ts.net \ + --admin-token-stdin +``` + +The positional URL is the **data-plane** origin: `GET /readyz` (`src/client/connect.ts:506`-`519`) +and then the catalog download against that same `serverUrl` (`connect.ts:542`, +`src/client/hub-client.ts:432`-`445`). *Audit correction 6: revision 1 cited `hub-client.ts:174`-`201`, +which is URL normalization, not the catalog fetch.* + +`--management-url` is a separate management origin, and when omitted it is taken from the `/readyz` +metadata — which is `hub.managementPublicOrigin` (`src/remote/protocol.ts:46`-`55`). The two are +resolved independently at `connect.ts:506`-`519` and nothing requires them to match: the catalog is +fetched from `serverUrl` (`connect.ts:542`) while key issuance goes to `managementUrl` +(`connect.ts:532`). *Audit correction 7: revision 1 cited `normalizeHubOrigin`, which validates a +single URL and proves nothing about the pair. Audit correction 11: revision 2 pointed key issuance +at the resolution and catalog lines instead of `issueClientKey` at `:532`.* + +The guide keeps its existing acceptance rule — `/readyz`, an authenticated `GET /v1/catalog`, and +one real routed response. This recipe is what finally makes all three reachable on macOS. + +### Correction the guide needs anyway + +The troubleshooting list still offers `--allow-insecure-http` +(`docs-site/src/content/docs/guides/remote-hub.md:332`). That flag does not exist: it is absent from +`CONNECT_USAGE` (`src/cli/connect.ts:31`-`39`), pairing refuses non-loopback HTTP outright +(`src/client/hub-client.ts:247`-`254`), and `remoteGui.allowInsecureHttp` is a retired no-op +(`src/config.ts:1067`-`1069`). + +**Decision: fix it in the same PR.** It is one line in an owned file, it is the same class of defect +the issue reports — a published command that cannot run — and leaving a known-false command next to +the one being corrected would be indefensible. The PR says so explicitly. + +The dead flag also appears in all seven translations (`ko:137`, `ja:107`, `zh-cn:104`, `zh-tw:85`, +`fr:106`, `ru:109`, `tr:109`). Those files are outside this lane's owned paths and are recorded as +a follow-up, consistent with the issue's own "English source first, translations later". + +## Regression guard + +`tests/ci-workflows/docs-remote-hub-claims.test.ts`, beside `docs-429-failover-claims.test.ts`. +It pins: the setup section never issues a nested `ocx config set hub.` or +`remoteGui.` before the parent object exists, the replace-not-merge warning is present, the +guide states opencodex terminates no TLS itself, `ocx connect` appears with a data URL and a +separate `--management-url`, and `--allow-insecure-http` does not reappear. Registered in +`scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`. NOT RUN locally; +hosted CI is the proof. diff --git a/devlog/_plan/260911_l7_docs/040_4215_delivery.md b/devlog/_plan/260911_l7_docs/040_4215_delivery.md new file mode 100644 index 0000000000..fbaeb6b1d9 --- /dev/null +++ b/devlog/_plan/260911_l7_docs/040_4215_delivery.md @@ -0,0 +1,68 @@ +# #4215 — delivery record + +Commit `6db526a8a1` on `codex/260911-l7-docs`. Closes #4215. + +## What shipped + +`docs-site/src/content/docs/guides/providers.md` gains a `### Which account a request spends` +subsection under the existing **Auth modes** heading: the rule per `authMode`, the two shipped +exceptions, a table of the eight providers that accept both a login and a key, a login-only line, +and a pointer at the Connection block's **Authentication** row. + +`tests/ci-workflows/docs-provider-billing-claims.test.ts` guards it, registered in +`scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`. + +## What the audits changed + +Three rounds, and each one changed the shipped text rather than merely approving it. + +**Round 1 rejected the issue's own wording.** "OpenCodex does not convert one into the other" is +false while `orcarouter-oauth` ships, and "an API key bills per token" is false for the several +presets that sell a subscription as a key. Both are now stated as what is actually true: a request +uses one credential family and never falls back across them. + +**Round 2 rejected the framing itself.** Revision 2 wrote "spends the Claude subscription +allowance" and cited a registry line. The registry states an id, a transport and an auth kind; it +does not state vendor billing terms, and `quota.ts:1404`-`1409` says the Anthropic endpoint reports +no tier at all. The guide now answers in terms the source can support — which credential the request +carries, which account it authenticates as, which windows opencodex reads back — and says once that +the billing terms belong to the vendor. + +**Round 3 caught two contradictions inside the shipped diff.** + +- The table was headed "Providers that accept both" and carried a Google Antigravity row whose own + text said it has no key mode. The heading now reads "Providers that accept both a login and a + key", and Antigravity moved to the login-only paragraph, where its neighbour `google` is named as + a different product rather than a key mode for the same login. The test's `DUAL_MODE` list lost + that entry, so CI no longer pins a row that refutes its own heading. +- The GitHub Copilot row implied that choosing a key changes which account pays, which fights + `providers.md:909` — Copilot exchanges a device-flow login for a short-lived Copilot token, not a + pasted API key. The row and the exceptions bullet now separate the two cases: an `xai` key + retargets the provider to `https://api.x.ai/v1` so a different account pays, while a + `github-copilot` key is still a Copilot credential against `api.githubcopilot.com`, so the + subscription pays either way. + +## Decisions this lane made + +- **Form.** Rule first, then one row per dual-mode provider, as the packet directed. A table + carries the rows because the reader's question is a lookup. +- **Scope.** Dual-mode providers only. A provider with one mode is already unambiguous. +- **Verification surface.** The issue asked the guide to point at "the account card". No + per-account auth-mode badge exists (`ProviderAuthPanel.tsx:504`-`527`); the mode is a + provider-level field, so the guide points at the Connection block's **Authentication** row and + names the five labels it renders, including the `No key needed` fallback. +- **Copilot stays in the table** even though both of its modes spend the same subscription, because + it genuinely accepts both credential forms and a reader who sees `authMode: "key"` in a config + needs to know it does not move the bill. + +## Verification + +The local product suite was NOT RUN by operator instruction: no `bun test`, no `bun run test`, no +`bun run test:changed`, no `bun run typecheck`, no `bun run build:gui`, no `bun install`. Hosted CI +on the exact pushed head is the proof. + +Because the new test could not be executed locally, a read-only subagent verified it by reading: +every `toContain`, `not.toContain` and regex literal was located in `providers.md`, checked against +the section-slice boundary, and confirmed to sit in the cell the assertion intends — including the +backtick delimiters that stop a login marker from matching a key cell. That check was re-run from +scratch after the table lost a row. diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 0986a883c0..d4a0f1c724 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -81,6 +81,59 @@ credentials must never be replayed on the same token, and local runtimes have no preserve. It is opt-in: when the option is absent the feature is off; object presence enables it unless `enabled: false`. +### Which account a request spends + +The question people ask before connecting an account is whether opencodex will draw on the +subscription that login already pays for, or bill a separate API account. The answer follows the +`authMode` above rather than the vendor's marketing tier. + +- `forward` — the ChatGPT login. The request carries your Codex credential, so it spends the + ChatGPT plan behind that login and reports that plan's Codex quota windows. Which windows exist + is plan-dependent: not every plan has a five-hour window. It never reads an API key. +- `oauth` — a subscription login. The request carries a stored access token, so it spends whichever + account you logged in as, and opencodex reports whichever usage windows that provider exposes. +- `key` — the request carries the key you supplied, so usage lands on the account that owns that + key, on that key's own terms. That is metered usage for a pay-as-you-go API account, but a plan + allowance when the key *is* a subscription: Z.AI GLM Coding Plan, Kimi Code, the BigModel coding + plan, Command Code and CodeBuddy all sell one that way. + +A request uses exactly one of these, and opencodex does not fall back from one to the other. When an +OAuth credential cannot be resolved the request fails with an authentication error instead of +reaching for a stored key, and the key-pool failover that answers a 429 or a 401 is refused outright +for OAuth and forward providers. + +Two exceptions are worth knowing because you can hit them: + +- `xai` and `github-copilot` accept `authMode: "key"` on the same provider id, and if that provider + already had a key stored, running `ocx login` for it can leave it in key mode rather than + switching it to the subscription. What that changes differs: an `xai` key retargets the provider + to `https://api.x.ai/v1`, so a different account pays, while a `github-copilot` key is still a + Copilot credential against `api.githubcopilot.com`, so the Copilot subscription pays either way. +- `orcarouter-oauth` is a consent flow that mints a user-owned `sk-orca-…` API key. Once it has, + the request carries a key, so it follows the `key` rule above. + +#### Providers that accept both a login and a key + +| Provider | Subscription login | API key | +| --- | --- | --- | +| OpenAI / ChatGPT | `openai` — Codex login; spends the ChatGPT plan behind it | `openai-apikey` — a separate provider; usage lands on the OpenAI Platform account that owns the key | +| Anthropic | `ocx login anthropic` — signs in as your Claude account. opencodex reads its five-hour and seven-day usage windows; that endpoint reports no subscription tier | `anthropic-apikey` — direct Anthropic API billing, no Claude subscription | +| xAI | `ocx login xai` — the Grok CLI subscription gateway. opencodex reads SuperGrok weekly credits, or the monthly pool | the same `xai` provider with `authMode: "key"`, which targets `https://api.x.ai/v1`, so usage lands on that API account | +| Kimi | `ocx login kimi` — log in with your Kimi account | `kimi-code` — the API-key form of the same Kimi Code Plan transport | +| Command Code | `ocx login command-code` — opencodex reads five-hour and weekly windows plus a credit balance | `commandcode` — the same service on `/provider/v1` with a key | +| GitHub Copilot | `ocx login github-copilot` — requires an active Copilot subscription | the same `github-copilot` provider with `authMode: "key"`. The device flow above is the supported path, and either credential is a Copilot one, so the subscription still pays | +| OrcaRouter | `ocx login orcarouter-oauth` — consent mints a user-owned, long-lived `sk-orca-…` key, and the request then carries a key | `orcarouter` — the same key pasted by hand | +| Meta Muse | `ocx login meta-muse` imports the Muse Code CLI key. Meta scopes that credential to its own CLI, so this is an unsupported use: how the calls settle is not observable from the API, and you should treat every call as billable against your account | `meta-model` is the supported path — every call is metered per token, and a Muse Code subscription does not work there | + +Cursor, Kiro and Nous Portal are login-only and have no API-key equivalent. Google Antigravity is +login-only too: `ocx login google-antigravity` signs in with your Google account over the Cloud Code +Assist wire, and the `google` preset beside it is the AI Studio Gemini API — a different product +reached with its own key, not a key mode for the same login. + +To check which mode a provider is actually using, open it on the Providers page: the **Connection** +block's **Authentication** row reads `OAuth`, `API key`, `ChatGPT passthrough`, `Local`, or +`No key needed`. It is a provider-level setting, so the account rows below it do not repeat it. + ## 1. ChatGPT login (forward / passthrough) The `openai` provider needs **no API key**. Direct forwards credentials from your existing diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index b5ea45c4a3..211e1f5c07 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -613,6 +613,7 @@ "digitalocean-scaleway-provider.test.ts": "providers", "docs-429-failover-claims.test.ts": "ci-workflows", "docs-bun-source-requirement.test.ts": "ci-workflows", + "docs-provider-billing-claims.test.ts": "ci-workflows", "docs-readme-translation-parity.test.ts": "ci-workflows", "doctor-codex-envkey-readiness.test.ts": "service", "doctor-oauth.test.ts": "service", diff --git a/tests/ci-workflows/docs-provider-billing-claims.test.ts b/tests/ci-workflows/docs-provider-billing-claims.test.ts new file mode 100644 index 0000000000..831202aea4 --- /dev/null +++ b/tests/ci-workflows/docs-provider-billing-claims.test.ts @@ -0,0 +1,119 @@ +/** + * The providers guide must keep answering "which account does this login spend?". + * + * The question arrives from users faster than the docs answer it (#4215), and the two obvious + * ways to answer it are both wrong. "OpenCodex never converts a subscription login into an API + * key" is false -- orcarouter-oauth mints a user-owned sk-orca- key by design. "An API key bills + * per token" is false too -- Z.AI GLM Coding Plan, Kimi Code, the BigModel coding plan, Command + * Code and CodeBuddy all sell a subscription as a key. Both sentences read as obviously true, + * which is exactly why a future edit will reach for them again. + * + * So this pins the shape that survived three source audits: a rule stated per authMode, one row + * per provider that accepts both, and a pointer at the dashboard surface that actually renders + * the mode. The per-provider rows rot first, because a new dual-mode preset lands in the registry + * without anyone reopening this guide. + */ +import { describe, expect, test } from "bun:test"; +import { repoPath } from "../helpers/repo-root"; + +const GUIDE = repoPath("docs-site/src/content/docs/guides/providers.md"); +const HEADING = "### Which account a request spends"; + +/** The subsection only, so a marker that also appears in the API-key catalog cannot satisfy it. */ +async function billingSection(): Promise { + const source = await Bun.file(GUIDE).text(); + const start = source.indexOf(HEADING); + expect(start, `providers.md lost the "${HEADING}" section`).toBeGreaterThanOrEqual(0); + const rest = source.slice(start); + const end = rest.indexOf("\n## "); + return end === -1 ? rest : rest.slice(0, end); +} + +/** + * Each provider that accepts a subscription login AND an API key. Both markers must appear on that + * provider's row: a row naming only one mode leaves the reader guessing about the other, which is + * the failure #4215 reported. + */ +const DUAL_MODE = [ + { label: "OpenAI / ChatGPT", login: "`openai`", key: "`openai-apikey`" }, + { label: "Anthropic", login: "ocx login anthropic", key: "`anthropic-apikey`" }, + { label: "xAI", login: "ocx login xai", key: '`authMode: "key"`' }, + { label: "Kimi", login: "ocx login kimi", key: "`kimi-code`" }, + { label: "Command Code", login: "ocx login command-code", key: "`commandcode`" }, + { label: "GitHub Copilot", login: "ocx login github-copilot", key: '`authMode: "key"`' }, + { label: "OrcaRouter", login: "ocx login orcarouter-oauth", key: "`orcarouter`" }, + { label: "Meta Muse", login: "ocx login meta-muse", key: "`meta-model`" }, +] as const; + +describe("provider billing claims", () => { + test("the rule is stated per authentication mode, not per vendor tier", async () => { + const section = await billingSection(); + + // forward: the ChatGPT plan, and the windows are plan-dependent. The unqualified version of + // this sentence promised every plan a five-hour window, which is not true. + expect(section).toContain("plan-dependent"); + expect(section).toContain("It never reads an API key."); + + // key: the account that owns the key, on that key's own terms -- which may be a plan. + expect(section).toContain("usage lands on the account that owns that"); + expect(section).toContain("but a plan"); + + // No cross-family fallback, stated as a failure rather than as a silent swap. + expect(section).toContain("fails with an authentication error"); + }); + + test("both shipped exceptions to the rule are stated", async () => { + const section = await billingSection(); + // A key persisted on an OAuth preset survives login, so logging in does not necessarily move + // billing to the subscription. + expect(section).toContain('`authMode: "key"` on the same provider id'); + // The consent flow that really does hand back an API key. + expect(section).toContain("sk-orca-"); + + // Copilot accepts a key on the same provider id, but that key is still a Copilot credential. + // The guide says elsewhere that the device flow is the supported path, so this section must not + // imply a key moves Copilot billing to some other account. + expect(section).toContain("the subscription still pays"); + }); + + test("every dual-mode provider names both of its modes", async () => { + const section = await billingSection(); + const rows = section.split("\n").filter(line => line.startsWith("| ")); + for (const provider of DUAL_MODE) { + const row = rows.find(line => line.startsWith(`| ${provider.label} |`)); + expect(row, `no row for ${provider.label}`).toBeDefined(); + expect(row, `${provider.label} row lost its subscription login`).toContain(provider.login); + expect(row, `${provider.label} row lost its API key`).toContain(provider.key); + } + }); + + test("the login-only providers are named so their absence is not read as an omission", async () => { + const section = await billingSection(); + expect(section).toContain("Cursor, Kiro and Nous Portal are login-only"); + // Antigravity is the provider a reader is most likely to mistake for a dual-mode one, because + // a `google` preset sits beside it in the catalog. It is a different product, so it belongs + // here rather than in the table above -- a row there would contradict its own heading. + expect(section).toContain("Google Antigravity is"); + expect(section).toContain("not a key mode for the same login"); + }); + + test("the reader is pointed at a dashboard surface that exists", async () => { + // #4215 asked for "the account card", which carries no auth-mode badge: the mode is a + // provider-level field on the Connection block. Sending a reader to the account rows would + // have them hunting for something that was never built. + const section = await billingSection(); + expect(section).toContain("**Authentication** row"); + for (const label of ["`OAuth`", "`API key`", "`ChatGPT passthrough`", "`Local`", "`No key needed`"]) { + expect(section, `the Authentication row no longer lists ${label}`).toContain(label); + } + expect(section).toContain("the account rows below it do not repeat it"); + }); + + test("the refuted absolute claim does not come back", async () => { + // Both phrasings are the ones a well-meaning edit reaches for, and both are false while + // orcarouter-oauth ships. + const source = await Bun.file(GUIDE).text(); + expect(/never converts/i.test(source)).toBe(false); + expect(source).not.toContain("does not convert one into the other"); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d772205061..02a8b205d5 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -448,6 +448,7 @@ "digitalocean-scaleway-provider.test.ts": "providers", "docs-429-failover-claims.test.ts": "ci-workflows", "docs-bun-source-requirement.test.ts": "ci-workflows", + "docs-provider-billing-claims.test.ts": "ci-workflows", "docs-readme-translation-parity.test.ts": "ci-workflows", "doctor-codex-envkey-readiness.test.ts": "service", "doctor-oauth.test.ts": "service",