@modelprofile.com/authswitch
Manage Codex, OpenCode and Claude Code accounts, switch interactively, and inspect live subscription, usage and reset information. Codex switching also preserves ChatGPT remote-control pairings.
Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.
The problem
The Codex CLI holds exactly one active credential. Logging in as a second account overwrites the first, so working across two accounts means re-running the browser login every time.
Remote control makes that worse. A ChatGPT client pairs with the local Codex app-server, and the pairing is recorded in Codex' state database keyed by the account id. Swap the credential naively and the running app-server keeps serving the old identity until it is restarted, and the incoming account can come back without the enrollment that made it reachable from the ChatGPT app.
authswitch keeps a stash of credentials keyed by account email, captures each account's remote-control enrollments next to its credential, and puts both back together.
Install
The package is published to npmjs:
pnpm install -g @modelprofile.com/authswitch
Node.js 24 or newer is required.
On Linux, the account authority runs as a systemd user service. Install and start it once:
authswitch authority service install # write ~/.local/share/systemd/user/authswitch-authority.service
authswitch authority service enable # start it with your user manager from now on
authswitch authority service start
authswitch authority doctor # the daemon's release and the harness tools its PATH finds
Run install from the shell you use Codex and Claude Code in. The unit runs the authswitch that shell's
PATH finds -- pnpm's global launcher, which stays in place across upgrades -- by its absolute path, and
refuses when that command is another release than the one installing (authswitch --version --json names
the release, its version and package root). It carries that shell's PATH, through which the launcher finds
Node and the daemon finds codex and claude, and, when set, CODEX_HOME, CLAUDE_CONFIG_DIR,
ANTHROPIC_CONFIG_DIR, XDG_CONFIG_HOME, XDG_DATA_HOME, AUTHSWITCH_HOME and AUTHSWITCH_CODEX_BIN;
nothing else from that environment, and never a token. A relative value of one of them is refused, since the
daemon runs from /. The install result names the command the unit runs.
doctor reports each harness tool the daemon cannot find and the reinstall that fixes it.
After upgrading with pnpm add -g @modelprofile.com/authswitch, stop the daemon, install the unit again and
start the daemon on the new release. A running service's unit is not replaced: install over it refuses and
names these three steps.
authswitch authority service stop
authswitch authority service install
authswitch authority service start
Authority SDK (new account registry)
The package also exports a typed client for a per-user authswitch authority daemon. Its
SmartData registry stores account identity separately from sealed refresh grants, runtime
bindings, durable device-operation receipts, Codex enrollment records, and native-handoff and migration journals. A daemon
owns OpenAI device login, targeted reauthentication, proactive refresh, and, on request,
revocation at removal for grants added through this API. Management snapshots expose separate account and login rows,
including each login's purpose, owner, health, problem and available actions; neither
snapshots nor events contain credentials. A login that a native tool refreshes also names
that tool -- ownerTool is claude_code, codex or opencode, and null exactly when no
native tool holds it, because the authority refreshes it or nothing does. The authority
names it rather than leaving a consumer to derive it from the purpose, which would be wrong
as soon as two purposes share a tool or one tool gains a second purpose.
The snapshot also lists nativeAssignments: which account a vendor tool's own home on this host runs on,
as { id, tool, accountId, loginId, state, revision } with the home named by its hash. Only homes the
authority itself switches are recorded -- the host's Claude Code and Codex homes adopted by a verified import,
and Claude container homes -- and state is switching while a handoff to another account is in flight and
quarantined until one is resolved. The login of an adopted home names its owner: claude_native or
codex_native. A native store the authority has not adopted -- an OpenCode store, or a Codex home imported
before 11.3.0 and not submitted again since -- appears as its login instead, with owner: 'legacy_native' and
its ownerTool, because the authority does not decide what that store holds.
Every change of an assignment is an account event, so a subscriber sees it like any other change.
A removed account is left out of the snapshot unless the caller asks with includeRemoved (on snapshot,
snapshotAll and subscribe): it then appears with removed: true and its logins with health: 'removed'.
Every account also names the provider tenancy its logins act in, organizationId: a Claude account's
organization UUID -- Claude Code's oauthAccount.organizationUuid, the orgId of claude auth status --json
-- and an OpenAI account's ChatGPT account id (the chatgpt_account_id claim, a personal or team ChatGPT
workspace, not an OpenAI API organization). It identifies, never authorizes, and is published from the
identity the authority recorded when the account was added, so no provider is asked for it. A daemon older
than 10.3.0 leaves it out, which is why it is optional in the type; authswitch account list then prints
organization not published.
An account label follows one published rule, isAuthSwitchAccountLabel (at most
authSwitchAccountLabelMaxLength characters, no space at either end, no control character); a rename that
breaks it is refused with invalid_input.
import { randomUUID } from 'node:crypto';
import { AuthSwitchClient } from '@modelprofile.com/authswitch/authority-client';
import { resolveAuthSwitchAuthorityPaths } from '@modelprofile.com/authswitch';
const paths = resolveAuthSwitchAuthorityPaths();
const client = new AuthSwitchClient(paths.authoritySocketPath, paths.runtimeSocketPath);
const snapshot = await client.snapshotAll();
const operationId = randomUUID();
const operation = await client.beginAddOpenAi(operationId);
// Keep operation.id to discover its final result after navigation or reconnect.
const receipt = await client.getOperation(operation.id);
// Or follow it without polling: each answer comes when the operation changes.
let current = operation;
while (['starting', 'pending', 'committing'].includes(current.state)) {
current = await client.watchOperation(current.id, current.revision);
}
Browser code can import the credential-free DTOs from
@modelprofile.com/authswitch/authority-contract. The backend-only binding, access, release and
managed Codex requests are named by @modelprofile.com/authswitch/authority-runtime-contract, and the one-time
import's own DTOs by @modelprofile.com/authswitch/authority-import-contract; those capabilities must
never reach a browser. Two of the runtime requests are served on the runtime socket -- resolving
access and releasing a binding -- while authswitch.authority.bind, its inverse
authswitch.authority.unbind and the managed Codex codex.start and codex.stop are served on the
management socket by design: minting a capability is the
act that authorizes a runtime, so only a trusted management caller may perform it, and a container that is
given the runtime directory can use and release a binding it was handed but can never create one or
release one it was not handed. A backend binds an account to a runtime incarnation by exact account ID,
login ID and purpose, and keeps the returned capability private. Targeted reauthentication likewise requires the
exact login; an account's presentation default never selects a grant for either action. Completed and
interrupted operations remain discoverable through getOperation() and paged
listOperations(). watchOperation(id, afterRevision, waitMs = 30000) is the same read as a long poll,
the way events waits: it answers as soon as the operation's revision passes afterRevision -- a sign-in
answers starting with no prompt, then pending with its device prompt, then its outcome -- at once for a
finished operation, and with the unchanged operation once waitMs (at most 30000) runs out. Only a change
of that operation answers it. A completed receipt and its account/grant change commit together, so
the receipt resolves a lost response or an event that arrives first. Add and reauthentication
callers supply one UUID for the logical start and reuse it after a lost response. Replaying that
UUID returns the same matching receipt without starting another provider login; using it for a
different target is rejected. Cancelling a durable starting receipt prevents a late provider
handle from promoting or overwriting it. Aborting a client request only detaches that request and
does not cancel the daemon-owned login. A client that disconnects before its answer -- a long poll it
stopped reading, or one the daemon's shutdown settles after the client went away -- ends only its own
connection: the daemon drops that request's pending work and keeps serving every other client. Each
request is bounded by its own deadline -- 35 s unless its route states another, for an import submit
authSwitchImportSubmitTimeoutMs (120 s for the import plus the longest Codex's app-server restart can take within
Codex's own bounds, about 31 minutes in all), the wait plus 5 s for a long poll -- and nothing in the transport ends it sooner: the daemon gives a
connection 35 s from acceptance to deliver its complete request, however it trickles in, and sets no limit
once it is admitted.
The runtime-only socket resolves that binding to a current access token; its directory can be mounted into a
container without exposing the management socket. A provider that rejects a specific
access-token generation can request a newer one through rejectedGrantGeneration; the
authority coalesces concurrent rejection callbacks and never replays an uncertain refresh.
A ChatGPT login backs a Claude Code runtime too (runtime: 'claude'); a Claude account never binds,
because it reaches Claude Code through the native switch. After an external Flex, OpenCode or Claude
runtime has fenced new work and drained its pending access resolutions and provider requests, it can
release its exact binding capability through the runtime socket. Every bind receives a fresh random capability, so a stale release cannot fence
or delete a successor even if the caller reused an incarnation label. Release waits for admitted
server-side resolution handlers, but handler completion does not prove response delivery and
cannot retract an access token already received or in use; the runtime owner therefore owns that
drain. Generic release cannot revoke a managed Codex binding, whose daemon-owned stop still closes
the runtime and clears its durable run before internal revocation. Binding release never transfers
or changes ownership of the account grant.
A holder that lost its capability -- a binding an earlier run of it left behind -- releases it with
unbind({ runtime, scopeId, incarnationId, expectedRevision }) (authswitch.authority.unbind), naming the
binding by the identity it bound it under, as the snapshot and getBinding publish it. Unbinding needs no
ready login, so the leftover bindings of an account whose sign-in has ended are released, and the account
removed, without signing in again. It is authorized exactly as bind is: the management socket is the trust
boundary, and a runtime and scope are the namespace a holder binds under and owns -- AGL, for example, binds
under its own installation's scopes -- so a holder names only its own. The daemon releases only the binding
at exactly that runtime and scope, and only while it is still the incarnation and revision the holder read:
another scope, another runtime, or a successor bound in the meantime is left alone, and the answer is
inactive, as it is for a binding already released. A binding's revision is the authority revision of the
bind that wrote it, so it never repeats for a runtime and scope, even after a release: a repeated or stale
unbind never names a successor bound under the same incarnation. Its capability is fenced and released exactly as the
runtime release would, so admitted access resolutions finish first. Managed Codex bindings are never named
here. While the login is ready, a holder can instead bind the same runtime and scope again, which replaces
the capability, and release the returned one.
getBinding(bindingId) (authswitch.authority.binding) reads one binding by the id bind returned,
credential-free, or null once the authority holds none by that id, so a backend checks its binding without
reading the whole snapshot.
Removing an account that still backs a Flex, OpenCode or Claude binding is refused with account_busy, naming
those runtimes: their holder stops them and releases the bindings first -- by capability, or by unbind when
it no longer holds one -- so a removal never strands a runtime whose next access would fail. Managed Codex
bindings are the daemon's own; a removal that goes ahead stops the account's managed Codex first and then
takes them with the account. A removal its checks refuse stops nothing; only an account change that lands between
those checks and the removal can refuse it after the stop.
Removing an account never reaches the network unless the caller asks. removeAccount(accountId, expectedRevision, { revoke: true }) (revoke: true on authswitch.authority.remove, --revoke on the
command line) also revokes the ChatGPT sign-in the authority held for that account at OpenAI (/oauth/revoke,
the request Codex sends when it logs out). The revocation runs after the removal committed and is best effort,
like Codex's: the account is removed either way. The revocations get at most 10 seconds in all, so a revoking
removal answers within the 35 seconds the client allows any request. The answer carries revoked: yes when
OpenAI confirmed it, failed when it did not, or not within those 10 seconds (the sign-in may still be valid
there, and the daemon's log says so without the token), not_applicable when the authority held no ChatGPT sign-in of its own for the account (a login a
native tool refreshes is that tool's), and not_requested without revoke. A daemon older than this client
refuses revoke as an invalid request before changing anything.
A revocation is irreversible, and authswitch cannot tell whether another tool still refreshes from the
same sign-in. A token is opaque; the import ledger records of each source only its kind, a hash of its
location, a digest of its bytes and the account and login it resolved to; and a login does not record
whether its current sign-in came from an import or from a device sign-in the authority ran itself. The same
ChatGPT identity turning up both as an imported copy and as a native Codex store proves the same account, not
the same sign-in, so no check can tell a shared sign-in from two independent ones. Revoking an imported
login can therefore sign out a native Codex, OpenCode or other home that still runs on that sign-in. Ask for
revoke only for a sign-in made through account add openai or account reauth, or when every other copy
of it should stop working too.
Every route on either socket answers in one of three ways: a result, a refusal, or a fault. A refusal
is an answer -- the daemon completed its check, nothing was left half-done, and its message says what the
owner does instead -- so it crosses the wire marked, with the message verbatim beside
{ reason, code }. A fault is anything the daemon did not decide: the transport replaces its text with
Internal server error, and a caller must never present that as if it said what to do next. A malformed
request is neither; it keeps its own text and carries no marker, because a caller's own bug is not an
instruction for an owner.
asAuthSwitchRefusal(error) from @modelprofile.com/authswitch/authority-contract is the one reader of
that marker: it answers { code, instruction } for a refusal and null for a fault. The code is the
closed TAuthSwitchRefusalCode set, which is what a client branches on without reading the text:
authority_closing, not_found, account_changed, account_busy, login_unavailable,
binding_unauthorized, native_owner_holds_login, claude_home_unregistered, claude_handoff_pending,
claude_receipt_missing, import_refusal, codex_unsupported, invalid_input, login_needs_reauth,
access_not_fresh, codex_home_unregistered, codex_handoff_pending and codex_app_server_unavailable, plus the
two the legacy commands answer with on this side of
the socket, authority_holds_login and authority_unavailable (see "The legacy fence" below). The
instruction is authored for the owner and contains no path, credential or digest; only an importer refusal
may name the process the owner has to stop. The set is producer-owned: a consumer branches on the codes it
knows and keeps a default branch, which is what lets the authority name a new one without breaking it.
The runtime's own answers are named the same way. authswitch.authority.bind refuses login_unavailable
when the authority does not hold the login itself, and authswitch.authority.resolveAccess refuses
binding_unauthorized for a capability that authorizes nothing -- missing, wrong, or superseded while the
credential was being resolved. Those are one answer on purpose: an absent binding and a wrong capability are
compared against the same zero hash, so nothing distinguishes them, and the repair is the same either way --
bind again. A binding whose account has since been reauthorized reads login_unavailable instead, because
binding again cannot help until that account holds a login again. A device sign-in or preuse operation this
authority does not hold reads not_found, from whichever route met it. What the access loop decides about
the login itself is marked as well: login_needs_reauth when only a new device sign-in brings the login back
(after which a bound runtime binds again), and access_not_fresh when the login is intact but the provider
could not renew its access yet, so the same request can succeed later. A changed identity or a view that
keeps moving during resolution stays an unmarked fault, because nobody decided it. Binding an account this
authority does not hold at all stays one too: the storage layer throws Account is not available for this runtime.,
which a socket caller reads as Internal server error, because naming it is a decision about which layer
authors owner instructions rather than a wording fix.
The import routes keep the marker they published in 8.1.0, authswitch_import_refusal, and
isAuthSwitchImportRefusal from @modelprofile.com/authswitch/authority-import-contract is the same
reader asking for the import_refusal code, so a consumer written against 8.1.0 keeps working and a
client of this release still reads the payload a daemon of that release answers with. The distinction
matters most for import.submit: a refusal there is a submit that decided -- it left nothing half-done,
and the ledger row it wrote says where that source stands -- while an unmarked failure leaves the outcome
unknown, and that source must then be read with authswitch.authority.import.status rather than
submitted again.
A daemon that is shutting down admits no new request -- every route answers authority_closing -- and
settles the requests it already admitted before it closes its store: a waiting events or
watchOperation read answers normally from the open store instead of failing.
A client reading a daemon that runs an older release -- one not restarted after an upgrade -- fails with
AuthSwitchDaemonOutdatedError, which names the restart (authswitch authority service stop, then
start), and a subscription reports unavailable with the reason daemon_outdated once and retries every
five seconds until the daemon is restarted.
AuthSwitchClient.subscribe(onSnapshot, onEvent, signal, { onStatus }) reports current
after a fresh snapshot or verified heartbeat, unavailable on disconnect or resync, and
closed on abort. Keep cached account actions disabled while status is not current.
Every change-event batch supplies a fresh snapshot before onEvent, so a consumer does
not need to reconstruct account state from events. lastVerifiedAt records the last
successful daemon response, including an empty heartbeat.
AuthSwitchClient.doctorPage() reads credential-free diagnostic evidence without unsealing
a grant, refreshing a token or contacting a provider. Each bounded page separately stamps
the database observation, daemon state and managed-Codex process observation. daemon.harnessBinaries
says whether the daemon's own PATH finds each harness tool (codex, or what AUTHSWITCH_CODEX_BIN names;
claude; opencode): found or not_found, absent from a daemon older than 10.3.0. Stored account
and login rows explicitly report liveCheck: not_checked; a ready daemon therefore does not
claim that a stored grant or provider is healthy. Native homes and journals omit paths,
digests and sealed values, while managed Codex reports durable, in-memory and observed-process
state independently with fixed problem categories. Continue each independent cursor to inspect
the complete inventory. Pages are later observations rather than one atomic global snapshot,
and the authority metadata revision is not a change clock for every native-home record. If the
management socket is unavailable, an offline caller must leave the database state unknown
instead of starting the database to inspect it.
The authority CLI uses that daemon surface without constructing the legacy native harness adapters:
authswitch account list [--json]
authswitch account add openai [--json]
authswitch account add claude [--json]
authswitch account use claude <account-id> [--json]
authswitch account use codex <account-id> [--json]
authswitch account reauth <account-id> <login-id> [--json]
authswitch account remove <account-id> [--revoke] [--json]
authswitch account operation get <operation-id> [--json]
authswitch account operation cancel <operation-id> [--json]
authswitch account operation resume <operation-id> [--json]
authswitch account preuse <account-id> <login-id> [--prompt <text>] [--model <id>] [--json]
authswitch account preuse --all [--prompt <text>] [--model <id>] [--json]
authswitch account preuse operation get <operation-id> [--json]
authswitch account preuse operation cancel <operation-id> [--json]
authswitch account preuse operation resume <operation-id> [--json]
authswitch authority doctor [--json]
authswitch authority import inventory [--json]
authswitch authority import status [--json]
authswitch authority import submit <source-id> --digest <source-digest> [--json]
account list --json prints the complete, consistent credential-free authority snapshot.
Its human form labels login health as stored state and does not imply a live provider check.
account add openai starts one standalone OpenAI device login. account reauth starts one
device login for the exact account and managed login IDs; it never chooses a presentation
default. Both commands print the durable operation ID, verification URL, user code and changed
receipts until completion. account operation resume polls only the supplied durable ID, while
get performs one read and cancel performs one explicit cancellation. Ctrl-C or the local
15-minute deadline detaches the CLI and leaves the daemon-owned operation running. If a start
response is lost, the fixed diagnostic reports the caller-generated operation ID and exact resume
command; the CLI does not replay the start request.
account add claude signs a Claude account in (see "Adding a Claude account" below): it starts a Claude sign-in,
runs claude auth login on this terminal with CLAUDE_CONFIG_DIR set to the home the authority made for it, and
follows the sign-in until the authority has imported the login. With --json it writes each changed sign-in as one
JSON Line and sends Claude Code's own output to stderr. A Claude Code that cannot be started, or that exits without
signing in, ends the sign-in, and the command exits nonzero. account use claude switches the host's Claude Code to
the account by the id account list shows: it finds the Claude login the authority holds for that account and runs
authswitch.authority.claude.switch, following the handoff with the claude.handoff long poll until it is settled
(a daemon older than 11.3.0 cannot be followed that way, and the command says so and exits nonzero). It prints whether Claude Code now
runs on the account, whether the switch was undone -- with the proof that failed -- or whether Claude Code's home is
quarantined; --json prints the handoff. It exits nonzero unless the switch committed, and answers at once, changing
nothing, when Claude Code already runs on the account. account use codex switches the host's Codex the same way
(see "The host's Codex home" below): it finds the OpenAI login the authority holds and renews for the account, runs
authswitch.authority.codex.switch and prints whether Codex now runs on the account, whether the switch was undone
-- with the problem and the proof that failed -- or whether Codex's home is quarantined, and how Codex's app-server
came back; --json prints the daemon's answer. It exits nonzero unless the switch committed, and answers at once,
changing nothing, when Codex already runs on the account. Every refusal the daemon answers these commands with, and
every other authority command, is printed as the daemon wrote it.
account remove removes one authority account by the id account list shows, at the revision it
reads just before, and prints the removal and what happened to its ChatGPT sign-in; --json prints the
daemon's answer, { account, revoked }. --revoke asks for the revocation described under removal above,
with the same caveat: authswitch cannot tell whether another tool still runs on that sign-in. The command
exits nonzero when OpenAI did not confirm a requested revocation, although the account is removed.
account preuse sends one quota-consuming prompt through the exact managed OpenAI account and
login IDs. The default prompt is Write 2000 words about strawberries.; --prompt replaces it
and --model requests one exact model. --all freezes one consistent authority snapshot, walks
its canonical daemon-owned OpenAI managed logins in snapshot order, and starts one fresh durable
operation per login whose stored canPreuse capability is true. It does not infer readiness from
plan, usage, token expiry or binding capability. A login whose snapshot capability is false is
reported as skipped with the snapshot revision, login revision, stored health, problem and
observation time; no request or operation ID is created for that skip.
Preuse operations run sequentially and are never retried automatically. Every terminal receipt
advances an --all run, including not_sent, refused and outcome_unknown, while any attempted operation
that does not complete makes the final exit status nonzero. Snapshot-ineligible skips alone do not
make an otherwise successful batch fail. Running --all again is a new quota-consuming run with
new operation IDs. Use account preuse operation resume to continue observing an existing receipt.
Only the explicit cancel command asks the daemon to cancel one. A receipt stopped by a limit
carries it as limit ({ kind, window, resetsAt }); a sent request that failed for another stated
reason is outcome_unknown with problem request_uncertain and carries its cause as failure
({ kind, status, providerCode }, for example { "kind": "http", "status": 503, "providerCode": null }),
never the provider's text. Human output prints both as sentences.
A host that shows receipts in a browser words them the same way through
@modelprofile.com/authswitch/preuse-text, the browser-safe entry point for those sentences:
import { describePreuseFailure, describePreuseLimit, localDateTime } from '@modelprofile.com/authswitch/preuse-text';
import type { IAuthSwitchPreuseOperation } from '@modelprofile.com/authswitch/authority-contract';
const lines = (operation: IAuthSwitchPreuseOperation): string[] => [
...(operation.limit ? [describePreuseLimit(operation.limit, Date.now())] : []),
...(operation.failure ? [`Cause: ${describePreuseFailure(operation.failure)}`] : []),
];
describePreuseLimit(limit, now) names the window and the limit, and a stated reset as a countdown from
now with the local time (Weekly usage limit reached, resets in 16h 46min (2026-09-27 05:12), or due to reset now once it has passed); describePreuseFailure(failure) names the cause as the tail of a
sentence (the provider answered HTTP 503). localDateTime(timestamp) is the local time both use,
2026-09-27 05:12. The module loads nothing but that time text, which imports nothing either; the package
root exports the same three functions from the same source.
For account add, account reauth and account operation resume, --json writes JSON Lines:
each line is one complete operation receipt, and unchanged revisions are omitted. account operation get --json and account operation cancel --json each write one JSON object followed by
a newline. Human output includes the resume command on every unfinished receipt. These device
commands do not switch the active native login.
For one preuse start or resume, --json likewise writes each complete changed receipt as one raw
JSON Line; preuse get and cancel write one raw JSON object. A preuse --all --json stream tags each
line as operation or skipped. Operation lines contain the complete durable receipt. Skip lines
contain the exact account and login IDs plus the stored snapshot evidence described above. A lost
start response, transport or output failure, Ctrl-C, or the local 15-minute observation deadline
stops the batch before another login starts and reports the current operation ID and resume command.
It detaches without replaying or implicitly cancelling the daemon operation.
authority doctor --json incrementally writes one JSON document. Its pages array contains
every independently stamped bounded page; observationSpan is written only after all cursors
complete, and globallyAtomic is false. A failure during paging returns nonzero and leaves the
document incomplete rather than presenting partial evidence as a successful observation span.
The doctor command imposes no inventory cap. Account listing and doctor do not contact a provider,
unseal a grant, mutate authority state, start the service, open the database offline or fall back
to a legacy credential store.
If the daemon is unavailable before the first response, it fails with a fixed diagnostic on
stderr and leaves stdout empty. The older native-store commands documented below still coexist until the coordinated
major-version migration and removal; these authority routes never use them.
The Claude native home
The daemon serves Claude Code's own credential store, which stays the native store that Claude Code
reads. authswitch.authority.claude.switch, .handoff and .handoffs on the management socket move
that one home between two authority logins and report the journal of those moves, and
authswitch.authority.usage answers for an Anthropic account from it. Each switch carries a revision that grows
with every change of it, and .handoff waits for one: with afterRevision and waitMs (at most 30000), which come
together or not at all, it answers once the revision passes afterRevision, at once for a settled switch
(committed, aborted or quarantined), or unchanged after waitMs. AuthSwitchClient.watchClaudeNativeHandoff()
is that long poll; pass each answer's revision back until the switch is settled. A daemon older than 11.3.0 names
no revision and refuses the wait. The home is
CLAUDE_CONFIG_DIR or ~/.claude, and the authority identifies it by the hash of the credential file
Claude Code owns -- the same value the import publishes as that source's path hash, which is what lets a
verified receipt adopt it.
The daemon is wired for that home whether or not Claude Code is installed, so doctor reports
claudeNativeAuthority: configured on any host; the claudeHomes page is what says whether a home has
been adopted. Until a verified claude_native import has adopted one, every Claude route refuses, except the Claude
sign-in, which only adds accounts. A
CLAUDE_CONFIG_DIR that is not an absolute path is refused when the daemon starts, because the same
variable also decides which file the import would read; it is never resolved against a working directory.
A switch is only attempted when the host can prove Claude Code uses that native login: Claude Code
2.1.273 or newer installed, a subscriber login in the store, no credential override in the environment
(including the remote well-known token files), no managed or local settings selecting another auth source,
and every Claude Code session running in that home on a verified release -- one whose lock table, live
credential pickup and refresh compare-and-swap were read from its bundle, currently 2.1.273 and 2.1.282.
An installed release above 2.1.273 that is not verified is proven by Claude Code's own answer instead:
claude auth status --json must report the claude.ai login of the outgoing account -- its organization and
email, the only account identifiers it names -- read from this home and with no API key, before the write,
and of the incoming account after it. That path runs only while
no Claude Code session runs in the home. If Claude Code does not report the incoming login after the
write, or the proof fails for any other reason, the exact outgoing bytes are written back while the locks
are provably still held and the handoff is aborted with problem: unsupported_effective_auth; when they
cannot be, the home is quarantined. A write whose proof never ran is proven by the reconcile that finds it
before it can commit. Anything else refuses and leaves
both logins where they were. A move that reaches the home and
then cannot complete that proof is quarantined with problem: unsupported_effective_auth, and the
handoff's optional proofFailure names which condition failed -- unsupported_release, subscription,
override, settings, profile, profile_unreadable or running_session -- so an owner is told what
to change. It is absent whenever problem is anything else. The settings that proof reads are the ones
the daemon may speak for: the user settings file of the resolved home and the managed settings directory.
A project's .claude/settings.json is read from the working directory of each running Claude session,
never from the daemon's own, which is its service unit's and selects nothing. While a login is in the
native home, Claude Code is its only refresher; the authority takes it back by the same journaled handoff.
The host's Codex home
The daemon also switches the host's own Codex home -- CODEX_HOME, or ~/.codex, as its import reads it -- between
OpenAI logins it holds, so the conversations that live in that home continue on another account.
authswitch.authority.codex.switch (AuthSwitchClient.switchCodexNative()), .handoff and .handoffs serve it,
and authswitch account use codex <account-id> runs it from the command line. The home is registered by the hash of
its auth.json path, the same value the import publishes as the codex_native source's path hash: a verified
import of that source adopts the home and makes its login codex_native, the login Codex renews itself. A Codex
home imported before 11.3.0 still has a legacy_native login; import status offers resume for it, and submitting
the same source again (authority import inventory, then import submit) proves its current bytes with the
provider and adopts it. Until a home is adopted, and on a daemon built without codexNative, the routes refuse
with codex_home_unregistered.
One login runs in the home at a time, and each login has exactly one renewer: Codex for the one in the home, the
authority for every other. The account's login is one grant (openai_managed), so the login Codex runs on cannot
also back a managed Codex app-server or a runtime binding: the switch stops the incoming account's managed Codex
first, and refuses with account_busy while a runtime a caller holds is bound to it. A switch is refused, changing
nothing, while another one is pending or left the home quarantined (codex_handoff_pending), for the account Codex
already runs on (invalid_input), and whenever Codex's own app-server is not there to prove it
(codex_app_server_unavailable): not running, not managed by codex app-server daemon, or unable to say.
Otherwise it runs as one journaled transfer:
- It renews the incoming login first when its access token would expire within twenty minutes, so Codex does not need to renew it while the switch is being proven, and reserves the switch; from here the authority no longer renews the incoming login.
- It stops Codex's app-server with
codex app-server daemon stop-- running Codex turns end -- and reads the home'sauth.json. A home that does not hold the login the authority placed there is quarantined, and nothing is written. - It saves the outgoing account's remote-control pairings from Codex's state database into the account's sealed pairing record, seals the outgoing login for the authority and journals the digests of the bytes on both sides of the write.
- It puts the incoming account's saved pairings back into the state database, and writes the incoming login as
Codex's own
auth.json(auth_mode: chatgpt, owner-only, atomically, only if the file is still the one it read). - It starts the app-server again in its own systemd scope with the environment it ran with, exactly as a
credential change always does (see "What a switch actually does"), and asks it:
account/readmust report a ChatGPT login of the incoming account's email andaccount/rateLimits/readmust name its ChatGPT account. - Only that answer commits the switch: the incoming login becomes
codex_native, the outgoing one the authority's to renew, and the home's assignment moves.
A switch Codex does not prove -- another login selected by the environment or the configuration
(proofFailure: other_login), a ChatGPT account it could not name (account_unnamed), or an app-server that did
not come back (problem: app_server_unavailable) -- is undone: the app-server is stopped again, the exact outgoing
bytes go back and it is started again. Where the home no longer holds what was written, or the app-server cannot be
stopped to put it back, the home is quarantined, and neither login is renewed by the authority until the owner
repairs it. A daemon that stops in the middle settles the switch when it starts: a home that still holds the
outgoing login undoes it, one that holds the written login commits only once the running app-server proves it, and
anything else is quarantined. The answer, { handoff, codexAppServer, codexAppServerDetail }, can take as long as
two Codex restarts; AuthSwitchClient waits authSwitchCodexNativeSwitchTimeoutMs, and a lost answer is read
with codex.handoff, which waits for a change past afterRevision exactly as claude.handoff does
(AuthSwitchClient.watchCodexNativeHandoff()).
doctor lists the adopted home (codexHostHomes, with its account, login, pending switch and status) and every
switch of it (codexHandoffs, with its phase, problem and failed proof), and says whether this daemon serves the
switch (daemon.codexNativeAuthority), so the home a refusal calls quarantined is visible there. A daemon older than
11.3.0 answers none of these; authswitch authority doctor then shows Codex native authority=unsupported and never
sends it the codexHostHomeAfter and codexHandoffAfter cursors it would refuse.
What a switch cannot see: a Codex process that runs on the home without the app-server (codex exec, or a TUI
started with --no-daemon) keeps the login it loaded and may write its own renewal of it back into auth.json;
close such processes before switching. The account Codex runs on reads no usage through the authority, since the
authority holds no copy of its login, and it cannot be removed until Codex is switched to another account.
Adding a Claude account
A Claude account reaches the authority by one of three ways, and each ends with an import whose migration ledger row is the receipt a switch of the host home, or a move into a container home, requires.
-
Adopt the host's own Claude Code home -- once, before anything else. The login Claude Code runs on stays Claude Code's (a projection, verified without rotating anything), and its home becomes the one the authority switches. Close every Claude Code session first: the import refuses while
clauderuns.authswitch authority import inventory # the source of kind claude_native, with its id and digest authswitch authority import submit <source-id> --digest <source-digest> -
Import the Claude logins
authswitch claude stashsaved -- each is a source of kindauthswitch_stashwith provideranthropicin the same inventory, taken over by one rotating refresh, after which its saved copy is dead. Submit each the same way, with Claude Code closed. The saved copy of the account Claude Code runs on is refused, because Claude Code already holds that login, and so is one of an account whose login the authority already holds and still works; adopt the host home first, or its own import is refused in turn once the authority holds that account. -
Sign in --
authswitch account add claude, or from a backend the Claude sign-in routes below.
Then authswitch account use claude <account-id> switches Claude Code on this host to any of them.
authswitch.authority.claude.signin.start (AuthSwitchClient.startClaudeSignIn(operationId)) makes a private home
for the sign-in -- a directory named by the operation id in the sign-in directory, which the service places at
$XDG_DATA_HOME/authswitch/claude-signin (default ~/.local/share/authswitch/claude-signin), beside the store --
and answers IAuthSwitchClaudeSignInOperation: pending, with prompt: { flow: 'terminal', command, directory }.
command is claude auth login with CLAUDE_CONFIG_DIR set to directory, quoted for a POSIX shell; a caller that
runs Claude Code itself runs claude with authSwitchClaudeSignInArguments in that environment, as the command
line does. Once .credentials.json is in the home and no Claude Code process runs on it, the authority imports the
login -- one rotating refresh, the import a saved Claude login gets, so the authority is its only renewer and the
import's ledger row (source kind claude_signin) is its receipt -- and removes the home; the sign-in is then
complete with result: { accountId, loginId }. An import that refuses leaves it failed with the import's
instruction in error, and one whose outcome is unknown outcome_unknown, pointing at authswitch authority import status. claude.signin.operation reads it, or waits for its next change when given afterRevision and waitMs
(watchClaudeSignIn), and claude.signin.cancel ends a pending one and removes its home. A sign-in that holds no
login fifteen minutes after it started fails, and one the authority's stop interrupts is interrupted; either way
its home goes. A finished sign-in answers for ten minutes after it finished; after that its id is unknown
(not_found). Sign-ins live in the running authority only: after a restart their ids are unknown, and the start
removes every home left in the sign-in directory. A daemon built without claudeSignIn refuses these routes with
claude_home_unregistered.
A sign-in, like the import of a saved login, replaces a login the authority already holds for that account only
when that login has ended (needs_reauth); a working login is kept, and so is one Claude Code holds, and the
sign-in or the import refuses saying so, before anything is written. An
account whose login Claude Code runs on is not removed either: the removal refuses with native_owner_holds_login
and the instruction to switch Claude Code to another account first (authswitch account use claude <account-id>), or to move the login out of its container home.
Claude container homes
A management caller that runs Claude Code in containers -- AGL's environments -- keeps several Claude logins
in the authority and moves one of them into a container's own Claude home while that container runs. A login
lives in exactly one place: sealed by the daemon, in the host home, or in one container home. The grant's
owner records it, and so does the home's occupancy, whose login index is unique across the host and every
container. The routes are on the management socket, the trust boundary that already scopes bind and
unbind:
authswitch.authority.claude.homes.register {runtime: 'claude', scopeId, accountId, directory}registers a home the caller created, under its own scope and for one account, for the home's whole life. The authority proves only facts about the path. The directory must be absolute and normalized, and reached through no link. Its parent must be a directory of the daemon's user that no one else can write, and every directory above that must be changeable only by its owner or root (a root-owned sticky directory such as/tmpcounts). The home must be neither the host's Claude home, nor the authority's data directory, nor another registered home, and must not nest with any of them, by path and by inode. Registrations run one at a time, so two concurrent ones of nested directories never both succeed. The parent is pinned by device and inode. The home itself may not exist yet; when it does, it must be a real directory of the daemon's user. The same scope, directory and account answer the existing registration. Anything else under that scope or directory isclaude_home_conflict..unregister {homeId, expectedRevision}forgets an empty home. It answersclaude_home_occupiedwhile the home holds a login or has a move pending..moveIn {homeId, loginId}moves one ready login of the home's account in. A login due within the hour is renewed first, and one whose renewal fails is refusedlogin_needs_reauth. The login is proven with the provider and written as.credentials.json(claudeAiOauth, every other key kept) and.claude.json(oauthAccount; the account caches go, every other key of the container's own file stays). It is read back and committed: the grant becomesclaude_native, and the snapshot'snativeAssignmentsgains a row withlocation: 'container'and the home'sscopeId. Only acommittedanswer means the login is in the home.- A login held by another home is
claude_login_in_use, and the instruction names that home's scope; the snapshot names it too. - A login of another account is
claude_home_conflict.
- A login held by another home is
.moveOut {homeId}takes the login back and names the result. The file is container-controlled, so the provider, never the file's label, says whose login it is: its profile read with the returned access token while that has five minutes left, otherwise after one refresh the daemon sends as the login's new refresher, behind a durable marker.returned: the very login moved in.returned_replaced: another login the provider proves is the same account and organization. It is sealed as the grant's login. authswitch cannot tell a renewal by Claude Code from a new sign-in inside the container, and it revokes no previous token family.needs_reauth, withproblem:returned_missing: no login in the home;returned_foreign: another account's tokens, discarded and never sealed;provider_rejected: the returned refresh token is dead;exchange_uncertain: a refresh may have been sent and its answer was lost.
quarantined, withproblem:foreign_or_torn: a file that is unparseable, oversized, not a regular file or of another user;claude_home_tampered: the home, or an entry in it, is not what was registered.
not_occupied: the home holds no login of the authority's, for example after a move-in whose answer was lost was undone.- Every result but a quarantine leaves the home without
.credentials.jsonand withoutoauthAccount. - An unavailable provider moves nothing and answers
access_not_fresh.
.resolve {homeId, operationId, outcome}is the owner's answer to a quarantine. It waits, answeringclaude_home_running, while a Claude Code process runs on the home.abandongives the login up (it needs a new sign-in) and empties the home when it can.restore_sealedreturns the authority's own sealed copy. It is allowed only for a move-in that never committed, and only once this call has emptied the home.
A daemon built with claudeContainerHomes: { homesDirectory } registers homes only strictly inside that
directory and refuses any other with invalid_input; without it a home may lie anywhere the path facts admit.
After a committed move-in, the caller can prove inside the container that Claude Code runs on the expected
account by comparing claude auth status --json with the snapshot's account: its orgId with the account's
organizationId, and its email with the account's email.
Both moves are valid only while no container process can touch the home, and the caller vouches for that:
it moves a login in during its start-probe window and out once its container is proven stopped. As defence
in depth, the authority refuses either move with claude_home_running when a Claude Code process of this
user runs on the home (resolved through the process's own mount namespace) or when a live Claude Code
process cannot be read. It never executes anything found in a home -- no claude, no helper, no settings --
and takes none of Claude Code's locks: whether Claude Code in the container uses the login is the caller's
to prove, inside the container.
A container home is touched only by descriptor. The parent is opened by path and proven to be the pinned
directory. The home is opened through it without following a link. Every file is addressed as
/proc/self/fd/<home>/<name> without following a link, and is read without blocking on a FIFO, as a regular
file of the daemon's user of at most 4 MiB, in strict UTF-8. A write goes to a fresh file created exclusively
under a random name and is renamed within the home, so a planted link or hard link is never written through.
After every batch the home's name must still be the directory that was opened; otherwise the move is
quarantined claude_home_tampered. A permission refusal is claude_home_tampered as well: an entry of
another user of the container, or a stripped directory.
Every move is journalled before it touches a home, and the daemon settles an interrupted one when it starts or when the caller moves out:
- A move-in that wrote nothing is undone.
- A move-in whose home holds exactly the prepared login is emptied and undone after the running check, and the sealed copy stays; the authority never commits a move the caller did not see succeed.
- A move-out that sealed the returned login finishes the removal and commits, while the credential file is the one it read or is gone.
- A move-out that sent a refresh loses the login, because that refresh may have spent it.
- Anything else is quarantined.
A move the daemon cannot settle when it starts -- a Claude Code process runs on the home, or anything else
fails -- stays pending and keeps only that home blocked; every other account is served. The home reports why
in unsettled (claude_home_running or settlement_failed, the latter also logged by the home's id), and
its next move-out settles it.
A login a container home holds is that home's alone. A host switch to it is refused claude_login_in_use,
naming the home's scope, and a switch to a login a move-in is taking is claude_handoff_pending. Its usage
is never read from the host's home, nor under Claude Code's locks there: authswitch.authority.usage
answers it unavailable with the problem held_in_container, and reads it again once the login is moved
out. An import never adopts a registered container home as the host's.
doctor lists the registered homes (claudeContainerHomes, with their account, login, pending move, status
and unsettled, never their path); claudeHomes lists the host's own home only. It also reports
daemon.version and whether this daemon serves container homes (daemon.claudeContainerHomes). A daemon
older than 10.2.0 answers none of these three; authswitch authority doctor then says the daemon is
outdated, names the restart, and never sends it the claudeContainerHomeAfter cursor it would refuse.
Removing an account removes its empty registrations with it. A home that holds a login refuses the removal
through that login's native owner.
Testing against a real authority
@modelprofile.com/authswitch/testsupport starts a real authority daemon for a consumer's tests -- the
daemon, its embedded database, and the management and runtime sockets -- on synthetic fixture homes, so a
test talks to it through AuthSwitchClient exactly as it talks to the installed one:
import * as fs from 'node:fs';
import { AuthSwitchTestAuthority } from '@modelprofile.com/authswitch/testsupport';
import { AuthSwitchClient } from '@modelprofile.com/authswitch/authority-client';
const support = await AuthSwitchTestAuthority.start();
try {
const client = new AuthSwitchClient(support.authoritySocketPath, support.runtimeSocketPath);
const alice = await support.seedClaudeAccount('alice');
const home = support.claudeHomeDirectory('env-one');
fs.mkdirSync(home, { mode: 0o700 });
const registration = await client.registerClaudeHome({ scopeId: 'my-app/env-one', accountId: alice.accountId,
directory: home });
await client.moveClaudeHomeIn(registration.id, alice.loginId);
const account = (await client.snapshot()).accounts.find(item => item.id === alice.accountId);
// account.organizationId === alice.organizationId, the organization the home's .claude.json names.
await client.moveClaudeHomeOut(registration.id);
} finally {
await support.stop();
}
-
AuthSwitchTestAuthority.start({ parentDirectory? })creates a private temporary directory inparentDirectory(the system temporary directory by default) and runs everything below it: the database, the three sockets, the fixture host Claude home, the fake host Codex andhomesDirectory. It refuses a parent directory inside one of this user's real stores --~/.claude,~/.claude.json,~/.codex,~/.authswitch,~/.config/authswitch,~/.config/opencode,~/.local/share/opencode,~/.local/share/authswitch,CLAUDE_CONFIG_DIR,CODEX_HOME,AUTHSWITCH_HOME, and theopencodeandauthswitchdirectories ofXDG_DATA_HOME,XDG_CONFIG_HOMEandXDG_RUNTIME_DIR-- and a path too long for a Unix socket. -
Container homes are confined to
homesDirectory;claudeHomeDirectory(name)names one there without creating it. Any other directory is refusedinvalid_input. -
seedClaudeAccount(name, { generation?, accessExpiresAt? })puts a ready Claude login of fixture accountnameinto the authority's custody with the verified import receipt a move-in requires, and answers{ name, accountId, loginId, accountUuid, organizationId, email, accessExpiresAt }. Seeding the same name with anothergenerationadds a second login of the same account. The identity isauthSwitchFixtureClaudeIdentity(name)(user-<name>,org-<name>,<name>@example.test) and every token starts withfixture-. -
provideris theAuthSwitchFakeClaudeProviderthe daemon talks to. It answers Anthropic's profile and token endpoints for the fixture tokens it issued, with the switches a test needs (profileDown,refreshTokens.set(token, 'rejected'),onProfile, theprofileCallsandrefreshCallscounters), and fails every other request, OpenAI's included, so nothing a test starts reaches a real provider. Secrets are sealed with a plain encoding, never in a keyring, and no Claude Code process is ever seen running on a home. -
Claude sign-ins run in
claudeSignInDirectory.claudeSignInLogin(name, { generation?, accessExpiresAt? })answers the two filesclaude auth loginleaves once fixture accountnamesigned in --files['.claude.json']andfiles['.credentials.json']-- and the{ name, accountId, loginId, accountUuid, organizationId, email }the authority imports them as, and tells the provider the login's tokens so the authority's rotating refresh succeeds. A consumer's fakeclaude, run withauthSwitchClaudeSignInArgumentsand the prompt's directory asCLAUDE_CONFIG_DIR, writes them there and exits; since no Claude Code process is ever seen on a home, the authority imports as soon as.credentials.jsonis there, so the fake writes.claude.jsonfirst and.credentials.jsonlast, each by renaming a complete file into place.completeClaudeSignIn(directory, name)writes them itself, for a test that needs noclaudeat all. The sign-in completes within a fraction of a second; follow it withwatchClaudeSignIn:const operation = await client.startClaudeSignIn(randomUUID()); const lena = support.completeClaudeSignIn(operation.prompt!.directory, 'lena'); let current = operation; while (current.state === 'pending' || current.state === 'committing') { current = await client.watchClaudeSignIn(current.id, current.revision); } // current.result is { accountId: lena.accountId, loginId: lena.loginId } -
The host's own Codex is
codexHost, anAuthSwitchFakeCodexHostin the authority's directory: a fakecodexthat answersapp-server daemon version,stopandstartas Codex does, and an app-server that loads the home'sauth.jsonwhen it starts and answers the two requests a switch proves itself with --account/readwith the ChatGPT login it loaded,account/rateLimits/readwith that login's ChatGPT account. The daemon servescodex.switch,.handoffand.handoffson it.seedOpenAiAccount(name, { generation?, accessExpiresAt? })puts a ready OpenAI login of fixture accountnameinto the authority's custody, andadoptCodexHost(name, { accessExpiresAt? })makes the host's Codex run on fixture accountnameas a verified import leaves it -- its sign-in in the home, its logincodex_native, the home adopted -- and starts the app-server; both answer{ name, accountId, loginId, chatgptAccountId, email, accessExpiresAt }. The identity isauthSwitchFixtureOpenAiIdentity(name)(user-<name>,workspace-<name>,<name>@example.test); the tokens are unsigned, valid for three hours unless stated, and never renewed within that time, so no request reaches OpenAI. On the fake,chatgptAccountId()names the account the home'sauth.jsonholds,requests()lists what the app-server was asked,reportApiKey(true)makes it report an API key -- a switch is then undone withproofFailure: 'other_login'-- andstopAppServer(),startAppServer()andisRunning()drive it. The fake runs without a systemd user manager, so a switch answerscodexAppServer: 'restarted_unscoped':const kai = await support.adoptCodexHost('kai'); const lena = await support.seedOpenAiAccount('lena'); const { handoff } = await client.switchCodexNative(lena.accountId, lena.loginId); // handoff.phase === 'committed'; support.codexHost.chatgptAccountId() === lena.chatgptAccountId // Codex ran on kai before; kai's login is the authority's now, lena's is Codex's own. A switch still under way is // followed with watchCodexNativeHandoff(handoff.id, handoff.revision) until it is settled. const back = await client.switchCodexNative(kai.accountId, kai.loginId); -
stop()closes the daemon, stops the fake Codex app-server and removes the directory with everything in it; the process then has no handle of the authority left, and no fake Codex process runs.
The fake provider, the fake Codex and the plain encoding live in this entry alone; the package root never loads them.
The one-time account import
The three authority import commands move the accounts that existed before the authority into it.
import inventory reads this host and changes nothing: no authority record, no legacy file, no
credential. It lists every Codex stash directory, saved credential record and native Codex, Claude and
OpenCode store with the authority account it resolves to, derived from the claims the stored token already
carries -- no provider is contacted. A malformed or unrecognised entry is reported rather than skipped, so
an empty result is never mistaken for an empty store. The report carries no token, no token digest and no
source path; a source is named by a hash of its location and a digest of its bytes.
Each source is classified by who refreshes its login after the import.
- adopt -- a copy authswitch made for itself, and AGL's own records. Nothing else refreshes such a copy, so the authority takes it over outright. It is verified by one real refresh, which rotates the token: from that moment the legacy copy is dead. That is the point of no return for that account, and the only fallback is one device sign-in.
- project -- a native Codex, Claude or OpenCode store. The vendor tool keeps refreshing its own store, so the authority registers the account, leaves the grant with its native owner and holds no copy of the credential. It is verified by a provider read with the access token already in the store, which rotates nothing. Two refreshers on one rotating refresh token is the failure this avoids.
A verified Claude projection also adopts the native home it came from, so the daemon may serve Claude
switches for it. The receipt is what adopts it, and a home belongs to one login at a time: a second home
for a login another one already holds -- a .credentials.json copied to another CLAUDE_CONFIG_DIR --
and a home whose active login has since moved are both refused with the importer's own code, so the
submit is known to have decided, the ledger row stays verified and no home changed.
A verified Codex projection adopts the host's Codex home the same way, in one commit with its login becoming
codex_native, so the daemon may switch it (see "The host's Codex home"). A Codex home imported before 11.3.0 is
adopted by submitting its source again: its row stays complete, and the bytes Codex has written since are proven
with the same provider read before the home is adopted.
import submit takes the source id and the digest the owner read in the inventory. The daemon re-reads the
source itself and refuses if the digest changed, so approving one source can never import a different one;
no credential crosses the socket for a source this package can read. It refuses while an authswitch watch or a
native harness process that owns the source is running. Codex's app-server is the one refresher the import
restarts itself: for a native Codex store or a saved OpenAI login -- the sources that app-server refreshes -- the
submit stops it with codex app-server daemon stop, reads the source again and checks its digest once nothing
can refresh it, imports it, and starts the app-server again with codex app-server daemon start, also when the
import fails or refuses after the stop. Codex restarts; running Codex turns end. An app-server that was not
running stays stopped. The start runs in its own transient scope of the user's systemd manager (see "What a switch
actually does"), so the app-server outlives the authority's service. The result's codexAppServer says which
happened -- not_running, restarted, restarted_unscoped or start_failed, the last with the reason among its
problems -- and codexAppServerDetail why it runs without a scope of its own (unsupported_platform or
user_manager_unavailable) and which environment it got (preserved, or allowlist with the reason); both are
absent for every other source. A refusal after the stop says in its text whether the app-server came back. The
submit waits as long as Codex's own bounds allow the restart; a caller whose answer is lost reads the source's
ledger row with authswitch authority import status and whether Codex's app-server runs with
codex app-server daemon version. Everything else that would refuse the submit refuses before Codex is stopped. The import still refuses, and stops nothing, where a restart is not safe: when
Codex cannot say whether its app-server runs (an app-server it cannot ask counts as running), and when the
app-server on this Codex home's control socket is one codex app-server daemon does not manage --
daemon version names no backend for it, and Codex's own daemon stop refuses it with "app server is running
but is not managed by codex app-server daemon". Codex answers codex app-server daemon version only while its
app-server runs; a stopped one makes it fail on the control socket, and that failure counts as stopped only
when no control socket exists in the Codex home and neither daemon pid file names a live process.
An adopt whose refresh fails, or whose answer is lost, is never retried: the source is quarantined, its grant
is left needing re-authentication, and the command says so. A projection fails differently, because nothing
rotating was ever sent for it: a native store that could not be proven live stays a pending native owner and
is imported by submitting the same source again once its own tool has refreshed it. That refresh rewrites the
file, so the retry carries the digest the inventory prints afterwards: a row waiting for its native owner
follows its own tool's bytes, and nothing else does. What it never follows is another login. A row names the
account and the grant it was approved for, and a source that now holds a different account's login is refused
with that stated -- before the account is registered, because the provider read that proves a projection asks
about the identity the new bytes themselves carry and would prove the wrong login perfectly well. Neither
that refusal nor "already imported" ever clears: no route forgets a row or re-points it at another account.
import status reads the migration ledger, the grants and the handoffs -- never the host -- and reports, per
source, where it stands, which owner refreshes its login now, and what to do next: nothing, submit the same
source again, or sign in to that account again. Each entry also carries the source's sourcePathHash, so a
backend that submitted an external source finds its own record by sourceKind and that hash -- also after a
submit whose outcome it never learned -- without deriving the ledger row id. Submitting a source again is how an interrupted run resumes,
and the answer comes from the same durable record the daemon decides on: a source whose sign-in was in
flight when the run died is reported as needing a device sign-in, not as resumable, because a rotating
refresh token is never sent a second time. Sources that were never submitted have no ledger row and appear
only in the inventory.
An import deletes nothing: retiring the legacy files is a separate, later step. The authority store is deliberately not backed up, so losing it costs one device sign-in per account -- and the remote-control enrollments and runtime bindings with it.
Where a daemon reads those stores is stated by whoever builds it, never defaulted. The legacy stores are
this user's real credentials, and every location they are found at comes from the environment, so
AuthSwitchAuthorityDaemon takes a required legacyImport: either the locations to read (an env and an
absolute homeDirectory, plus the provider operations that import uses) or 'none', which builds a daemon
with no account import at all and answers import inventory, import status and import submit with the
import_refusal code -- a decided refusal of the closed set, never an empty inventory and never a fault, so
a caller reads it exactly as it reads every other import refusal. A construction that names neither -- no
locations and no 'none' -- is refused before the daemon exists, rather than reading whichever host happened
to start it. The per-user daemon this package installs -- authswitch authority daemon, the service unit's
own entry point -- is the one place that takes those locations from this host.
AuthSwitchClient.getUsage(accountId, loginId) reads OpenAI managed limits or a Claude
host login's profile and usage through the management socket. The tagged, credential-free
reading preserves Claude's labelled five-hour, weekly and model windows, plan provenance,
extra usage facts and explicit partial availability. Missing values never become zero.
The daemon caches one result in SmartData for a bounded current and stale period; a new
reading emits one account event without changing the account row revision. Concurrent
reads and rejected-token refresh share the existing provider and authority owners; usage
adds no polling timer. Native Claude reads hold the qualified vendor-home lease through
both status requests and publication, and revalidate the exact credential and config
source even when serving a cached result. Native grants are never refreshed by the daemon.
Pending, quarantined, setup-token-without-profile-scope and other unsupported contexts
return explicit unavailable status without a provider request. Production native home
adoption still requires the separate verified migration cutover.
AuthSwitchClient.startPreuse() accepts one client-generated operation UUID and one exact
OpenAI account/login/purpose. The daemon resolves an access-only grant, optionally reads the
model catalog, then commits request_may_have_started immediately before FlexHarness sends one
inference with retries disabled. Reusing the UUID with the same request returns its durable receipt; changing
the target, prompt or requested model is rejected. A crash, cancellation or shutdown after the
marker remains outcome_unknown and is never replayed. Before-marker refusals are not_sent.
A reached usage limit is named in the receipt: problem is usage_limit or rate_limit and limit
carries { kind, window, resetsAt } (null otherwise). The account's usage reading stops the prompt
before the marker (not_sent); a provider that refuses the prompt with a limit before it begins to
answer settles it refused, a terminal state in which nothing was consumed.
Only the request hash, selected model, timing and safe token counts are stored; the prompt,
generated prose and credentials are absent from persistence and management responses. Account
removal and targeted reauthentication refuse while that account has an active preuse request.
A trusted in-process daemon host can call AuthSwitchAuthorityDaemon.startManagedCodex()
for a bound account and workspace. It owns a private Codex app-server, passes only
the authority's current access token through Codex's external-token login, answers its
unauthorized callback through the authority, and revokes the binding on stop. The returned
runtime exposes an interactive codex --remote unix://… launch descriptor and native
thread/start, thread/resume and turn/start methods for noninteractive consumers.
A backend in another process asks the running authority for the same runtime over the
management socket: AuthSwitchClient.startManagedCodex({ accountId, loginId, purpose: 'openai_managed', scopeId, codexHomeDirectory }) (authswitch.authority.codex.start) answers
{ scopeId, accountId, loginId, codexHomeDirectory, socketPath } and the backend attaches to
socketPath as an app-server client (for example with @modelprofile.com/mcp-crossharness's
CodexAppServerClient), stating each thread's working directory itself. The authority keeps the
login, answers Codex's token refresh and owns the process; it leaves every other server request to
the attached clients, and a client that does not own the login leaves the refresh request, which
Codex sends to every client, unanswered. Asking again with the same scope, account, login and home
answers the same runtime, which is how a backend that restarted finds it again; another account or
home on a running scope is account_busy. stopManagedCodex(scopeId) (authswitch.authority.codex.stop)
stops it and releases its binding. The runtime also ends when the authority stops, and the backend
asks again once it is back. The socket path is a local control endpoint, not a credential, and stays
with a backend of the same user. The daemon runs managedCodexExecutable (the service states
AUTHSWITCH_CODEX_BIN or codex).
Callers supply a private, durable codexHomeDirectory for Codex's own configuration and
conversation state. SmartData binds that home immutably to one account and scope, and the
daemon records its active process/socket identity before launching it. Admission refuses
an existing nonempty native home or a surviving app-server owner; a fresh empty home is
created on first use. A removal or re-sign-in of an account that goes ahead stops that
account's managed Codex first, as a Codex switch restarts Codex: running turns end,
attached clients are disconnected, and until the device operation settles or the removal
finishes, starting Codex on the account is account_busy, so a client asks again then, as
after any stop. One its checks refuse -- a stale revision, a login that is not the account's,
a binding its holder still has to release -- stops nothing. A durably recorded managed
Codex process this daemon does not run -- one left by an earlier run of it -- refuses both
with account_busy until its exact process is proven gone; an uncertain launch remains
blocked. An account keeps its login id when it is removed and signed in again, so a home
bound to it serves it again. Only the private socket directory is removed on close.
Codex's credential store remains ephemeral and does not read or write auth.json in that
home.
Codex binds the app-server socket in its own per-user directory,
<realpath /tmp>/codex-daemon-<euid>, and publishes the private socket path as a link to it.
The daemon checks that publication with @modelprofile.com/mcp-crossharness's
observeCodexUnixSocket and connects only when the link is exactly Codex's own, in a
directory other users cannot rewrite, and the socket behind it is the user's own in a 0700
directory of the user's; anything else fails the start with that package's error. Codex keeps a
zero-byte startup lock in that directory for each socket path it has served; the file is
Codex's own and authswitch does not remove it.
Managed Codex runs Codex 0.156.0 or newer -- the floor @modelprofile.com/mcp-crossharness's
requireCodexVersion checks, the first release that publishes the socket alias -- and checks the
contract at every start instead of pinning one release. Before anything is recorded or started, the
codex it will run must report a readable version at or above the floor, and its own protocol
description (codex app-server generate-json-schema) must still offer every surface managed Codex uses:
the requests account/login/start, thread/start, thread/resume and turn/start; the external
ChatGPT token login with the fields it sends; the account/chatgptAuthTokens/refresh callback with the
unauthorized reason and the reply it gives; and the thread and turn parameters it sends. After
connecting, the app-server must report exactly the version that was checked, because Codex can replace
itself between the check and the start. Each failure is the codex_unsupported refusal naming the
version or the missing surface, so a Codex update that drops something managed Codex relies on stops new
managed sessions by name instead of failing one later. A prerelease above the floor runs; a prerelease of
0.156.0 itself is below it.
Managed-Codex process ownership observation is qualified on Linux and reads exact argument
boundaries from /proc; on other hosts doctor reports the process observation as unknown,
and authority actions that require survivor proof refuse instead of guessing.
Codex (verified through 0.157) does not persist an unused thread created by thread/start; resume applies
after Codex has committed a rollout. The existing CLI has not yet been moved to this path.
This SDK is additive at this stage. The commands documented below still use the existing credential stores, except that the legacy fence below refuses them a login the authority holds. The authority daemon does not import those stores automatically, replace their active native sessions, or take over their refresh grants. An active Codex/OpenCode native grant remains pending until its owner is idle and an explicit handoff verifies the latest native source. Claude Code's own native credential files remain vendor-owned; a container setup-token grant is a separate grant under the same account. The one-time migration and deletion of authswitch's old stores are separate cutover steps.
The backend-only Claude native handoff journals an exact two-file write and transfers
ownership between two account grants in one SmartData transaction. An inactive Claude
grant is refreshed by the daemon only after a durable pre-send marker; pending, native,
and quarantined grants cannot enter that exchange. A native home and incoming grant
require a verified import receipt: a migration ledger row that binds the exact source
digest to one account and grant. A source counts as migrated only after such a verified
import. authswitch authority import submit writes one for the host's Claude home and for
every saved Claude login it imports, and a Claude sign-in writes one for the login it
imports (see "Adding a Claude account"). The installed daemon serves this host's Claude
home, and switches it only once such an import has adopted it. Handoff receipts are
queryable after a lost response.
runningEffectiveAuth: unsupported_effective_auth means the native store changed, while
an already-running Claude process's effective login cannot be proven externally;
qualified live native-store switching still works.
The authority database admits exactly the collections of one registry and prepares each at startup. Startup rejects unknown collections, unknown metadata schemas and an existing store without its metadata document. Published 7.0.0 metadata is upgraded in place to the shape 6.4/6.5 already wrote, while account IDs, sealed grants, enrollments and uncertain states remain intact. A 7.0.0 store that a restore had marked closed is refused, not opened. Startup also removes the backup digest that 6.4.0 to 7.0.0 stored on native handoff and migration ledger rows, and refuses a store holding a record only the retired backup and restore system could have written. The store is deliberately not backed up: its refresh grants are sealed to this host's TPM, so a lost store is recovered by one device re-login per account.
The legacy fence
Once the authority has taken a saved copy over, the legacy save and switch must not write it again: an
adopted copy's refresh token was rotated away by the import, and activating it would hand a tool a dead
login. runAuthSwitchMutation -- the one path the command line, the guide, watch and the hosted
AuthSwitchService all take -- asks first, and decides in three branches.
- The authority answers: it is asked for this record's ledger row through
import statuson the management socket, keyed by the same source id the importer uses (a hash of the saved copy's path). Averifiedorcompleterow refuses withauthority_holds_login; any other row, or none, lets the command run. An authority that answers decides, whether or not its service unit is installed. - Nothing answers within four seconds -- no socket, a silent one, or no
XDG_RUNTIME_DIR-- and the authority's service unit is installed: the command refuses withauthority_unavailableand the instruction to runauthswitch authority service start. Unknown is never treated as idle. - Nothing answers and no unit is installed: the legacy commands behave exactly as before. This is every host before the cutover.
What tells the second branch from the third is durable, never the socket: the runtime directory is tmpfs,
so after a reboot the socket is absent until the user service starts -- exactly when a fence keyed on it
would open. The evidence is the unit authswitch authority service install writes,
$XDG_DATA_HOME/systemd/user/authswitch-authority.service (default
~/.local/share/systemd/user/authswitch-authority.service), read through smartdaemon's
SystemdUnitFile.inspect(). The installer and the fence derive that directory from one rule,
$XDG_DATA_HOME or else the home directory; the installer takes both from its own process and its account's
home, the fence from the locations its caller states. The two agree unless $HOME is overridden while
XDG_DATA_HOME is unset, and then every store the fence guards lies under that overridden home too. The fence
writes nothing and keeps no record of its own.
The check works without a login session. Reading the unit needs only its directory, so a cron job or a plain
ssh command with no XDG_RUNTIME_DIR is decided by the unit exactly like a desktop session: no unit, the
legacy commands run; the unit installed, they refuse with authority_unavailable. The stated
XDG_RUNTIME_DIR names only the socket to ask; an absent one is never replaced by this process's own. The
answer depends on the unit directory derived from the stated XDG_DATA_HOME or home, and on nothing else:
a malformed XDG_RUNTIME_DIR or XDG_CONFIG_HOME never refuses the command, and neither does a malformed
process XDG_DATA_HOME when the caller states other locations. A unit that cannot be inspected -- a file the
installer would not have written, a directory another user can write -- refuses rather than being read as
absent, and so does a stated XDG_DATA_HOME that names no usable unit directory, because the reader can
then tell an installed authority from none no better. The refusal says why after its first sentence: a path
smartdaemon will not vouch for is named with the reason and, where a generic fix is safe, the command -- for
a group-writable ~/.local, "/home/you/.local is writable by its group or by others, so nothing beneath
it can be trusted; remove that with chmod go-w /home/you/.local and run this again." A unit file with
another mode than 0644 gets chmod 0644; a symbolic link, a foreign owner or a file the installer could not
have written is named without a command; any other failure is named by its error class and code.
authswitch authority service install explains its failures in the same words. It also refuses to replace
a unit that is in use or not in a state systemd can replace safely, names smartdaemon's reason and what to do
about it: for a running service, stop it with authswitch authority service stop, install again, then start it
with authswitch authority service start; for a queued job, wait for it; for a pending reload, run
systemctl --user daemon-reload; drop-in files and a unit systemd loads from another file are named. On a
platform without systemd no unit can be installed.
The unit says an authority exists, not which stores it imports from, so while an installed authority is stopped the legacy save and switch refuse for every saved copy of every store, until it runs again. A record the importer could never have read -- an account id that names no saved-copy file -- is never asked about.
drop / rm and the remove mutation are deliberately not fenced: they delete the user's own saved copy
and write no credential. A projected source is never fenced either -- its native store stays with its own
tool. The command line takes this host's locations only when it composes its own harnesses, and with
injected harnesses is fenced only by locations its caller states; AuthSwitchOperations,
runAuthSwitchMutation and an AuthSwitchService given its harnesses take the locations as a required
argument (authSwitchHostLegacyFence() names this host's, 'none' states that a
caller owns no authority), so no library path reads a host it was not given. AuthSwitchOperations and the
hosted service ask the fence before a coordinator they were given as well, so a refused mutation never has its host
stop the runtimes it would restart. The hosted service reports a fence refusal as the operation's problem, in
the instruction's own words.
Usage
Run authswitch, authswitch -i, or authswitch --interactive for an interactive guide. Use the arrow keys and
Enter to switch accounts, save the current login, list accounts and live status, check remote
control, preuse one or all accounts, or remove a saved account. The guide shows the current
account and returns to the menu after each successful action; choose Back in a submenu or
Exit to finish.
Before showing the actions, the guide checks whether the current credential has a readable saved copy of its own account. A login without one gets an offer to save it while keeping it active; a login its harness merely refreshed is already saved. The switch action checks again before offering targets; declining that save cancels the switch. Scripts still preserve the outgoing login automatically through the harness adapter.
When saving, choose whether to keep the account active or clear the active login so
you can run codex login for another account. Removing a saved account requires
confirmation and leaves the active login in place.
The guide requires an interactive terminal. With no arguments, piped output and CI
runs show help instead; explicitly requesting -i, --interactive, or --tui
without an interactive terminal fails. Explicit commands remain available for scripts:
authswitch # interactive guide
authswitch -i # explicitly open the guide
authswitch codex --interactive # guide for one harness
authswitch --tui # full-screen account management
authswitch codex --tui # start the dashboard on Codex
authswitch list # all known accounts across registered harnesses, with live status
authswitch list --json # complete account/status document for scripts
authswitch limits # every account and limit type: used % and reset countdown
authswitch active # which account each provider is using right now
authswitch codex stash # save the active credential under its account email
authswitch codex list # Codex accounts, including an unsaved active login, with live status
authswitch codex use [email] # activate one; prompts when no email is given
authswitch opencode use [email] --stop # stop running OpenCode instances first
authswitch opencode use [email] --keep-running # switch without stopping anything
authswitch codex preuse <email> # send the default prompt without activating the account
authswitch codex preuse --all # the same for every Codex account, one account at a time
authswitch watch # check usage every 2 minutes and switch when an account runs out
authswitch claude watch --threshold 90 --dry-run # report what it would switch, for one harness
authswitch codex current # print the account currently in use
authswitch codex drop <email> # forget a stash
authswitch codex doctor # report credential storage and remote-control state
authswitch opencode list # all OpenCode provider logins and saved accounts
authswitch opencode stash openai --keep
authswitch opencode use alice@example.com
authswitch opencode login openai # device login; save the new account without switching
authswitch codex login # save a new Codex account without disturbing the current login
authswitch claude stash --keep # save a Claude Code subscriber login, while it keeps running
authswitch claude list --json
authswitch claude use alice@example.com
OpenCode and Claude Code
Both adapters support current, stash, use, drop, doctor, the guide,
the management TUI and human/JSON lists. The command line carries out every
credential change itself; it asks no other program, AGL included, to carry it out.
Every operation, including use and stash, works while OpenCode or Claude
Code is running: the credential file is written atomically, the outgoing login is
re-saved from disk first, and a write that races a native rewrite is detected and
refused rather than half-applied. A running Claude Code picks a switch up on its next
request, so it never needs a restart. Status lookups never activate, switch or clear a
login; reading a saved Claude login whose access token has expired does renew that saved
copy's own tokens, and nothing else. preuse remains a Codex capability.
Running instances during a switch
Claude Code picks a switch up live. A running session re-reads its credential
file whenever the file changes, before its next request, and reloads .claude.json
when that changes (verified against Claude Code 2.1.273 and 2.1.282). So use, stash and a
backend activation never ask for Claude Code to be stopped and never suggest a
restart; a switch reports Running Claude Code sessions use it from their next request. What authswitch must not do is write in the middle of that session's own
writes, so every Claude Code operation that reads or writes the native login runs
while holding Claude Code's own locks, taken with its own library and options: the
OAuth refresh lock (<config dir>/.oauth_refresh.lock and its legacy twin
<config dir>.lock), the credential write lock (<config dir>/.storage-write.lock)
and the global config lock (.claude.json.lock). A lock Claude Code holds is waited
for up to 15 seconds, without holding the others meanwhile, so a live session is
never blocked by a waiting switch; after that the operation fails with nothing
changed. A lock left behind by a Claude Code that died goes stale after its own
threshold and is taken over, as Claude Code itself would.
A running OpenCode can still overwrite a switch. Not by holding the previous login:
its auth service caches nothing and re-reads auth.json on every lookup. The window is a
request already in flight, which read the previous login before the switch and can write
that login's own rotation back afterwards -- and OpenCode replaces the whole provider map
in place rather than atomically. So before use and
before stash without --keep, authswitch lists OpenCode's own running instances --
pid, start time and command line -- and offers to stop them:
2 OpenCode process(es) are running:
pid 4821 started 2026-09-16 14:02 UTC opencode
pid 5533 started 2026-09-16 14:02 UTC opencode serve
? Stop these 2 OpenCode process(es) first? (y/N)
Answering yes sends SIGTERM to exactly those pids and waits up to ten seconds;
an instance that survives is reported by pid and the switch continues. Three flags
decide the same thing without a prompt:
| Flag | Effect |
|---|---|
--stop |
stop the listed instances gracefully, then switch |
--force-stop |
the same, then SIGKILL the ones that ignored SIGTERM |
--keep-running |
switch without stopping anything |
--stop and --force-stop also stop Claude Code's instances when you ask for it;
without them nothing about Claude Code's processes is listed or offered, and
--keep-running is accepted and changes nothing.
Without a terminal and without a flag, nothing is ever signalled: the instances are
listed, the switch proceeds, and the command says which flag would have changed that.
SIGKILL is only ever sent for --force-stop. Only pids authswitch enumerated for
the current user in that operation are signalled -- never a name or pattern match,
never another user's process -- and a pid is re-verified against a fresh listing
before a forced kill, so a recycled pid cannot inherit it. The session that is
running the command is listed but never stopped: stopping it would kill the switch.
On Windows the instances are listed but not stopped, because its process list does
not identify their owner. stash --keep needs none of this: it writes no native
file and is guarded by proving the native sources unchanged around the read.
After an OpenCode switch that left instances running, the command says so explicitly,
because one of them refreshing its token can put the previous account back. A backend
activation through AuthSwitchService gets that same caveat in its outcome, but no
listing and no offer: a host has no terminal to consent with, and a supervisor that
completed the mutation itself owns its own runtime's lifecycle.
authswitch codex login and authswitch opencode login openai use the shared
OpenAI device login flow. They display a verification link and code, then save
the new account without changing the active native login. Ctrl-C cancels login.
Select the saved account with use when ready to switch. Claude Code and other
OpenCode providers retain their native login commands.
The ChatGPT sign-in
authswitch runs the ChatGPT sign-in itself, as the one party that signs in and
refreshes: the device code and its poll, the authorization-code exchange, the
refresh-token grant, the revocation, the claims of the tokens, and the account's
wham/usage limits. AuthSwitchOpenAiProvider is that provider -- beginLogin,
refreshCredential (one shared request per refresh token, so a rotating grant is
sent once), revokeCredential, inspectCredential and getAccountRateLimits --
and interfaces.openai holds its types. It holds no credential of its own. The
protocol is the one Codex's own login uses, adapted from OpenAI Codex (Apache-2.0;
see third-party-notices.md), and every failure is a fixed message that carries no
response body, token or header. Inference with a login's access is not auth:
FlexHarness's /openai module serves it from { accessToken, accountId, isFedrampAccount } alone, which is exactly what the authority's resolveAccess
answers.
The protocol itself lives in @modelprofile.com/authswitch-signin, published from
this repository's ts_signin folder and shipped inside this package as well.
It also serves applications that sign accounts in on a server: a device sign-in
whose state any process can finish after a restart, and OpenAiGrantManager,
which keeps a grant usable for many processes over a store the application supplies.
See its readme.
Hosted account management
AuthSwitchService exposes credential-free account lists, login capabilities,
login prompts, saved-account identifiers and mutation outcomes for authenticated
hosts such as AGL. Every request has protocolVersion: 1. list, login and
mutate start bounded background operations; get polls the returned operation
ID and cancel cancels a pending login. Operations outlive a disconnected client,
and completed results remain available for ten minutes. Call close() during
host shutdown. The host must authenticate and authorize every request.
Login requests identify the harness, provider and flow explicitly; the one flow is
device, which needs no browser on the machine that signs in. AuthSwitchLogin is
the backend login owner. Its default provider is AuthSwitchOpenAiProvider,
authswitch's own ChatGPT sign-in (see "The ChatGPT sign-in"), and it accepts any
other provider that implements IAuthLoginProvider. Its credential-bearing
completion is for backend storage only.
When an enclosing registry owns an injected adapter, construct it with
{ disposeProviders: false } as the second argument and dispose that registry
after closing the login service.
OpenCodeHarness.importCredential(providerId, credential) saves an inactive
login. activateCredential(providerId, credential) preserves and verifies the
outgoing native login before activation; the caller retains ownership of the
incoming credential's durable storage. Both are backend APIs, never wire payloads.
AuthSwitchOperations is shared by the command, guide and TUI, and is built
with a coordinator (or undefined) and the legacy fence locations described in
"The legacy fence". A host that injects its own harnesses into AuthSwitchService
states that fence as the third argument. The coordinator receives only the
harness, operation, account ID, consent to wait, and a credential-location
fingerprint. A failed or ambiguous transport never falls through to a second local
mutation. Nothing passes a coordinator by default: the authswitch command line
builds its operations with none and carries out each mutation itself.
AglAuthSwitchCoordinator, the bridge that ran agl authswitch --request <json>,
is deprecated because AGL no longer serves that command; it and the coordinator
parameters of AuthSwitchOperations, AuthSwitchCli and AuthSwitchService are
slated for removal in the next major.
authSwitchMutationReplacesLogin(mutation) says whether a mutation replaces the
harness's active login -- switch, or save without keepActive -- and therefore
concerns the instances running on it. The command line, the dashboard and a host
such as AGL all read this one rule; remove and a save that keeps the login
active leave the loaded login in place.
That rule, assertAuthSwitchMutation and the TAuthSwitchMutation command are also
published on their own at @modelprofile.com/authswitch/mutation, which a browser can
bundle, as it can the preuse sentences at ./preuse-text:
import { authSwitchMutationReplacesLogin, type TAuthSwitchMutation } from '@modelprofile.com/authswitch/mutation';
That module imports nothing at all -- no Node built-in, no package, no other module of
this one -- so a web client decides what a mutation means to the instances it shows
without pulling a credential store into the bundle. The package root stays Node-only:
it reaches the stash, and with it node:fs, node:child_process and node:crypto.
Both entry points export the same three names from the same source, so a Node caller
can keep importing them from the package root.
OpenCode credentials live in ${XDG_DATA_HOME:-~/.local/share}/opencode/auth.json.
Several providers can be active at once. Saving or switching openai preserves
every other provider, and stash openai without --keep clears only OpenAI.
When several logins are active, the guide asks which to save; scripts must name
the provider or account. In the TUI, select an active row before pressing a or c.
ChatGPT OAuth accounts display their email and use the same direct status APIs as
Codex. An anthropic OAuth login holds the same Claude subscriber grant Claude Code
stores, so it reports Claude's own plan, organization and usage windows through the
account endpoints described under Claude Code; OpenCode keeps no scopes and no
account identity with that entry, so both are left to the service. An access token inside
Claude Code's five-minute margin is renewed first, with the request OpenCode's own
Anthropic provider sends (opencode-anthropic-auth: console.anthropic.com, Claude
Code's public client, no scope), and the rotated pair is written back into auth.json
atomically, preserving every other provider. Nothing is stranded by that write: OpenCode's
auth service re-reads auth.json on every lookup and its Anthropic provider asks for the
credential again inside every request, so a running instance picks up the rotation exactly
as it picks up its own. A refusal is reported and nothing is written, and a saved copy is
never renewed, because OpenCode's own entry holds the same refresh token.
API keys and opaque provider tokens have no reliable email, so their
labels contain the provider and a SHA-256 fingerprint. Their quota/billing information is
explicitly unavailable. OPENCODE_AUTH_CONTENT overrides the file and blocks file
management. Provider configuration and environment credentials remain managed by OpenCode.
Claude Code supports subscriber file logins on Linux and Windows. The adapter pairs
~/.claude/.credentials.json with ~/.claude.json's oauthAccount; with
CLAUDE_CONFIG_DIR, both files are inside that directory. Switching preserves
unrelated settings and credential entries and clears native account-specific caches.
A metadata write failure rolls back our writes and keeps the outgoing account saved.
macOS Keychain switching and environment/API-key/cloud-provider logins are explicitly
unsupported. A file login shadowed by an environment override cannot be switched.
User and current-directory project settings are also checked for credential helpers
and provider environment overrides; helpers are never executed. Authswitch manages
the local file login, so launch-time flags, managed policies and credentials supplied
by embedding applications remain under Claude Code's control.
Claude's direct OAuth profile and usage lookups report the plan, weekly and five-hour
usage, per-model and per-surface windows, and available extra-usage budget information.
The usage response's limits[] rows are the authoritative window list: which
meters apply, their scope, the service's severity (normal, warning,
critical) and its headline pick are Claude's, so a new per-model weekly limit
such as Fable weekly appears without an authswitch release. list --json keeps
the rows in the service's order; the tables and limits --json order them as
described under Condensed views. Rows are classified on their
kind and reset group exactly as sent. Labels combine the service's model or surface
name with the window period, and such a scoped row is treated as a feature limit.
Rows in a reset group authswitch cannot represent (anything but the five-hour
session and the weekly window) are not shown; one Unsupported limits
availability fact names them. A malformed row, or a limits value that is not a
list, makes the whole usage lookup unavailable rather than partially guessed.
Responses without limits[] rows fall back to the older keyed weekly, five-hour
and model windows.
These endpoints require the user:profile scope and may reject expired or restricted
logins. A null reset timestamp is shown as Not scheduled. Billing renewal and
cancellation dates and earned reset credits are unavailable from these endpoints;
authswitch does not infer them from account creation or token expiry dates. The
Claude Code adapter declares that, so limits explains it once for the provider
instead of per account.
Nothing but Claude Code refreshes a Claude login, so a saved one stops being readable a
few hours after it was saved. Reading a saved, inactive account's status therefore
refreshes it first, exactly as Claude Code would: when its access token expires within
five minutes, authswitch exchanges the stored refresh token at Claude's token endpoint,
keeps every other field of the login, and writes the result only while the saved record
still holds the refresh token it sent. The request is the one Claude Code sends, down to
the scopes: a login of Claude Code's own OAuth client is renewed with Claude Code's
current scope set plus the project scopes the login carries -- not with the set the
login happens to be stored under, which can be an older or wider one the service no
longer accepts -- and a set the service refuses as invalid_scope is sent once more
with the stored scopes. A login that names its own OAuth client keeps its stored scopes,
because only that client's grant knows them. A login that is neither one of Claude
Code's subscriber logins nor stored with a plan is not sent at all. The refresh holds
the account store's lock, so a switch started at the same time waits for it and then
activates the refreshed login. The active login is never refreshed -- Claude Code
owns it, and two parties refreshing one login can invalidate the grant. A refusal is
reported, never worked around -- a rejected grant says so and says what fixes it
(below), and a service failure, an unreachable service or an unsupported response leave
the saved login untouched. Requests carry User-Agent: authswitch/<version>, and HTTP
429 is reported as rate limiting with the service's retry time -- never as an exhausted
or expired account.
Claude Code also rotates the refresh token of the login it is running on, so reading the
accounts (list, limits, active, current, watch, the dashboard) mirrors that login
into its own saved record whenever the two differ. Without it a login replaced outside
authswitch -- a /login in Claude Code, a new device login -- left the stash holding the
token from before the rotation, which the sign-in service can refuse: it did for two saved
logins on 2026-09-18. The record keeps its account, its identity and its save time, Claude
Code's own files are only ever read, and a login with no saved record of its own is never
saved implicitly; stash is what saves a login. An installation whose native login
authswitch cannot read mirrors nothing, and a read waits at most a second for a lock a
switch or Claude Code holds before leaving the saved copy to the next read.
Codex is mirrored the same way, for the same reason: its app-server refreshes the login it
runs on its own schedule, so the saved copy of that login held older tokens between
switches -- and, because a saved login used to mean the exact credential in
~/.codex/auth.json, an account read as unsaved from its first refresh on and the guide
offered to save what was already saved. A saved Codex login is now one whose stored
credential belongs to the account its record names, and reading the accounts writes the
refreshed credential into that record. Only the saved copy is written: ~/.codex/auth.json
is read, no app-server is stopped or signalled for it, and the record keeps its account,
its remote-control enrollments and its save time. A login with no saved record of its own,
a record whose credential is missing, and a record that belongs to another Codex account
with the same email are all left alone; stash is what saves or repairs those. A copy that
could not be written says so beside the account and is tried again on the next read.
An account the service has put on hold is not a dead login: the hold refuses the grant while it lasts and the same login works again once it is lifted, so such an account keeps its stored plan, says that it is on hold and where to appeal, and is asked again on the next read.
A grant the sign-in service refused is remembered in the saved record itself, as a hash
of the request that was refused -- the refresh token, the client and the scopes, never
the token itself -- so a new authswitch run, a restarted watch or a second dashboard
does not replay a dead login. Because the hash covers the request and not only the
token, a release that changes how authswitch asks gives every such login one honest
attempt under the new derivation, while a refusal of the request authswitch still sends
stays remembered. Saving that login again clears it, as does any refresh that replaces
the saved credential. Such a login reports Login expired or was rejected. Log in again with Claude Code and save it. Claude Code rotates a login's refresh token as it uses it, and the service can refuse the older one a saved copy still holds once that login has been replaced outside authswitch; authswitch claude stash --keep saves the new one.
Each n/a in a Claude row has a reason in the footnote under the table, and each
reason names what to do:
Login refresh: Login expired or was rejected.-- the sign-in service refused the saved refresh token itself. Log in again in Claude Code for that account and runauthswitch claude stash --keep; nothing else fixes it.Login refresh: This Claude account is on hold and cannot sign in.-- the account, not the login. Appeal at the address in the message; the saved login is kept and is asked again on the next read.Login refresh: This saved login has no refresh token, and no subscriber scope or plan to refresh with.-- the saved record is missing what a refresh is made of. Save the login again.Login refresh: this saved login's access token has expired and cannot be renewed for this installation.-- authswitch cannot read Claude Code's own files here (the macOS Keychain, or an incomplete native login), so it cannot prove this login is not the active one, and never refreshes a login that might be.Login refresh: The Claude sign-in service returned HTTP ...,... could not be reached.,... timed out.,... is rate limiting requests (HTTP 429)-- the service, not the login. The saved login is untouched; read again later.Profile: ...orUsage: ...-- the account endpoints, asked with a token the refresh had just accepted.Profile: Login expired or was rejected.means the access token itself was refused; the other wordings are the service, as above.Claude returned a different account or organization-- the answer was about another account and was discarded whole, rather than shown under this account's name.Claude profile response is unsupportedorClaude usage response is unsupported-- the service answered in a shape authswitch does not read. Nothing is guessed from a response it does not understand.
An OpenCode anthropic row reads the same endpoints and so shows the same reasons. Its
renewal adds Login refresh: Login expired or was rejected. Log in again with opencode auth login., which is Anthropic refusing the refresh token OpenCode's entry holds -- no
retry changes that, and the refusal is never remembered, because OpenCode owns that entry.
This saved Anthropic login's access token has expired. is a stashed copy, which is left
alone so that the login OpenCode uses keeps a refresh token the service still accepts.
OpenCode and Claude saved records are separate owner-only files under
~/.authswitch/opencode and ~/.authswitch/claude. Emails are accepted as account
references; ambiguous matches report provider-qualified references and full IDs.
Restarting Codex's app-server
A host that restarts Codex on request -- AGL's "Restart Codex" -- calls CodexDaemon.restart(), which runs the
same codex app-server daemon commands a credential change runs around it, with nothing changed in between:
import { CodexDaemon, CodexHome, CodexAppServerRefusal } from '@modelprofile.com/authswitch';
const daemon = new CodexDaemon(new CodexHome(codexHomeDirectory));
try {
const outcome = await daemon.restart(); // ICodexAppServerRestart
// outcome.restart: 'restarted' | 'restarted_unscoped' | 'start_failed'
// outcome.wasRunning: whether a running app-server was stopped first
} catch (error) {
if (!(error instanceof CodexAppServerRefusal)) throw error;
// error.reason: 'unmanaged' | 'unknown' | 'stop_failed'; error.restart: how a start after a failed stop went
}
It stops a running app-server with codex app-server daemon stop -- running Codex turns end -- and then always
starts it with codex app-server daemon start, also when none was running. The start runs from HOME in its own
systemd user scope, so the app-server outlives the host that restarted it; where no user manager answers it starts
directly and answers restarted_unscoped with the reason. The app-server gets back the environment the running
one had, read from /proc before the stop; when none ran, or it could not be read, it gets the minimal
environment codexAppServerEnvironment states, and the outcome says so (environment: 'allowlist' with
environmentProblem). wasRunning says whether a running app-server was stopped first, and
describeCodexRestart(outcome) words the outcome, as a start where none was running. A start that fails is the
answer (start_failed with problem), never a throw.
It refuses, as a credential change does and before anything is stopped, an app-server codex app-server daemon
does not manage (unmanaged) and a Codex that cannot say whether its app-server runs (unknown). A stop that
fails refuses with stop_failed, after starting the app-server again unless Codex says it still runs. The call
takes at most codexAppServerRestartBoundMs.
Preusing an account
preuse sends one text prompt through the selected account. Use it after a reset
to make the first token-consuming request for the next usage window:
authswitch codex preuse alice@example.com
authswitch codex preuse alice@example.com --prompt "Write 2000 words about strawberries."
authswitch codex preuse alice@example.com --model gpt-5.5 --prompt "Describe a strawberry in one sentence."
authswitch codex preuse --all
authswitch codex preuse --all --model gpt-5.5
The default prompt is Write 2000 words about strawberries. Without an account,
an interactive terminal shows a picker of the harness's accounts, plus All
accounts when it has more than one and Back. Choosing one account runs it
exactly as naming it would; choosing All accounts confirms once and then runs
as --all does, table and exit status included. Scripts must specify an account
or --all and, when multiple harnesses are registered, the harness. Active and
saved accounts are supported, including an active login that has not been stashed.
--all preuses every account the harness lists, active and saved, each exactly
once -- an active login and its own saved record are one account -- and it cannot
be combined with an account reference. The accounts run strictly one after
another, never in parallel, each with one request and no retry, and each one is
reported as it goes. The run ends with a table and a counts line:
[1/3] Preusing alice@example.com (Codex)…
Prompt completed with gpt-5.5. Tokens: 19 input, 2711 output, 2730 total.
[2/3] Preusing bob@example.com (Codex)…
Prompt completed with gpt-5.5. Tokens: 19 input, 2711 output, 2730 total.
[3/3] Preusing apikey:9f2 (Codex)…
Codex preuse requires a ChatGPT subscription login; API-key logins have no subscription reset window.
Preuse results
┌────────────────────┬───────────┬─────────┬─────────────────────┬──────────────┐
│ Account │ Outcome │ Model │ Tokens in/out/total │ Next reset │
├────────────────────┼───────────┼─────────┼─────────────────────┼──────────────┤
│ alice@example.com │ Completed │ gpt-5.5 │ 19/2711/2730 │ 5d 18h 53min │
│ bob@example.com │ Completed │ gpt-5.5 │ 19/2711/2730 │ 5d 18h 53min │
│ apikey:9f2 │ Skipped │ - │ - │ - │
└────────────────────┴───────────┴─────────┴─────────────────────┴──────────────┘
2 completed, 1 skipped.
Next reset is the lead window of the reset schedule read after that account's
prompt, the same window the single-account reset table lists first. An account
that sent no prompt has no model, token count or deadline of its own, and says so
rather than showing a zero. Skipped is a refusal the adapter stated before any
request -- an API-key login, a missing or expired credential, a model catalog
that could not be read, a usage limit the account has reached -- so nothing was
consumed for it. Failed and Interrupted may both have consumed tokens and are
never retried; Not run is an account the interruption never reached. Every
account that did not complete prints its reason, and a Failed one always names
its cause without the provider's text: a limit, a rejected token, the 180-second
deadline, a cancellation, an answer that ended unfinished, an HTTP error status
(the provider answered HTTP 503), an answer that could not be read, an error the answer stream reported with the
provider's error code, a connection that failed before any answer, or an error
that names no cause.
Before it sends anything, Codex preuse reads the account's usage (wham/usage).
When the account's limit is reached, the account is Skipped with the window and
its reset in local time, for example Weekly usage limit reached, resets in 16h 46min (2026-09-27 05:12). No preuse prompt was sent. A reading that fails
decides nothing: the prompt is sent and the provider's own answer counts, except a
rejected token, which is refused before sending as the catalog read refuses it.
When the provider still refuses the prompt with a usage or rate limit before it
begins to answer -- a 429 naming usage_limit_reached, or a stream that fails with
it before its first output -- the reason names that limit and says that no tokens
were consumed. A limit reported after the answer began is named the same way, with
its window and reset, and says that tokens may already have been consumed: the
provider had begun to answer.
The guide (authswitch, authswitch -i) offers Preuse an account or all
accounts (consumes quota) for a harness that supports it. It asks which account
or All accounts, confirms once and names what the prompt will be sent to, then
reports exactly as the command line does: one account's reset schedule, or the
results table for all of them. Back or a declined confirmation sends nothing,
and an account the adapter refuses returns to the menu rather than ending the guide.
Codex inference uses the published FlexHarness models/providers packages and the
selected ChatGPT login's access token and account ID. It selects the account's
advertised default text model unless --model is supplied, preferring a supported
low reasoning setting when the catalog provides one. API-key logins are rejected
because they do not have ChatGPT subscription reset windows. Expired credentials
must be renewed through Codex and saved again.
Each invocation consumes quota and sends one inference request per account. It does not watch for resets or schedule future requests. No active login is switched, credential refreshed or written, app-server restarted, or earned reset consumed. The prompt has no tools, files or conversation history. The generated prose is drained without being printed or stored; the command reports provider token counts and then reads the account's reset schedule. It does not infer that a timer started merely from a zero-percent usage reading or a successful response.
Ctrl-C cancels the request, and in a --all run it stops the run before the next
account starts. Codex preuse has a three-minute deadline, with a ten-second
model-catalog limit. Failed or interrupted requests can have consumed tokens and
are never retried automatically. A completed prompt exits with 0 even if the
subsequent reset lookup fails, reporting that uncertainty separately; inference
failure exits with 1, invalid arguments or unsupported harnesses with 2, and an
interrupted prompt with 130. A named single account also exits 1 when it was
refused, because it is the account the command was asked to preuse; --all exits
0 when every account either completed or was skipped for a stated reason. Check
authswitch list before repeating an uncertain request. --json remains a
list-only option.
Watching usage and switching automatically
authswitch watch reads every account's usage on an interval and switches a harness to a
better saved account when the one in use runs out:
authswitch watch # every harness that supports it, every 2m, at 95%
authswitch claude watch # one harness (also: authswitch watch claude)
authswitch watch --interval 5m --threshold 90 # check less often, switch earlier
authswitch watch --dry-run # report decisions, change nothing
authswitch watch --once # one check, then exit
authswitch watch --json # one JSON event per line
14:02:07 Watching Claude Code, Codex every 2m; switching at 95%.
14:02:07 Running Codex sessions keep the login they loaded; new sessions use the switched one.
14:02:09 Claude Code alice@example.com (type:Max) (5h:17%) (w:4%) ok
14:02:10 Codex switched bob@example.com → carol@example.com (type:Pro) (5h:3%) (w:12%) — Codex weekly 96% ≥ 95%
14:04:09 Claude Code alice@example.com (type:Max) (5h:21%) (w:4%) ok
14:04:10 Codex carol@example.com (type:Pro) (5h:4%) (w:12%) ok
Watch covers the harnesses whose adapter declares autoSwitch: Claude Code and Codex.
OpenCode is not watched, because most of its providers report no usage and its lifecycle
belongs to its supervisor. Naming an unsupported harness exits with 2, as does an interval
outside 1 minute to 1 day, a threshold outside 50 to 100, or any other argument the command
cannot act on. --interval takes seconds or a unit (120, 90s, 2m, 1h, 1d).
How it decides. Each check reads the harness's accounts and every account's usage, then applies one rule set per harness:
- The account in use is used up when any of its general account windows has reached the threshold. The watch then switches to the saved account whose account windows are all at least ten points below the threshold, preferring the one whose longest window resets soonest (capacity that expires first is used first), then the least used, then by name.
- When every other saved account is used up too, it switches to the one that is usable again first, and only when that is more than one interval sooner than the account in use.
- An account limited only on a feature window (one model or surface) is traded for an account that is below the margin on every window; without one it stays, because it still serves everything else.
- A reading that is unknown, too old, rejected or rate limited never counts as used up and
never makes an account a target. The line then says so instead of saying
ok.
What it does and does not do. A switch goes through the same path as use and never asks
for a session of yours to be stopped: Claude Code picks the new login up on its next request, and running Codex sessions
keep the login they loaded while new ones use the switched account (the start line says so
once). A Codex switch does stop and start Codex' own managed app-server, because auth.json
cannot be rewritten underneath it, so its remote-control connection drops for a moment. A
switch that is refused or fails is reported and decided again on the next check. Checks
never overlap; a check that takes longer than the interval is followed by the next one at
once. An account whose lookup a provider refused (HTTP 429) or whose lookup failed is not
asked about again until the time the provider named, or after a delay that doubles from one
interval up to half an hour, and the account's last reading stands in meanwhile while it is
less than ten minutes old. Claude's profile is read at most once an hour per account while
watching; usage is always live.
One watch runs per AUTHSWITCH_HOME: a second one exits with 1 and names the running pid,
and a lock left behind by a watch that died is taken over. Ctrl-C or SIGTERM cancels the
lookups in flight, releases the lock and exits with 0. --once exits with 0, or 1 when a
switch it attempted did not complete. --json writes one event per line
(start, tick, switch, error) with accounts, readings and reasons, and no credential.
Management dashboard
authswitch --tui opens a resizable account table with subscription, usage and
reset availability, usage bars for the lead window and the next general window,
scrollable account details and an activity log.
With multiple registered adapters, a harness selector switches the view; account
selection and mutations always belong to the displayed harness. Status loads
incrementally through each account's status API, without activating it.
| Key | Action |
|---|---|
| Arrows, Home/End, PageUp/PageDown | Select accounts or scroll the focused view |
| Tab / Shift-Tab | Move focus between harnesses, accounts, tabs and details |
| Enter | Switch to the selected saved account; activate a harness in its selector |
/, s, S |
Filter the table, cycle its sort column, reverse sorting |
a / c |
Save the current login and keep it active / save and clear it |
d |
Remove the selected saved copy, preserving the active login |
p / P |
Preuse the selected account / every account of this harness (consumes quota) |
r / g |
Refresh status / run harness diagnostics |
q / Ctrl-C |
Close and restore the terminal |
The preuse keys appear in the key line only for a harness that supports preuse, and each run states how many accounts it will consume quota on before it starts. The footer follows the run account by account, the activity log keeps each account's result, and the table is read again afterwards because the prompts moved the usage it shows. Closing the dashboard stops the run before its next account.
Save, switch, preuse and removal actions require confirmation. Confirmations start on Cancel; use Left/Right or Tab to select the action, then Enter. Switching first checks the current credential and offers to save an unsaved login. A declined, failed or unverified save prevents switching. Plain character shortcuts remain available as text while editing a table filter; Enter keeps the filter and Escape clears it. Closing waits for an in-flight operation to finish its cleanup.
Interactive mode flags accept an optional harness and cannot be combined with commands or another mode flag.
Getting your accounts into the stash
stash reads whatever Codex currently has active, files it under the account's email, and empties the active slot so the next login starts clean:
authswitch codex stash # -> stashed alice@example.com
codex login # log in as the second account
authswitch codex stash # -> stashed bob@example.com
Use --keep to take a checkpoint without logging yourself out.
Switching
authswitch codex use alice@example.com
Or with no argument, to pick from a list:
authswitch codex use
The account argument also accepts the directory slug, the local part, or a unique prefix, so authswitch codex use alice resolves to alice@example.com when that is unambiguous. It is deliberately not a substring match — this argument decides which credential becomes active, and an incidental substring hit should never pick an account you did not name.
So you know what you switch to, every harness's switch names the account with a badge of its type and its current usage:
? Which Claude Code account should be active?
❯ phil@example.com (active) (type:Max) (5h:12%) (w:75%)
alice@example.com (type:Pro, unverified) (w:40%)
...
Now active: alice@example.com (type:Pro, unverified) (w:40%)
The badge appears in the account picker of use, in the dashboard's switch
confirmation and on the Now active: line that reports a completed switch (in the
dashboard, its activity log). type is the account type as limits shows it; 5h
and w are the account's five-hour and weekly usage windows, rounded to whole
percent -- except that a window the provider reports below 100% never reads as the
exhausted 100%. Feature limits never appear in it, and when several account windows share a
period without the provider picking one of them, that part is left out rather than
guessed.
Usage is read live and once per command. The picker looks up the offered
accounts, at most four at a time, before it asks; a switch named on the command line
switches first and then looks up only its target for the Now active: line. Whatever
has not answered after 2.5 seconds is left out and its requests are cancelled, so the
command still exits at once. Every part the provider did not report is omitted, and
with none known the account is named alone. Reading usage never delays the switch
beyond that deadline and never fails it. The dashboard reuses the status it already
shows and makes no extra lookup.
What a switch actually does
use runs as one sequence, and steps 1 to 4 stop at the first failure:
- Stop the app-server. It holds the state database open, owns the remote-control websocket, and refreshes tokens on its own schedule. Reading or writing
auth.jsonwhile it runs races that refresh — the copy taken could be a token the daemon has already rotated away, and a credential the daemon wrote afterwards would be destroyed unstashed.authswitchstops it through Codex' owncodex app-server daemon stoprather than signalling the process behind Codex' back, and aborts if it will not stop. Codex restarts; running Codex turns end. An app-servercodex app-server daemondoes not manage --daemon versionnames no backend for it -- is refused by name before anything is stopped, because Codex's own stop refuses it too. When Codex cannot answer at all, the app-server counts as running only if a daemon pid file names a live process. Codex bounds its own lifecycle commands (up to 375 s for its operation lock, 300 s of shutdown grace, 10 s to force a stop), and authswitch waits past those bounds rather than cutting a stop short. - Check in the outgoing credential. Only now is the active credential read, stashed under its account email, and read back from the stash to prove it landed. A credential is never overwritten until a copy has been verified on disk. Re-stashing an account that already has a stash refreshes it.
- Write the incoming credential to
auth.json, owner-only, through an exclusively-created temp file and a rename. - Restore the incoming account's remote-control enrollments into the state database. If the state database or its enrollment table is missing, this fails loudly rather than reporting that it restored nothing.
- Start the app-server again — but only if it was running to begin with, so the machine's posture is unchanged. This runs even when an earlier step failed: a failed switch must not leave remote control down, and its error then also says whether the app-server came back. Codex detaches the app-server it starts with
setsidalone, so on Linux it would stay in the control group of whatever started it and end with that service -- the authority's, or AGL's.codex app-server daemon starttherefore runs in a transient scope of the user's systemd manager (systemd-run --user --scope, through@push.rocks/smartdaemon), from the home directory, with the environment the app-server ran with before the stop: every Codex turn runs its tools with that environment, so the owner's agent socket (SSH_AUTH_SOCK),GPG_AGENT_INFOand whatever else their shell set stay. On Linux, step 1 reads it from/proc/<pid>/environof the process a daemon pid file names, when that process hasapp-serveramong its arguments and this Codex home as itsCODEX_HOME(or$HOME/.codex). Only what systemd sets itself or refuses is left out --XDG_RUNTIME_DIRandINVOCATION_ID, which the new scope sets anew, andSYSTEMD_*andDBUS_*; the lost session bus is no loss, because a Codex home that keeps its login in an OS keyring is refused before anything is stopped -- and a variable no scope can carry (a name such asBASH_FUNC_x%%, a value over 32768 characters) is left out and named. When it is not that process, or any read fails, the start falls back to the minimal onecodexAppServerEnvironmentnames --CODEX_HOME,HOME,PATH, the user's identity, locale, time zone and base directories, and the certificate and configuration paths Codex carries into its own daemon, nothing else of the process that restarts it and no proxy variable -- and says so with the reason;codex app-server daemon stopandstartfrom the owner's shell then give it the shell's environment back. Where no systemd user manager answers, or the host has none, it starts directly and says so. A start never throws: whatever fails isstart_failedwith the reason. A stop that fails and leaves Codex unable to say whether its app-server still runs starts it again too, and the refusal says the app-server may have stopped and how the start went. A switch or save through the hosted service orAuthSwitchOperationsreports what happened in the outcome'scodexAppServer--not_running,restarted,restarted_unscoped(started without a scope of its own) orstart_failed-- withcodexAppServerDetail(unscopedReason,environment,environmentProblem), andwatch --jsoncarries both on a Codexswitchevent, whose text says them too.
stash follows the same quiesce-and-resume discipline, for the same reason.
If the switch completes but something did not finish — most likely the app-server refusing to come back — the command prints what is outstanding and exits non-zero, so it cannot look like a success to a script.
Running use for the account that is already active is not a no-op: it restores that account's enrollments, which is the repair path when remote control has stopped working for the current login.
Re-selecting the active account keeps its current tokens, including any refresh since it was last saved, and updates the verified stash before restoring pairings.
doctor prints where the credential lives, which state database is in use, every
enrollment row, whether the app-server is up, and any stash directory left incomplete
by an interrupted write.
Account status
authswitch list lists every registered harness; authswitch codex list limits the
output to Codex. Harnesses appear in the order limits uses, by provider name. Each
section includes saved accounts and an identifiable active login even if it has
never been saved, and draws up to four tables under the harness's name:
Codex
┌───────────────────┬──────────────────┬────────────────────┬─────────────┬───────────────┐
│ Account │ Status │ Usage │ Renewal │ Earned resets │
├───────────────────┼──────────────────┼────────────────────┼─────────────┼───────────────┤
│ alice@example.com │ active, saved │ weekly 12% · 3d 4h │ 2026-10-08 │ 0 │
│ Pro │ 2026-09-17 13:32 │ 5h 37% · 2h 13m │ auto-renews │ │
├───────────────────┼──────────────────┼────────────────────┼─────────────┼───────────────┤
│ bob@example.com │ saved │ usage n/a │ n/a │ n/a │
│ Plus, unverified │ 2026-09-14 11:32 │ │ │ │
└───────────────────┴──────────────────┴────────────────────┴─────────────┴───────────────┘
┌───────────────────┬────────────────────┬────────┬───────────┐
│ Account │ Limit type │ Used % │ Resets in │
├───────────────────┼────────────────────┼────────┼───────────┤
│ alice@example.com │ Codex weekly │ 12% │ 3d 4h │
│ Pro │ Codex five-hour │ 37% │ 2h 13m │
│ │ Code review weekly │ 100% │ 3h 59m │
└───────────────────┴────────────────────┴────────┴───────────┘
┌────────────────┬───────────────────┐
│ Metric │ alice@example.com │
├────────────────┴───────────────────┤
│ Saved login │
│ remote-control │ 1 │
└────────────────┴───────────────────┘
┌─────────────────┬─────────────────────────────────────────────────────────┐
│ Account │ Problem │
├─────────────────┼─────────────────────────────────────────────────────────┤
│ bob@example.com │ Subscription and limits: Login expired or was rejected. │
└─────────────────┴─────────────────────────────────────────────────────────┘
- Accounts -- one row per account, divided from the next. Account is the
label (bold when active) with the account type beneath it, as in
limits, and, for a harness with several credential slots, the slot (openai · Pro). Status says whether the login is active and saved, with the local date and time of its last save beneath; an active login without a save readsnot saved. Usage holds one compact line per headline window,<window> <used>% · <reset countdown>, lead window first as described below. Renewal is the same cell as inlimits, and a harness whose provider never reports billing dates, such as Claude Code, gets one note beneath the table instead of an unexplainedn/aper account. Earned resets appears only when an account of the harness reports earned reset credits: the available count, then one line per reported credit. - Limits -- the
limitstable of this harness, without its Provider and Renewal columns, for the accounts that report windows. - Details -- one comparison of every remaining provider fact: Metric and one column per account, split into pages when the terminal is too narrow, with a full-width row per section. Facts already shown above are left out: those that restate a displayed structured field, and one that only repeats the account's own label. Dated rows, such as Codex's daily token totals, are merged across accounts and keep the newest seven days. A sentence that every account of its section states identically, such as Claude's note that it reports no billing dates, is printed once beneath the table. Saved-login notes (Codex remote-control pairing, account IDs) form the Saved login section; a note that only names the account's credential slot is left out, because the Accounts table names it.
- Problems -- a login changed outside authswitch first (
Active login changed outside authswitch: expected alice@example.com, now bob@example.com (not saved) — authswitch claude stash --keep saves it), then every account's problems, then the harness's own. Problems of one account that share a message are merged with their topics (Profile, usage:above the message), and accounts with the same problems share one block, whose Account cell names them all on one line.
Human output never prints a raw ISO timestamp: dates and times are local
(2026-10-08, 2026-09-17 13:32), and a timestamp inside provider text is rewritten
the same way. list --json keeps every original value. A note beneath a table wraps to
the terminal, with its continuation lines under the note's prefix.
The lead window is the provider's headline window, otherwise weekly. Countdowns use
two units, such as 3d 4h; due means the reported deadline has passed, without
claiming the service has refreshed the quota, and not scheduled means the provider
reported no reset. All countdowns share the same snapshot time. When the provider
picks a headline window (Claude does, for example its Fable weekly limit), that
window leads. Otherwise the general account windows lead, weekly first. A general
window is named by its period (weekly, 5h) unless another shown general window
has the same period; a feature limit, or a general window sharing its period, is
named by its label (Fable weekly 100% · 2d). Feature limits such as Spark and code
review appear in the Limits table and lead the usage cell only when the provider
picks one as its headline. The TUI uses the same selection and names. Window
durations come from the provider, not from the plan name or the slot a window
arrives in; a Pro account with only a general weekly window gets no invented 5-hour
limit.
Every window label names the window by how long it is -- Codex weekly, Codex five-hour, Claude weekly, Code review weekly -- so the same length reads the
same in every harness. The named lengths are five-hour, daily, weekly,
monthly and annual, the same set Codex names its own windows with, and its
primary/secondary slot is shown (Codex weekly (secondary)) only where two windows
of one length would otherwise share a label. A length with no name of its own keeps
its compact duration (Codex 90m) instead of reading as a period it is not.
When accounts of one harness share a label, every table numbers the later ones
(alice@example.com (2)), so each account keeps its own column in the Details
comparison and reads the same everywhere. Missing data stays n/a; known zeroes
remain zero. Plans read as in limits and the switch badge (Max), and stored plans
are marked unverified (Plus, unverified). Renewal dates are shown only when an
adapter can provide live billing data. Codex uses the desktop app's account-check
endpoint for automatic renewal, explicit renewal/cancellation dates and subscription
expiry.
The layout follows the terminal width. From 120 columns every Accounts column stands
alone; from 100 the earned resets join the usage cell; from 60 the status joins the
account cell; below 60 one Status cell holds status, usage, renewal and earned resets;
below 40 every table becomes labelled lines, separated by a rule line per block. Each
list section covers one harness, so no colour line is drawn.
Condensed views
authswitch limits answers "how much is left, when does it come back, and when does
the subscription renew" in one table. It is one row per account and limit type,
sorted by provider and then by account email with each harness and each account kept
together, with a two-unit countdown (2h 13m, 3d 4h, 45m, <1m, due) and the
account's next billing event:
Account limits
┌──────────────────┬───────────────────┬───────────────────┬────────┬───────────────┬─────────────┐
│ Provider │ Account │ Limit type │ Used % │ Resets in │ Renewal │
├──────────────────┼───────────────────┼───────────────────┼────────┼───────────────┼─────────────┤
┃ Claude Code │ phil@example.com │ Claude weekly │ 75% │ 4d 3h │ n/a │
┃ │ Max │ Claude five-hour │ 12% │ 1h 2m │ │
┃ │ │ Fable weekly │ 88% │ 2d 5h │ │
├──────────────────┼───────────────────┼───────────────────┼────────┼───────────────┼─────────────┤
┃ Codex │ alice@example.com │ Codex weekly │ 12% │ 3d 4h │ 2026-10-08 │
┃ │ Pro │ Codex five-hour │ 37% │ 2h 13m │ auto-renews │
┃ │ │ Code review weekl │ 100% │ 3h 59m │ │
┃ │ │ y │ │ │ │
┃ ├───────────────────┼───────────────────┼────────┼───────────────┼─────────────┤
┃ │ bob@example.com │ n/a │ n/a │ n/a │ n/a │
┃ │ Plus, unverified │ │ │ │ │
├──────────────────┼───────────────────┼───────────────────┼────────┼───────────────┼─────────────┤
┃ OpenCode / anthr │ anthropic API key │ n/a │ n/a │ n/a │ n/a │
┃ opic │ 20e751c3b707 │ │ │ │ │
┃ │ n/a │ │ │ │ │
├──────────────────┼───────────────────┼───────────────────┼────────┼───────────────┼─────────────┤
┃ OpenCode / opena │ alice@example.com │ Codex five-hour │ 0% │ not scheduled │ 2026-10-02 │
┃ i │ Pro │ │ │ │ cancels │
└──────────────────┴───────────────────┴───────────────────┴────────┴───────────────┴─────────────┘
n/a — Codex · bob@example.com: Subscription and limits: Login expired or was rejected.
n/a — OpenCode / anthropic · anthropic API key 20e751c3b707: This provider login does not expose a
supported subscription or quota API.
n/a — Claude Code: renewal dates are not exposed by Claude's account API.
The table fits 100 columns; at that width long provider names, account labels and limit types wrap inside their cells.
Renewal spans the rows of its account with two lines: the local calendar date,
and beneath it auto-renews when the provider says the subscription renews then, or
cancels when it ends then -- a scheduled cancellation, or an expiry with automatic
renewal off. Anything else reads n/a: the date comes only from live billing data,
never from a plan, a quota reset, account creation or token expiry. A harness whose
provider never reports billing dates, such as Claude Code, gets one footnote instead
of an unexplained n/a per account.
A number is never invented. An account whose provider exposes no quota API, whose
lookup failed, or whose login cannot be read shows n/a in Used % and
Resets in, and the footnote under the table says why — one line per distinct
reason. A provider is never dropped from the table for lacking data, and one failed
account never blanks another's numbers. not scheduled means the provider reported
usage without a reset deadline; it is not the same as n/a.
Limit types are the provider's own window names, ordered general-before-feature and
weekly-first within an account. Claude's per-model and per-surface limits carry the
service's name (Fable weekly); an unscoped Claude meter other than the five-hour
session and the weekly total keeps its kind in parentheses
(Claude weekly (weekly_oauth)). The provider column names the harness, qualified by
the credential slot when a harness owns several (OpenCode / openai).
Each provider and each account is named once. The Provider cell spans all rows of
its provider, and the Account cell spans the rows of its account with two lines:
the account label, and beneath it, dimmed, the account type — the provider's own
plan name with its first letter capitalised (Max, Pro, Prolite). A type read
from the stored login because the live lookup failed is marked unverified (Plus, unverified), and
an account whose provider reports no plan, such as an API-key login, shows n/a. A
provider row without an account has no type line.
Used % is coloured by the provider's own reading when it reports one: orange for
warning, red for critical, uncoloured for normal. Without a reading, n/a is
orange and an exhausted window (100% or more) is red. Colour is omitted when
NO_COLOR is set, output is not a terminal, or the terminal is narrower than 40
columns.
Rows are grouped in two independent ways. A divider separates one account from the
next, so an account's limit types read as one block; a provider row without an
account is a block of its own. Inside a provider's span the divider starts at the
Account column, and a change of provider draws a full rule. A line along the left edge (┃) marks the harness:
all its rows share one colour, including every credential slot of an OpenCode
installation, and the next harness takes the next colour (cyan, orange, green, pink,
blue, then again from the start). Without colour the ┃ edge still shows where the
data rows are. Groups follow harness and account ids, never labels: two accounts
that share an email are separate blocks, ordered by account id, and two harnesses
that share a label are ordered by harness id. A harness is placed by its first
provider name, so no other provider sorts between its slots. Below 40 columns the
table becomes labelled lines; every row repeats its provider and its account with
the type as a second line, a rule line separates the accounts, and no colour line is
drawn.
authswitch active answers "what am I logged in as" for every harness at once:
Active accounts
┌──────────────────────┬────────────────────────────────┬────────────┬─────────────────┐
│ Provider │ Account │ Saved │ Source │
├──────────────────────┼────────────────────────────────┼────────────┼─────────────────┤
┃ Claude Code │ phil@example.com │ 45m ago │ credential file │
┃ Codex │ alice@example.com │ 2h 13m ago │ credential file │
┃ Flex │ none │ n/a │ none │
┃ OpenCode / anthropic │ anthropic API key 20e751c3b707 │ 30d ago │ credential file │
┃ OpenCode / openai │ alice@example.com │ not saved │ credential file │
└──────────────────────┴────────────────────────────────┴────────────┴─────────────────┘
note — OpenCode / openai · alice@example.com: This active login is not saved yet; authswitch cannot
restore it after a switch.
Source is where the shown login comes from: credential file is the harness's
own live credential, stash only means nothing is active and only saved copies
exist, none means neither. Saved is how long ago authswitch last saved that
login, or not saved — which is also the warning that a switch could not bring it
back. A harness with no active login keeps its row and explains itself in a note.
Each harness has its own colour line, ordered and coloured as in limits; the
table has no dividers, since every row is a different credential slot.
Source also answers whether the file still holds what the last switch wrote.
When authswitch switches a login it records that account and the hash of the
credential it wrote, in its own store, never in the harness file. If a later read
finds a different account in that slot, the row's source reads
credential file (changed) and a note explains it:
note — Claude Code · bob@example.test: The active login changed outside authswitch since the last
switch (for example a new login). It now holds bob@example.test, which is saved; authswitch
claude use alice@example.test switches back.
For a harness that does not pick up swaps live, such as OpenCode, the sentence also
names the other cause: a running instance that wrote its previous login back. An
unsaved current login adds how to keep it (authswitch <harness> stash --keep saves it).
authswitch <harness> current prints the same sentence. The same account with a
rotated token is that account refreshing itself and is not reported; for Claude Code and
Codex the rotation is copied into that account's saved record. Re-running the switch
re-arms the Claude Code and OpenCode check against the file's current contents; Codex has
no such check.
Both commands accept a harness qualifier (authswitch claude limits) and --json.
Neither changes which account is in use: they never activate, switch or clear a login and
they need no harness stopped. Like list, they may renew a saved Claude login's own tokens
when its access token has expired, because otherwise its usage cannot be read at all. Every
provider lookup is bounded by the same 10-second timeout and isolated per account as
authswitch list.
JSON output
authswitch list --json, authswitch codex list --json, and the ls alias write
one JSON document to stdout, with no color, tables or progress messages. --json
may appear before or after the command and requires list, ls, limits or
active. It cannot be combined with a mutation or interactive mode. Unknown
arguments to these commands exit with 2.
limits --json emits IAccountLimits and active --json emits IActiveAccounts:
the same rows as the tables, plus the machine-readable fields the tables condense —
accountId, providerAccountId, slotId, accountType ({ plan, source } with the adapter's plan
name, such as max or pro, sanitised but not display-formatted, and source live
or stored; null when no type is known or the row names no account; both documents
carry it), the limits rows' billing (the account's reported hasActiveSubscription,
autoRenew and ISO renewsAt, cancelsAt and expiresAt, each omitted when not
reported; null when the provider reported none or the row names no account), scope, windowSeconds, the ISO resetAt beside the human
resetsIn, the provider's severity (normal, warning, critical, or null when
it gave none) and headline (true for the window the provider picks for a
single-value summary), the ISO savedAt beside the human savedAgo, the per-row
unavailableReason that the footnotes summarise, and drift, which carries the
expected and current account ids, their labels, whether the current one is saved,
the ISO switchedAt of the switch that was undone, and the same sentence as reason. accountId remains the opaque
authswitch identifier used for harness operations; providerAccountId is the exact provider-native identifier, or null
when the credential reports none. Both documents carry schemaVersion: 2, the
shared generatedAt snapshot every countdown is relative to, and complete, which
is false when any account or harness could not be read.
The exported IAccountList contract contains:
schemaVersion: 3,generatedAt(ISO UTC), andcomplete.harnesses[]:id,label,loginHint,saveUnavailableReason,accounts,credentialDrift(slots whose file no longer holds the account the last switch wrote), and harness-levelproblems(distinguishing failed discovery from an empty list).- Each account's opaque
id, exact provider-nativeproviderAccountId(or null),label,isActive, verifiedisStashed,savedAt,details, andstatuscontaining all labelledfacts,problems, and optional typedsummaryfields. Missing fields remain omitted, not replaced by zero;summary.usageWindows[]carriesseverityandheadlineonly when the provider reported them.
JSON retains full fact strings and absolute ISO reset/expiry timestamps. It exports
the credential-free adapter contract, never credentials, raw HTTP responses or
harness internals. A partial result still produces valid JSON and exits with 1;
successful and empty results exit with 0. complete describes lookup success,
not whether the provider exposes every possible metric.
Version 3 adds providerAccountId without changing the opaque id used by mutation commands. Version 2 allowed
usageWindows[].resetAt to be null and added optional account slotId fields. Consumers must
accept schema version 3, preserve the two identifiers' distinct meanings and handle null provider IDs and reset timestamps. Scripts should qualify
mutation commands with codex, opencode or claude, since all three now register
by default. Saved Codex credentials retain their existing format.
For each ChatGPT login, Codex status includes the live plan, every usage window the service reports with remaining percentages and reset times, additional limits such as code review, credit availability and balance, spend limits when returned, earned reset availability and expiry details, lifetime and peak daily tokens, activity streaks, and the latest seven reported daily token totals.
The Codex adapter implements the read-only GET requests used by Codex 0.155.0 at
chatgpt.com/backend-api/wham: usage, rate-limit-reset-credits, and profiles/me.
Billing uses chatgpt.com/backend-api/accounts/check/v4-2023-04-27, verified against
the official desktop bundle 26.908.40834. It selects the exact credential account;
another workspace's default entry cannot supply its billing information.
Each request uses that account's access token and account ID. For the active
account it reads the current credential, which may be newer than the stash.
Listing never switches accounts, starts or stops an app-server, refreshes tokens,
writes ~/.codex/auth.json, stores status results, or consumes an earned reset. The one
write it makes is the saved copy of the active login, brought onto the credential Codex is
running on as described above.
These backend contracts come from Codex's source and can change. Missing fields, expired logins, denied requests, timeouts, and unsupported responses are shown explicitly; they are never reported as zero usage. Other accounts and successful sections still appear, and a failed status lookup makes the command exit nonzero. An expired saved login must be renewed through Codex and saved again.
The returned usage plan is not a billing status. Billing dates come from the separate account-check response, never from a quota reset, token expiry or stored plan. Automatic renewal is on only when the provider says it will renew and no cancellation date is scheduled. A non-renewing account without a cancellation date or expiry does not acquire an invented date. When renewal is off, a returned renewal timestamp is never shown as a renewal; the Renewal cell shows the expiry as the day the subscription ends. JSON keeps every returned field.
The billing request uses a verified Chromium-compatible User-Agent because the service challenged the generic authswitch User-Agent in controlled comparisons. This is a fixed HTTP request profile and requires no local browser installation. Other Codex status endpoints retain the authswitch User-Agent. If the billing endpoint still returns a Cloudflare verification challenge, that is reported explicitly as unavailable billing and a partial result, without treating it as a rejected login. Authswitch does not import browser cookies, replay desktop integrity state, refresh credentials or switch accounts to bypass the challenge. A plan from the stored login remains unverified when the live usage request fails. API-key logins and keyring credentials cannot provide these ChatGPT subscription metrics.
Harness adapters
The CLI operates on IAuthHarness, with opaque account IDs scoped to each harness.
Codex, OpenCode and Claude Code are built-in adapters. Adding an adapter does not require
changing command routing, save prompts, account pickers or status rendering:
import { AuthSwitchCli, CodexHarness } from '@modelprofile.com/authswitch';
import { AnotherHarness } from './anotherharness.js'; // your IAuthHarness implementation
const cli = new AuthSwitchCli([new CodexHarness(), new AnotherHarness()]);
process.exitCode = await cli.run(process.argv.slice(2));
Each adapter owns discovery, identity matching, save verification, credential
storage, switching and process lifecycle, live status APIs, and diagnostics.
readState() and readAccountStatus() must never activate, switch or clear a login.
readState() may bring the saved copy of the active login onto the credential that login's
harness last wrote -- the Claude Code and Codex adapters do, because both harnesses rotate
the login they run -- and must write nothing else.
readAccountStatus() may renew the tokens of the saved copy it is reading, when the provider
gives it no other way to answer (the Claude Code adapter does, under the store's lock, for
saved and inactive logins only); it must never touch the active login's credential. readAccountStatus(accountId, options) receives an optional IHarnessStatusOptions whose signal cancels the
lookup's requests; authswitch aborts it when a status is no longer wanted, such as a
switch badge past its deadline or a closed dashboard, so an adapter should pass it to
every request it makes. Its optional session belongs to a caller that reads the same
accounts repeatedly, such as authswitch watch: session.reuse(key, maxAgeMs, lookup)
returns the value that key resolved to less than maxAgeMs ago, so an adapter can stop
repeating a lookup whose answer rarely changes (the Claude adapter reuses its profile for
an hour). Keys are the adapter's own and carry no secret; a rejected lookup is not
remembered, and outside a session every lookup is live. switchAccount() must
preserve and verify the outgoing credential without relying on the interactive
save prompt. removeAccount() removes only the saved copy. Status facts carry
provider-specific labels and units; unsupported capabilities are explicit. Adapter
labels, details, facts and outcomes contain plain text; smartconsole owns terminal
formatting for both the guide and dashboard.
Credentials, email assumptions and Codex enrollment metadata stay out of the
shared interface. The existing CodexSwitcher API and single-switcher CLI
constructor remain available.
An account's optional slotId identifies the credential it replaces. Accounts in
different slots may be active simultaneously. saveCurrent({ keepActive, accountId })
must target the selected active account; omitting accountId is allowed only when
the adapter has one unambiguous active login. usageWindows[].resetAt may be null
when the provider explicitly reports no scheduled reset; missing windows stay omitted.
Adapters may implement preuseAccount(accountId, options) as an optional
quota-consuming capability. IHarnessPreuseOptions carries the prompt, optional
model identifier and cancellation signal; IHarnessPreuseResult returns the
selected model and optional input/output/total token counts. The adapter owns
credential selection and inference and must leave the active login unchanged.
Unsupported adapters reject the command before account selection. Prompt results
never contain credentials or raw provider responses. A refusal that sent no
request must be a PreuseError with requestStarted false, which is what a run
over several accounts reports as skipped rather than failed; every other error,
PreuseError or not, is treated as a request that may have consumed tokens.
preuseAccounts(harness, accounts, options, report) is that run: it sends one
prompt per account in turn, reports a started and a finished event for each,
reads the account's reset schedule after a completed prompt, stops before the
next account once the signal aborts, and returns the per-account results with
their counts.
Adapters can also return an optional IHarnessAccountStatus.summary with a
subscription plan and its live/stored provenance, labelled usage windows with
duration, used percentage and UTC reset time, and available reset credits with
optional expiry details. This harness-neutral data supports shared presentation
without parsing provider-specific fact strings. Missing data stays unknown;
explicit zeroes remain zero. A successful reset-detail lookup supplies the summary
count; if it fails, the limits response can still supply availability.
Each usage window may set scope: 'account' | 'feature' (default account).
Adapters mark model-specific or feature-specific quotas as feature; those quotas
appear in the Limits table, JSON and details and lead the account summary only
when the provider marks one as its headline (see below). Weekly windows are identified by durationSeconds: 604800;
neither window labels nor plan names are parsed to infer limits.
A window may also carry the provider's own reading: severity?: TUsageSeverity
('normal' | 'warning' | 'critical') and headline?: true for the window the
provider picks for a single-value summary. Adapters set them only when the provider
reports them, never from local thresholds. A headline window leads the account
summary whatever its scope; the first one in the adapter's order wins if several are
marked.
Optional summary.billing contains provider-reported hasActiveSubscription,
autoRenew, renewsAt, cancelsAt, and expiresAt, independent of the plan name.
Dates are ISO UTC and omitted
when unknown. They must come from a live billing source; entitlement expiry alone
does not establish renewal or cancellation. Codex omits this field if its billing
lookup fails and supplies a corresponding problem.
Facts may supply a section label for grouped comparison and a summaryKey
(subscription, billing, usageWindows, resets, or resetDetails) when the
fact duplicates a structured field. The list hides such a fact only if that field
is displayed; JSON retains every fact. Adapters without this metadata continue to
display their facts in Additional information. A fact label ending in an ISO date
(Tokens 2026-09-15) marks a daily series, which the list merges across accounts.
Three optional adapter declarations shape the shared views. liveSwap: true says that
running instances pick up a credential swap on their next request: the CLI and TUI
then offer no stop before a switch (an explicit --stop or --force-stop is still
honoured through processes), outcomes carry no restart advice, and the credential
drift sentence names only a new login as the cause. renewalUnavailableReason is a
lower-case clause for a provider that never reports renewal dates; list and limits
print it once as <label>: <reason>. autoSwitch: true says that every saved account's
usage can be read and that a switch needs no session of the user's stopped, which is what
authswitch watch requires before it switches a harness by itself; a helper process the
adapter manages itself may still be restarted by its own switch, as Codex' app-server is. The built-in Claude Code adapter declares
all three; Codex declares autoSwitch: true with liveSwap: false, and OpenCode declares
neither.
With multiple adapters, the guide first asks which harness to manage and provides an action to choose another. Unqualified mutation commands prompt for a harness; scripts must qualify them. Account references never resolve across harnesses.
Where things are stored
| What | Where |
|---|---|
| Stashed credentials | ~/.authswitch/codex/<email>/auth.json (mode 0600, in a 0700 directory) |
| Saved OpenCode/Claude logins | ~/.authswitch/<harness>/<id>.json (mode 0600, in a 0700 directory) |
| Last switch per credential slot | ~/.authswitch/<harness>/switches.json (account ids, credential hashes and timestamps; no credentials) |
| Stash metadata and enrollments | ~/.authswitch/codex/<email>/stash.json |
| Codex' active credential | $CODEX_HOME/auth.json, default ~/.codex/auth.json |
| Codex' remote-control enrollments | the remote_control_enrollments table in $CODEX_HOME/state_<n>.sqlite |
| A Claude sign-in's home, while it runs | $XDG_DATA_HOME/authswitch/claude-signin/<operation-id>/, default under ~/.local/share (0700; removed when the sign-in ends) |
CODEX_HOME and AUTHSWITCH_HOME both override their defaults, which is also how the test suite runs against a throwaway directory. AUTHSWITCH_CODEX_BIN points at a specific codex executable when it is not on PATH, and NO_COLOR turns off colouring (as does piping the output anywhere that is not a terminal).
Account identity comes from the id_token inside the credential — the email claim names the stash, the account id ties it to its enrollments. The token is decoded, never verified, and never printed: authswitch only ever reports emails, plan names and server names.
Limits
Keyring-backed credentials cannot be switched. Codex can keep credentials in an OS keyring instead of auth.json, in which case there is no file to move. authswitch detects this and refuses rather than half-working; doctor reports it as keyring (not switchable). Switch Codex to file storage via auth_credentials_store_mode in config.toml and log in again to use this tool.
Other Codex clients keep running. Stopping the managed app-server does not stop an editor extension that spawned its own. Close it, or expect it to keep using the credential it already loaded.
A running OpenCode can undo a switch. Its auth service keeps no login in memory -- every lookup re-reads auth.json -- but a request already in flight, which read the previous login before the switch, can write that login's own rotation back afterwards, over a provider map OpenCode replaces in place. Authswitch lists those instances, offers to stop them, warns when any keep running, and detects the overwrite afterwards — but it cannot prevent it while they run. Restart them after a switch. A running Claude Code picks the switch up instead; its guarantee rests on the behaviour and lock protocol verified against Claude Code 2.1.273, and authswitch claude active still reports a login that changed outside authswitch.
The Codex stash is keyed by email. Two Codex workspaces with the same email cannot both be saved under that key. Their account IDs are distinguished during listing and identity checks, and a collision refuses to overwrite either login. Account IDs in the shared harness interface do not have this email restriction.
A stash is a credential. ~/.authswitch holds live refresh tokens. It is written owner-only, but it is not encrypted, and it belongs in the same threat model as ~/.codex/auth.json itself.
Development
pnpm install
pnpm build
pnpm test
The test suite builds a fixture CODEX_HOME with a real SQLite state database and exercises the full stash/login/restore cycle, including the case where a fresh login has removed an account's enrollment row. It never touches the developer's own Codex installation.
License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the repository license file.
The ChatGPT sign-in and usage-limit code is adapted from OpenAI Codex, which is licensed under the Apache License, Version 2.0. Its notices are in third-party-notices.md and its license in openai-codex-license.txt; both ship with the package.
Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
Company Information
Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.