M3.5 part F: let a build pay with a Codex subscription seat - #178
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
API_KEY(default, as before) orSUBSCRIPTION. It is hashed into the approval (binding version 4), so an approval for one mode does not cover the other.account_refholds the measuredtokens.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.factory_run.paid_by, V84), so a re-armed dispatch keeps its subscription charging.refresh_tokenemptied.CodexAdapterwrites it to$HOME/.codex/auth.jsonunderumask 077, then unsets the variable. It does not pipe the file into a login.UNMETEREDlines with its real token counts. An API-key run of the same model is still priced.subscription_not_signed_in).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-tokenrefuses a ChatGPT access token.refresh_tokenset to""is accepted. A file without the field is refused.codex execran 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.mdand the design)Checks
npm run buildpasses.testFastpasses.spire-runtime-dockeris unchanged by this PR now.DockerPublicationITfailed twice earlier with empty Docker daemon 500 answers while other projects loaded the daemon; to be re-run before merge.