From aa788905b1a2e45d0e1771f9d1c34594e0a32eb8 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 00:22:04 +0900 Subject: [PATCH 1/4] docs(devlog): seed the L7 dispatch packet --- devlog/_plan/260911_l7_docs/000_packet.md | 81 +++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 devlog/_plan/260911_l7_docs/000_packet.md 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. + From 091b7f3e0da5710a804d8a84a5fb5daa48e1f6fc Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 08:22:59 +0900 Subject: [PATCH 2/4] docs(devlog): lock the L7 wordings for #4215 and #4200 Three adversarial read-only audits rejected revisions 1 and 2 of the wording unit. The refuted claims are recorded next to the corrected ones so the next reader does not reintroduce them: OpenCodex does not never convert an OAuth login into a key (orcarouter-oauth mints one by design), an API key does not always bill per token (several presets are coding-plan subscriptions bought as a key), and the registry cannot prove vendor billing terms at all - it proves which account a credential authenticates as. --- devlog/_plan/260911_l7_docs/010_roadmap.md | 64 +++++++ .../_plan/260911_l7_docs/020_4215_wording.md | 140 ++++++++++++++++ .../_plan/260911_l7_docs/030_4200_recipe.md | 157 ++++++++++++++++++ 3 files changed, 361 insertions(+) create mode 100644 devlog/_plan/260911_l7_docs/010_roadmap.md create mode 100644 devlog/_plan/260911_l7_docs/020_4215_wording.md create mode 100644 devlog/_plan/260911_l7_docs/030_4200_recipe.md 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. From 6db526a8a17f437e7035dca1b810ea391c5d26e5 Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 08:37:16 +0900 Subject: [PATCH 3/4] docs(providers): state which account each provider login spends The guide never answered the first question a subscriber asks: does connecting this login draw on the subscription it already pays for, or bill a separate API account. Asked on X for Claude Max, and it recurs for every provider that offers both. The rule is stated per authMode rather than per vendor tier, followed by one row for each of the eight providers that accept both a login and a key, and a pointer at the Connection block's Authentication row so a reader can check their own setup. Two claims the issue proposed are not true and are not written. OpenCodex does convert an OAuth login into an API key in one case: orcarouter-oauth mints a user-owned sk-orca key by design. And an API key does not always bill per token, because Z.AI GLM Coding Plan, Kimi Code, the BigModel coding plan, Command Code and CodeBuddy all sell a subscription as a key. What is true, and what the section says, is that a single request uses exactly one credential family and never falls back across them. The issue also asked the guide to point at the dashboard account card. No per-account auth-mode badge exists; the mode is a provider-level field on the Connection block, so the section names the surface that is actually there. Closes #4215 --- .../src/content/docs/guides/providers.md | 53 ++++++++ scripts/test-layout/layout.json | 1 + .../docs-provider-billing-claims.test.ts | 119 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 4 files changed, 174 insertions(+) create mode 100644 tests/ci-workflows/docs-provider-billing-claims.test.ts 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", From ee61b4822a8cfb67fc8fd8d779c84af5eb9e775c Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 08:38:28 +0900 Subject: [PATCH 4/4] docs(devlog): record the #4215 delivery and what three audits changed --- .../_plan/260911_l7_docs/040_4215_delivery.md | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 devlog/_plan/260911_l7_docs/040_4215_delivery.md 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.