Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ codex-switch account list
codex-switch account usage work
codex-switch use work
codex-switch current

# Detect or reconcile a login performed directly in Codex.
codex-switch sync --check
codex-switch sync
```

Use `codex-switch doctor` before reporting a problem. Machine-readable output is
Expand Down Expand Up @@ -106,6 +110,7 @@ contains usage numbers and public account metadata only, never tokens.
codex-switch init
codex-switch current
codex-switch status
codex-switch sync [--check] [--prefer-live] [--as <alias>]
codex-switch doctor
codex-switch use <alias>
codex-switch deactivate
Expand All @@ -126,6 +131,31 @@ codex-switch vault rotate-key
codex-switch update [--check]
```

## Logins changed outside codex-switch

The live `$CODEX_HOME/auth.json` is the source of truth for the active account.
`current`, `status`, `account list`, `account show`, and the default
`account usage` selection inspect that live identity instead of trusting the
last account recorded by `codex-switch`.

If you log in directly through Codex, inspect and safely adopt the change with:

```bash
codex-switch sync --check
codex-switch sync
```

When the live login belongs to another saved profile, `sync` repairs the active
pointer and adopts only a provably newer credential generation. An unmanaged
login can be preserved with `codex-switch sync --as <alias>`. If generations
cannot be ordered, the tool leaves both sides unchanged until you explicitly
run `codex-switch sync --prefer-live`.

Running `codex-switch use <alias>` when that alias is already live performs the
same safe reconciliation without rewriting `auth.json`, closing Codex, or
requiring a restart. A real switch to a different account still requires Codex
to be stopped.

## What a switch changes

Normal account switches modify only:
Expand Down
30 changes: 27 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
are never copied between profile directories.
4. The official `codex login` command owns the login protocol.
5. Unknown authentication schemas and ambiguous token generations fail closed.
6. The live `auth.json` identity is authoritative. Persisted active state is a
recovery hint and must never override a different live account ID.

## Components

Expand All @@ -19,8 +21,9 @@
credential store. WSL uses a Windows PowerShell bridge to protect the key with
current-user DPAPI and store only ciphertext in HKCU.
- `vault` encrypts all saved account profiles with XChaCha20-Poly1305.
- `switcher` reconciles a live Codex refresh generation, prepares a journal,
performs compare-before-replace, and records the selected profile.
- `switcher` observes the live identity, classifies external-login and token
drift, safely synchronizes known profiles, prepares a journal, performs
compare-before-replace, and records the selected profile.
- `codexusage` runs the official Codex App Server in an isolated temporary
`CODEX_HOME` and reads the stable account, rate-limit, and token-usage methods.
- `accountusage` queries up to four profiles concurrently, reconciles credential
Expand All @@ -35,10 +38,12 @@
```text
acquire lock
-> recover stale journal
-> verify Codex is stopped
-> read and hash live auth
-> identify the live account independently of recorded state
-> reconcile live refresh generation into vault
-> decrypt and validate target
-> if target is already live, repair state without replacing auth.json
-> otherwise verify Codex is stopped
-> persist prepared journal
-> compare live hash again
-> atomically replace auth.json
Expand All @@ -50,6 +55,25 @@ The journal contains only profile IDs, hashes, and timestamps. If the process
stops after replacement but before state persistence, recovery compares the live
file with both hashes and completes the state transition.

## Live-state reconciliation

```text
read live auth and recorded state
-> match account_id plus workspace_id against encrypted profiles
-> prefer one exact credential-material match
-> classify in-sync, external login, refresh, unmanaged, or ambiguous
-> for sync: compare the live hash again
-> adopt only a provably newer live generation
-> persist the derived active pointer
```

Read-oriented commands use the observation immediately, so a stale recorded
profile never receives the active marker. Observation itself does not modify
state or credentials; the existing live usage-refresh path may still persist a
validated newer token generation. `sync` performs explicit reconciliation
writes under the shared lock. Unknown and multiple matches are never assigned
by email or alias, and conflicting token generations require `--prefer-live`.

## Isolated usage query

```text
Expand Down
4 changes: 4 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ focuses on:
- A token refreshed during an isolated query is accepted only after account,
workspace, and refresh-generation checks. Active-file updates use a
compare-before-replace check under the shared operation lock.
- Active-account detection uses the live account and workspace identifiers;
stale recorded state, aliases, and email addresses cannot redirect live
credentials into another saved profile. Synchronization rechecks the live
file hash before committing derived state.
- Real credentials are forbidden in tests and fixtures.
- The Linux desktop implementation fails closed when Secret Service is absent.
WSL fails closed when neither the Windows DPAPI bridge nor Secret Service is
Expand Down
26 changes: 22 additions & 4 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,19 +55,37 @@ ordinary switching.

## Active account is unmanaged

Preserve it before switching:
Inspect it and preserve it before switching:

```bash
codex-switch account import-current current
codex-switch sync --check
codex-switch sync --as current
```

## Account list disagrees with a login performed in Codex

Recent releases derive the active marker from Codex's live `auth.json`. Check
the detected drift and reconcile the encrypted profile and local pointer:

```bash
codex-switch sync --check
codex-switch sync
```

If the live account is already a saved profile, no alias is required. This does
not rewrite Codex sessions, plugins, configuration, or UI state. Running
`codex-switch use <live-alias>` also repairs the state without requiring Codex
to close or restart.

## Token generations are ambiguous

Codex changed a refresh token but the saved and live timestamps cannot prove
which is newer. Reauthenticate the affected profile:
which is newer. Inspect the conflict first. To intentionally preserve the
credentials currently used by Codex:

```bash
codex-switch account reauth <alias>
codex-switch sync --check
codex-switch sync --prefer-live
```

The ambiguity is intentionally not resolved by guessing.
Expand Down
25 changes: 23 additions & 2 deletions internal/accountusage/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
appconfig "github.com/SilkageNet/codex-switch/internal/config"
"github.com/SilkageNet/codex-switch/internal/filelock"
appstate "github.com/SilkageNet/codex-switch/internal/state"
"github.com/SilkageNet/codex-switch/internal/switcher"
"github.com/SilkageNet/codex-switch/internal/usagecache"
"github.com/SilkageNet/codex-switch/internal/vault"
)
Expand Down Expand Up @@ -127,6 +128,14 @@ func (service Service) Refresh(ctx context.Context, profileIDs []string) (map[st
if stateErr != nil && !errors.Is(stateErr, os.ErrNotExist) {
return nil, stateErr
}
observation, observationErr := (switcher.Service{Home: service.Home, Paths: service.Paths, Vault: service.Vault}).Observe()
if observationErr != nil {
return nil, observationErr
}
activeProfileID := ""
if observation.Managed {
activeProfileID = observation.ProfileID
}
vaultChanged := false
for profileID, candidateRaw := range candidates {
profile, findErr := data.Find(profileID)
Expand Down Expand Up @@ -176,7 +185,7 @@ func (service Service) Refresh(ctx context.Context, profileIDs []string) (map[st
}
vaultChanged = true

if state.ActiveProfileID == profile.ID {
if activeProfileID == profile.ID {
changed, syncErr := service.reconcileActive(profile, candidate, &state)
if syncErr != nil {
entry := results[profileID]
Expand Down Expand Up @@ -220,7 +229,8 @@ func (service Service) reconcileActive(profile *vault.Profile, candidate authsch
if err != nil {
return false, fmt.Errorf("validate active credentials after refresh: %w", err)
}
if live.Tokens.AccountID != candidate.Tokens.AccountID {
if live.Tokens.AccountID != candidate.Tokens.AccountID ||
(live.WorkspaceID != "" && candidate.WorkspaceID != "" && live.WorkspaceID != candidate.WorkspaceID) {
return false, errors.New("active account changed while usage was being queried; refreshed credentials were kept only in the vault")
}
decision, err := authschema.CompareGeneration(candidate, live)
Expand All @@ -235,6 +245,10 @@ func (service Service) reconcileActive(profile *vault.Profile, candidate authsch
if refreshed, ok := live.GenerationTime(); ok {
profile.TokenUpdatedAt = refreshed
}
if err := appstate.Save(service.Paths.State, appstate.State{ActiveProfileID: profile.ID, AuthHash: liveHash}); err != nil {
return false, fmt.Errorf("record active credentials: %w", err)
}
*state = appstate.State{Version: 1, ActiveProfileID: profile.ID, AuthHash: liveHash}
return true, nil
case authschema.GenerationUseSaved:
currentHash, hashErr := service.Home.AuthHash()
Expand All @@ -255,6 +269,13 @@ func (service Service) reconcileActive(profile *vault.Profile, candidate authsch
return false, fmt.Errorf("record refreshed active credentials: %w", err)
}
*state = appstate.State{Version: 1, ActiveProfileID: profile.ID, AuthHash: publishedHash}
case authschema.GenerationSame:
if state.ActiveProfileID != profile.ID || state.AuthHash != liveHash {
if err := appstate.Save(service.Paths.State, appstate.State{ActiveProfileID: profile.ID, AuthHash: liveHash}); err != nil {
return false, fmt.Errorf("record active credentials: %w", err)
}
*state = appstate.State{Version: 1, ActiveProfileID: profile.ID, AuthHash: liveHash}
}
}
return false, nil
}
Expand Down
47 changes: 47 additions & 0 deletions internal/accountusage/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,53 @@ func TestRefreshRejectsCredentialsForDifferentAccount(t *testing.T) {
}
}

func TestRefreshUsesLiveAccountWhenRecordedStateIsStale(t *testing.T) {
service, manager, profile := testService(t, false)
data, err := manager.Load()
if err != nil {
t.Fatal(err)
}
otherDocument, err := authschema.Parse(authBytes("account-b", "refresh-b", "2026-08-20T00:00:00Z"))
if err != nil {
t.Fatal(err)
}
updatedAt, _ := otherDocument.GenerationTime()
other := vault.NewProfile("b", "test", otherDocument.Raw, "account-b", "", "b@example.com", updatedAt)
if err := data.Add(other, false); err != nil {
t.Fatal(err)
}
if err := manager.Save(data); err != nil {
t.Fatal(err)
}
savedOther, _ := data.Find("b")
if err := service.Home.WriteAuth(profile.Auth); err != nil {
t.Fatal(err)
}
if err := appstate.Save(service.Paths.State, appstate.State{ActiveProfileID: savedOther.ID, AuthHash: "stale"}); err != nil {
t.Fatal(err)
}
service.Runner = fakeRunner{
snapshot: codexusage.Snapshot{FetchedAt: time.Now().UTC(), PlanType: "pro"},
auth: authBytes("account-a", "refresh-new", "2026-08-20T01:00:00Z"),
}
results, err := service.Refresh(context.Background(), []string{profile.ID})
if err != nil {
t.Fatal(err)
}
if results[profile.ID].Error != "" {
t.Fatalf("unexpected refresh warning: %s", results[profile.ID].Error)
}
live, _ := service.Home.ReadAuth()
liveDocument, _ := authschema.Parse(live)
if liveDocument.Tokens.RefreshToken != "refresh-new" {
t.Fatal("actual active profile was not refreshed")
}
state, _ := appstate.Load(service.Paths.State)
if state.ActiveProfileID != profile.ID {
t.Fatalf("stale active state was not repaired: %#v", state)
}
}

func testService(t *testing.T, active bool) (Service, *vault.Manager, vault.Profile) {
t.Helper()
root := t.TempDir()
Expand Down
Loading