Skip to content

M3.5 part F: let a build pay with a Codex subscription seat - #178

Merged
artyomsv merged 8 commits into
masterfrom
feat/pay-with-subscription
Sep 26, 2026
Merged

artyomsv merged 8 commits into
masterfrom
feat/pay-with-subscription

Conversation

@artyomsv

@artyomsv artyomsv commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

What

A repository's build setup can now pay with a signed-in Codex subscription instead of an API key. This is M3.5 part F.

  • Pay with on the build setup: API_KEY (default, as before) or SUBSCRIPTION. It is hashed into the approval (binding version 4), so an approval for one mode does not cover the other.
  • Seats are shared, one per account. Several builds may use one signed-in seat at once, like an API key; seats rotate least recently used first. A per-run lease was built and reviewed twice, and each round found another race between workers; its reason is gone because the agent copy has no refresh token. account_ref holds the measured tokens.account_id, an enabled seat account is unique (V85), a seat with no known account is never used, and signing in again to the same account replaces that seat file (how an expired seat is renewed). Older seats are identified at startup under an advisory lock.
  • Run-worker logs are scrubbed. Every log record passes a filter that removes the secrets of every run and sign-in the worker holds, in the message and in every exception of the graph, suppressed ones included.
  • How a run paid lives on the run (factory_run.paid_by, V84), so a re-armed dispatch keeps its subscription charging.
  • The refresh token stays in the orchestrator. The agent gets the stored sign-in with every refresh_token emptied. CodexAdapter writes it to $HOME/.codex/auth.json under umask 077, then unsets the variable. It does not pipe the file into a login.
  • Charging follows how the run paid. A subscription run records UNMETERED lines with its real token counts. An API-key run of the same model is still priced.
  • Setup rules. A subscription needs no model prices, but saving one needs a signed-in seat (subscription_not_signed_in).
  • Secrets. Failure details scrub each token in the sign-in file, not only the whole file.
  • Screens. Settings shows a seat with no known account as "Sign in again". Run detail names a seat as "Subscription … no per-token price", never as an API key.

Why the file has an empty refresh token

This was measured on 2026-09-25 with codex-cli 0.156.1 (design §5.3):

  • --with-access-token refuses a ChatGPT access token.
  • A file with refresh_token set to "" is accepted. A file without the field is refused.
  • One live codex exec ran 3 hours after sign-in, with the id token expired. It exited 0 and did not rewrite the file.

Known limits (also in docs/UNVERIFIED.md and the design)

  • No item build has run on a seat yet. That is the next step (part P).
  • The access token lasts 10 days. Nothing refreshes it; signing in again renews the seat.
  • Not verified: whether the vendor accepts two sessions of one account at the same moment.
  • After a worker restart, the log filter cannot scrub a unit that an earlier process started.
  • Running out of quota is not classified yet (§5.8). Nothing marks the seat as resting.

Checks

  • Full UI: 932 tests in 105 files; npm run build passes.
  • testFast passes.
  • Contract, orchestrator and run-worker suites pass; gateway, review-worker and agent-image passed on the previous commit and are untouched since.
  • spire-runtime-docker is unchanged by this PR now. DockerPublicationIT failed twice earlier with empty Docker daemon 500 answers while other projects loaded the daemon; to be re-run before merge.
  • 66 production mutants over four rounds. Each was caught by the intended test.

A repository's build setup now says how a build pays: an API key, as
before, or a signed-in Codex subscription. The choice is part of what an
approval binds (binding version 4), so an approval for one is not an
approval for the other.

- A subscription run leases one seat, fenced by its run id. The run
  result releases it; a lease that is never released expires at wall
  clock + 10 minutes. The API-key selector never reaches a seat.
- The agent gets the stored sign-in with every refresh token emptied.
  Codex accepts such a file and runs on the access token; the refresh
  token never leaves the orchestrator. The adapter writes the file as
  $HOME/.codex/auth.json (umask 077) and unsets the variable.
- Charging follows how the run paid, not the model: a subscription run
  records UNMETERED lines with its real token counts.
- A subscription needs no model prices, but saving one needs a seat.
- Failure details scrub each token in the file, not only the file.
- Run detail names a seat as a subscription, never as an API key
  billed per token; Settings shows a leased seat as "In use".

Part of M3.5 part F. The design records the measurement behind the
emptied refresh token (§5.3) and where the lease differs from §5.5.
- factory_run.paid_by (V84) records how a run pays. A re-armed
  dispatch clears the credential on purpose, so charging no longer
  reads the payment through it; a subscription retry was priced as an
  API-key run. A retry that pays another way is refused like any other
  differing component.
- A dispatch the broker definitely missed is assembled again under the
  same run id; that run now gets its own seat back instead of being
  refused by the lease it never used.
- Refresh tokens inside arrays are emptied too.
A failed run can leave its agent running when a stop does not take, and
a queued command could start after its seat went to another build, so
two agents could share one sign-in.

- The lease has two phases. Until the agent starts it is time-bound, and
  the command carries signInStartBy: the worker refuses a sign-in build
  it picks up later. Once RunStarted arrives the time bound is removed.
- RunAgentStopped is a new run result, sent only when the runtime
  (RunRuntime.agentRunning; Docker reads the agent container's state)
  confirms no agent process runs: after the build, on recovery, on a
  takeover hold, and once per unit from the watchdog. It is the only
  thing that frees a seat. A runtime that cannot tell keeps it held.
- An assembly that fails after leasing frees the seat at once. Settings
  gains "Free seat" for a worker that died for good.
- One seat per account: account_ref is filled from tokens.account_id,
  an enabled seat's account is unique (V85), and signing in again to an
  account replaces that seat's file. Older seats are identified at
  startup; duplicates are switched off.
- Every run-worker log record passes a filter that scrubs the secrets
  of every run and sign-in the worker holds, in the message and in each
  exception of the chain.
- The standalone dispatch path refuses a sign-in command.
The per-run lease drew a new race between workers in every review round,
and its reason is gone: an agent's copy of the sign-in has its refresh
token emptied, so two agents cannot refresh one sign-in and log each
other out. By the operator's decision, seats are now shared like API
keys and rotate least recently used first.

- Removed: the lease columns (V83), the start deadline on ExecuteRun,
  RunAgentStopped, RunRuntime.agentRunning, "In use" and "Free seat".
- Kept: one seat per account (V85), renewal by signing in again, the
  emptied refresh token, charging by factory_run.paid_by, log scrubbing.
- Seat identification at startup runs under an advisory lock, so two
  orchestrators cannot both identify one seat and switch it off.
- The log filter also scrubs suppressed exceptions, and copies the whole
  exception graph when any part of it quotes a secret.
- The held-build path's log-scrub registration is tested with a fixture
  that answers scrubFor on purpose.
- A seat is shared, so a dispatch picking it at the same moment as
  another now waits for that pick instead of skipping the only seat and
  refusing the build.
- The log filter treats an exception graph too large to scan as one that
  may quote a secret, and copies it within its bound.
- The work-run worker test clears its run's LiveSecrets entry around
  every case, so no case passes on another's leftover.
- Settings and the design no longer promise one build at a time; the
  old lease paragraphs are marked superseded.
- A publication runs with its permit's forge credential, which may have
  been rotated since the build; it is now held for the log filter too,
  under its own key, and let go with the workspace.
- A held build's secrets are registered only when the agent is about to
  start, and let go at once when the launch created no unit, so a build
  refused before it starts leaves nothing behind.
- The seat pick locks its row again (FOR UPDATE, still no SKIP LOCKED):
  a pick that waited re-checks the seat, so one switched off meanwhile
  is not handed out.
A create that fails part-way can leave credential-bearing containers
behind without reporting a unit, so "no unit reported" did not prove
that nothing exists. A held build's secrets are now let go only when
the launch never reached creation: the topology is saved immediately
before creation is attempted, and that is the signal used.
A save that throws ends the launch before anything is created, so a
held build's secrets are let go then too, instead of being kept with no
container that could ever release them.
@artyomsv
artyomsv merged commit 232f2bd into master Sep 26, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant