From d3fc4cc5696c31d507aed517af4ca3c21aeaecb1 Mon Sep 17 00:00:00 2001 From: Silkage Date: Thu, 3 Sep 2026 11:43:28 +0800 Subject: [PATCH] feat: reconcile external Codex logins --- README.md | 30 +++ docs/architecture.md | 30 ++- docs/security.md | 4 + docs/troubleshooting.md | 26 +- internal/accountusage/service.go | 25 +- internal/accountusage/service_test.go | 47 ++++ internal/app/app.go | 226 +++++++++++++--- internal/app/usage_test.go | 22 ++ internal/doctor/doctor.go | 22 ++ internal/switcher/observation.go | 368 ++++++++++++++++++++++++++ internal/switcher/switcher.go | 142 +++++----- internal/switcher/switcher_test.go | 287 ++++++++++++++++++++ 12 files changed, 1111 insertions(+), 118 deletions(-) create mode 100644 internal/switcher/observation.go diff --git a/README.md b/README.md index 7f036a9..7e6f44b 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 ] codex-switch doctor codex-switch use codex-switch deactivate @@ -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 `. If generations +cannot be ordered, the tool leaves both sides unchanged until you explicitly +run `codex-switch sync --prefer-live`. + +Running `codex-switch use ` 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: diff --git a/docs/architecture.md b/docs/architecture.md index ddcf92c..b3c30d7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/docs/security.md b/docs/security.md index e0010fb..fd8b096 100644 --- a/docs/security.md +++ b/docs/security.md @@ -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 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index afc3773..a785b48 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -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 ` 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 +codex-switch sync --check +codex-switch sync --prefer-live ``` The ambiguity is intentionally not resolved by guessing. diff --git a/internal/accountusage/service.go b/internal/accountusage/service.go index 1bf6488..be42fd5 100644 --- a/internal/accountusage/service.go +++ b/internal/accountusage/service.go @@ -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" ) @@ -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) @@ -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] @@ -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) @@ -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() @@ -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 } diff --git a/internal/accountusage/service_test.go b/internal/accountusage/service_test.go index 1b060e7..0dc7dbe 100644 --- a/internal/accountusage/service_test.go +++ b/internal/accountusage/service_test.go @@ -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() diff --git a/internal/app/app.go b/internal/app/app.go index 3bf4c2a..654e055 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -94,6 +94,7 @@ func NewCommand(version string) *cobra.Command { newInitCommand(options), newCurrentCommand(options), newStatusCommand(options), + newSyncCommand(options), newDoctorCommand(options), newUseCommand(options), newDeactivateCommand(options), @@ -245,7 +246,11 @@ func newAccountListCommand(options *Options) *cobra.Command { if err != nil { return err } - state, _ := appstate.Load(runtime.paths.State) + observation, err := runtime.switcher().Observe() + if err != nil { + return err + } + activeProfileID := observation.ProfileID cache, err := runtime.usageService(options.Version).Cached() if err != nil { return err @@ -275,10 +280,15 @@ func newAccountListCommand(options *Options) *cobra.Command { if err != nil { return err } + observation, err = runtime.switcher().Observe() + if err != nil { + return err + } + activeProfileID = observation.ProfileID } views := make([]accountView, 0, len(data.Profiles)) for _, profile := range data.Profiles { - view := toView(profile, profile.ID == state.ActiveProfileID) + view := toView(profile, profile.ID == activeProfileID) view.Usage = usageFromCache(cache.Profiles, profile.ID, refreshErrors[profile.ID], time.Now()) views = append(views, view) } @@ -303,7 +313,13 @@ func newAccountListCommand(options *Options) *cobra.Command { plan, limits, tokens, updated := summarizeUsage(view.Usage, time.Now()) _, _ = fmt.Fprintf(writer, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", marker, view.Alias, identity, plan, limits, tokens, updated) } - return writer.Flush() + if err := writer.Flush(); err != nil { + return err + } + if notice := observationNotice(observation); notice != "" { + _, _ = fmt.Fprintln(options.Output, notice) + } + return nil }, } command.Flags().BoolVar(&refresh, "refresh", false, "refresh every account before listing") @@ -330,7 +346,11 @@ func newAccountUsageCommand(options *Options) *cobra.Command { if err != nil { return err } - state, _ := appstate.Load(runtime.paths.State) + observation, err := runtime.switcher().Observe() + if err != nil { + return err + } + activeProfileID := observation.ProfileID profiles := make([]vault.Profile, 0, len(data.Profiles)) switch { case all: @@ -342,10 +362,13 @@ func newAccountUsageCommand(options *Options) *cobra.Command { } profiles = append(profiles, *profile) default: - if state.ActiveProfileID == "" { + if !observation.HasLive { return errors.New("no managed account is active; pass an alias or --all") } - profile, findErr := data.Find(state.ActiveProfileID) + if !observation.Managed { + return errors.New("the active account is unmanaged or ambiguous; pass a saved alias") + } + profile, findErr := data.Find(activeProfileID) if findErr != nil { return errors.New("the active account is unmanaged; pass a saved alias") } @@ -374,13 +397,20 @@ func newAccountUsageCommand(options *Options) *cobra.Command { } } } + if !cached { + observation, err = runtime.switcher().Observe() + if err != nil { + return err + } + activeProfileID = observation.ProfileID + } cache, err := runtime.usageService(options.Version).Cached() if err != nil { return err } views := make([]accountView, 0, len(profiles)) for _, profile := range profiles { - view := toView(profile, profile.ID == state.ActiveProfileID) + view := toView(profile, profile.ID == activeProfileID) view.Usage = usageFromCache(cache.Profiles, profile.ID, refreshErrors[profile.ID], time.Now()) if len(profiles) == 1 && view.Usage.Status == "unavailable" { if view.Usage.Error != "" { @@ -428,8 +458,12 @@ func newAccountShowCommand(options *Options) *cobra.Command { if err != nil { return err } - state, _ := appstate.Load(runtime.paths.State) - return options.render(toView(*profile, profile.ID == state.ActiveProfileID), formatView(toView(*profile, profile.ID == state.ActiveProfileID))) + observation, err := runtime.switcher().Observe() + if err != nil { + return err + } + view := toView(*profile, profile.ID == observation.ProfileID) + return options.render(view, formatView(view)) }, } } @@ -485,10 +519,16 @@ func newAccountRemoveCommand(options *Options) *cobra.Command { if err != nil { return err } - state, _ := appstate.Load(runtime.paths.State) - if profile.ID == state.ActiveProfileID { + observation, err := runtime.switcher().Observe() + if err != nil { + return err + } + if profile.ID == observation.ProfileID { return errors.New("cannot remove the active account; switch or deactivate first") } + if observation.State == switcher.AccountStateAmbiguous && profile.AccountID == observation.AccountID { + return errors.New("cannot remove a profile that may be active while the live account match is ambiguous") + } removed, err := data.Remove(args[0]) if err != nil { return err @@ -524,7 +564,10 @@ func newUseCommand(options *Options) *cobra.Command { } message := fmt.Sprintf("Active account: %s. Restart Codex to apply it.", result.Alias) if !result.Changed { - message = fmt.Sprintf("Account %s is already active.", result.Alias) + message = fmt.Sprintf("Account %s is already active. No restart is required.", result.Alias) + if result.CredentialsUpdated || result.StateRepaired { + message = fmt.Sprintf("Account %s is already active. Reconciled changes made outside codex-switch; no restart is required.", result.Alias) + } } return options.render(result, message) }, @@ -580,14 +623,68 @@ func newStatusCommand(options *Options) *cobra.Command { if err := runtime.switcher().Recover(); err != nil { return err } - view, err := options.currentView() + observation, err := runtime.switcher().Observe() + if err != nil { + return err + } + result := map[string]any{"codexHome": runtime.home.Path, "accountState": observation} + if view, viewErr := viewFromObservation(observation); viewErr == nil { + result["active"] = view + } + message := fmt.Sprintf("CODEX_HOME: %s\n%s", runtime.home.Path, formatObservation(observation)) + return options.render(result, message) + }, + } +} + +func newSyncCommand(options *Options) *cobra.Command { + var check bool + var preferLive bool + var alias string + command := &cobra.Command{ + Use: "sync", + Short: "Reconcile codex-switch with the active Codex login", + RunE: func(*cobra.Command, []string) error { + if check && (preferLive || alias != "") { + return errors.New("--check cannot be combined with --prefer-live or --as") + } + runtime, err := options.loadRuntime(false) + if err != nil { + return err + } + if check { + observation, err := runtime.switcher().Observe() + if err != nil { + return err + } + return options.render(observation, formatObservation(observation)) + } + result, err := runtime.switcher().Sync(switcher.SyncOptions{Alias: alias, PreferLive: preferLive}) if err != nil { return err } - result := map[string]any{"codexHome": runtime.home.Path, "active": view} - return options.render(result, fmt.Sprintf("CODEX_HOME: %s\n%s", runtime.home.Path, formatView(view))) + message := "Account state is already in sync." + switch { + case result.Imported: + message = fmt.Sprintf("Imported and synchronized the active Codex login as %q.", result.Alias) + case result.CredentialsUpdated && result.StateRepaired: + message = fmt.Sprintf("Synchronized credentials and repaired the active account as %s.", result.Alias) + case result.CredentialsUpdated: + message = fmt.Sprintf("Synchronized the active credentials for %s.", result.Alias) + case result.StateRepaired: + if result.Alias == "" { + message = "Cleared stale active-account state because Codex is logged out." + } else { + message = fmt.Sprintf("Repaired the active account as %s; Codex credentials were not replaced.", result.Alias) + } + } + return options.render(result, message) }, } + command.Flags().BoolVar(&check, "check", false, "inspect account drift without changing state") + command.Flags().BoolVar(&preferLive, "prefer-live", false, "adopt the active Codex credentials when generations conflict") + command.Flags().StringVar(&alias, "as", "", "import an unmanaged active login using this alias") + return command } func newDoctorCommand(options *Options) *cobra.Command { @@ -647,7 +744,11 @@ func newSelectCommand(options *Options) *cobra.Command { if err != nil { return err } - return options.render(result, fmt.Sprintf("Active account: %s. Restart Codex to apply it.", result.Alias)) + message := fmt.Sprintf("Active account: %s. Restart Codex to apply it.", result.Alias) + if !result.Changed { + message = fmt.Sprintf("Account %s is already active. No restart is required.", result.Alias) + } + return options.render(result, message) }, } command.Flags().BoolVar(&allowRunning, "allow-running", false, "switch even when a Codex process is detected") @@ -878,35 +979,86 @@ func (options *Options) currentView() (accountView, error) { if err != nil { return accountView{}, err } - data, err := runtime.manager.Load() + observation, err := runtime.switcher().Observe() if err != nil { return accountView{}, err } - state, err := appstate.Load(runtime.paths.State) - if err != nil { - return accountView{}, err - } - raw, err := runtime.home.ReadAuth() - if errors.Is(err, os.ErrNotExist) { + return viewFromObservation(observation) +} + +func viewFromObservation(observation switcher.Observation) (accountView, error) { + if !observation.HasLive { return accountView{}, errors.New("no account is active") } - if err != nil { - return accountView{}, err - } - document, err := authschema.Parse(raw) - if err != nil { - return accountView{}, err + if observation.State == switcher.AccountStateAmbiguous { + return accountView{}, errors.New("multiple saved profiles match the active Codex login; run 'codex-switch sync --check'") + } + if observation.Managed { + return accountView{ + ID: observation.ProfileID, + Alias: observation.Alias, + AccountID: observation.AccountID, + WorkspaceID: observation.WorkspaceID, + Email: observation.Email, + Source: observation.Source, + Active: true, + AuthenticatedAt: observation.AuthenticatedAt, + LastUsedAt: observation.LastUsedAt, + }, nil + } + return accountView{AccountID: observation.AccountID, WorkspaceID: observation.WorkspaceID, Email: observation.Email, Active: true, Alias: "unmanaged"}, nil +} + +func formatObservation(observation switcher.Observation) string { + identity := observation.Email + if identity == "" { + identity = observation.AccountID + } + switch observation.State { + case switcher.AccountStateInSync: + return fmt.Sprintf("Active account: %s (%s)\nState: in sync", observation.Alias, identity) + case switcher.AccountStateExternalLogin, switcher.AccountStateExternalLoginWithRefresh: + return fmt.Sprintf("Active account: %s (%s)\nState: Codex was logged in outside codex-switch; run 'codex-switch sync'", observation.Alias, identity) + case switcher.AccountStateCredentialRefresh: + return fmt.Sprintf("Active account: %s (%s)\nState: Codex credentials were refreshed; run 'codex-switch sync'", observation.Alias, identity) + case switcher.AccountStateStateDrift: + return fmt.Sprintf("Active account: %s (%s)\nState: local account metadata is stale; run 'codex-switch sync'", observation.Alias, identity) + case switcher.AccountStateCredentialConflict: + return fmt.Sprintf("Active account: %s (%s)\nState: credential generations conflict; inspect before using 'codex-switch sync --prefer-live'", observation.Alias, identity) + case switcher.AccountStateUnmanaged: + return fmt.Sprintf("Active account: unmanaged (%s)\nState: run 'codex-switch sync --as ' to preserve it", identity) + case switcher.AccountStateAmbiguous: + return fmt.Sprintf("Active account: ambiguous (%s)\nState: multiple saved profiles match; no automatic changes are allowed", identity) + case switcher.AccountStateLoggedOut: + return "Active account: none\nState: Codex logged out outside codex-switch; run 'codex-switch sync'" + default: + return "Active account: none\nState: in sync" } - if state.ActiveProfileID != "" { - if profile, findErr := data.Find(state.ActiveProfileID); findErr == nil && profile.AccountID == document.Tokens.AccountID { - return toView(*profile, true), nil +} + +func observationNotice(observation switcher.Observation) string { + switch observation.State { + case switcher.AccountStateExternalLogin, switcher.AccountStateExternalLoginWithRefresh: + recorded := observation.RecordedAlias + if recorded == "" { + recorded = "none" } + return fmt.Sprintf("Note: Codex is actually using %s, not the last recorded account %s; run 'codex-switch sync'.", observation.Alias, recorded) + case switcher.AccountStateCredentialRefresh: + return fmt.Sprintf("Note: Codex refreshed %s outside codex-switch; run 'codex-switch sync'.", observation.Alias) + case switcher.AccountStateStateDrift: + return fmt.Sprintf("Note: local state for %s is stale; run 'codex-switch sync'.", observation.Alias) + case switcher.AccountStateCredentialConflict: + return "Warning: live and saved credential generations conflict; run 'codex-switch sync --check'." + case switcher.AccountStateUnmanaged: + return "Note: the active Codex login is unmanaged; run 'codex-switch sync --as ' to preserve it." + case switcher.AccountStateAmbiguous: + return "Warning: multiple saved profiles match the active Codex login; run 'codex-switch sync --check'." + case switcher.AccountStateLoggedOut: + return "Note: Codex is logged out but stale active-account state remains; run 'codex-switch sync'." + default: + return "" } - matches := data.FindByAccount(document.Tokens.AccountID, document.WorkspaceID) - if len(matches) == 1 { - return toView(*matches[0], true), nil - } - return accountView{AccountID: document.Tokens.AccountID, WorkspaceID: document.WorkspaceID, Email: document.Email, Active: true, Alias: "unmanaged"}, nil } func addDocument(manager *vault.Manager, alias, source string, document authschema.Document, replace bool) (vault.Profile, error) { diff --git a/internal/app/usage_test.go b/internal/app/usage_test.go index a652a3a..975a9bf 100644 --- a/internal/app/usage_test.go +++ b/internal/app/usage_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/SilkageNet/codex-switch/internal/codexusage" + "github.com/SilkageNet/codex-switch/internal/switcher" ) func TestSummarizeUsage(t *testing.T) { @@ -31,6 +32,27 @@ func TestSummarizeUsage(t *testing.T) { } } +func TestObservationMessagesExplainExternalLogin(t *testing.T) { + observation := switcher.Observation{ + State: switcher.AccountStateExternalLoginWithRefresh, + Alias: "silkage", + Email: "silkage@example.com", + RecordedAlias: "kun", + } + status := formatObservation(observation) + notice := observationNotice(observation) + for _, expected := range []string{"silkage", "outside codex-switch", "codex-switch sync"} { + if !strings.Contains(status, expected) { + t.Fatalf("status %q does not contain %q", status, expected) + } + } + for _, expected := range []string{"silkage", "kun", "codex-switch sync"} { + if !strings.Contains(notice, expected) { + t.Fatalf("notice %q does not contain %q", notice, expected) + } + } +} + func TestFormatUsageShowsDetailedWindows(t *testing.T) { now := time.Date(2026, 8, 21, 12, 0, 0, 0, time.UTC) duration := int64(300) diff --git a/internal/doctor/doctor.go b/internal/doctor/doctor.go index f0cdcd8..20a9cb3 100644 --- a/internal/doctor/doctor.go +++ b/internal/doctor/doctor.go @@ -11,6 +11,7 @@ import ( "github.com/SilkageNet/codex-switch/internal/codexlogin" appconfig "github.com/SilkageNet/codex-switch/internal/config" "github.com/SilkageNet/codex-switch/internal/process" + "github.com/SilkageNet/codex-switch/internal/switcher" "github.com/SilkageNet/codex-switch/internal/vault" ) @@ -62,6 +63,7 @@ func Run(home codexhome.Home, paths appconfig.Paths, manager *vault.Manager, cod add("active_auth", "error", err.Error()) } + vaultReady := false if data, err := manager.Load(); err != nil { if errors.Is(err, os.ErrNotExist) { add("vault", "warning", "vault is not initialized") @@ -70,6 +72,26 @@ func Run(home codexhome.Home, paths appconfig.Paths, manager *vault.Manager, cod } } else { add("vault", "ok", fmt.Sprintf("%d account profile(s)", len(data.Profiles))) + vaultReady = true + } + + if vaultReady { + observation, err := (switcher.Service{Home: home, Paths: paths, Vault: manager}).Observe() + if err != nil { + add("account_state", "error", err.Error()) + } else { + switch observation.State { + case switcher.AccountStateInSync, switcher.AccountStateNoActive: + add("account_state", "ok", string(observation.State)) + case switcher.AccountStateExternalLogin, switcher.AccountStateExternalLoginWithRefresh, + switcher.AccountStateCredentialRefresh, switcher.AccountStateStateDrift, switcher.AccountStateLoggedOut: + add("account_state", "warning", string(observation.State)+"; run codex-switch sync") + case switcher.AccountStateUnmanaged: + add("account_state", "warning", "unmanaged; run codex-switch sync --as ") + default: + add("account_state", "warning", string(observation.State)+"; run codex-switch sync --check") + } + } } if codexBinaryErr != nil { diff --git a/internal/switcher/observation.go b/internal/switcher/observation.go new file mode 100644 index 0000000..f3602c8 --- /dev/null +++ b/internal/switcher/observation.go @@ -0,0 +1,368 @@ +package switcher + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/SilkageNet/codex-switch/internal/atomicfile" + "github.com/SilkageNet/codex-switch/internal/authschema" + "github.com/SilkageNet/codex-switch/internal/filelock" + appstate "github.com/SilkageNet/codex-switch/internal/state" + "github.com/SilkageNet/codex-switch/internal/vault" +) + +type AccountState string + +const ( + AccountStateInSync AccountState = "in_sync" + AccountStateExternalLogin AccountState = "external_login" + AccountStateCredentialRefresh AccountState = "credential_refresh" + AccountStateExternalLoginWithRefresh AccountState = "external_login_with_refresh" + AccountStateCredentialConflict AccountState = "credential_conflict" + AccountStateStateDrift AccountState = "state_drift" + AccountStateUnmanaged AccountState = "unmanaged" + AccountStateAmbiguous AccountState = "ambiguous" + AccountStateLoggedOut AccountState = "logged_out" + AccountStateNoActive AccountState = "no_active" +) + +type CredentialState string + +const ( + CredentialStateCurrent CredentialState = "current" + CredentialStateLiveNewer CredentialState = "live_newer" + CredentialStateSavedNewer CredentialState = "saved_newer" + CredentialStateAmbiguous CredentialState = "ambiguous" +) + +type Observation struct { + State AccountState `json:"state"` + CredentialState CredentialState `json:"credentialState,omitempty"` + HasLive bool `json:"hasLive"` + Managed bool `json:"managed"` + NeedsSync bool `json:"needsSync"` + ProfileID string `json:"profileId,omitempty"` + Alias string `json:"alias,omitempty"` + AccountID string `json:"accountId,omitempty"` + WorkspaceID string `json:"workspaceId,omitempty"` + Email string `json:"email,omitempty"` + Source string `json:"source,omitempty"` + AuthenticatedAt time.Time `json:"authenticatedAt,omitempty"` + LastUsedAt time.Time `json:"lastUsedAt,omitempty"` + RecordedProfileID string `json:"recordedProfileId,omitempty"` + RecordedAlias string `json:"recordedAlias,omitempty"` +} + +type SyncOptions struct { + Alias string + PreferLive bool +} + +type SyncResult struct { + Changed bool `json:"changed"` + CredentialsUpdated bool `json:"credentialsUpdated"` + StateRepaired bool `json:"stateRepaired"` + Imported bool `json:"imported"` + DetectedState AccountState `json:"detectedState"` + ProfileID string `json:"profileId,omitempty"` + Alias string `json:"alias,omitempty"` +} + +type observationSnapshot struct { + observation Observation + data vault.Data + state appstate.State + live authschema.Document + liveHash string + hasLive bool +} + +func (service Service) Observe() (Observation, error) { + snapshot, err := service.observeUnlocked() + if err != nil { + return Observation{}, err + } + return snapshot.observation, nil +} + +func (service Service) observeUnlocked() (observationSnapshot, error) { + data, err := service.Vault.Load() + if err != nil { + return observationSnapshot{}, err + } + state, err := appstate.Load(service.Paths.State) + if err != nil { + return observationSnapshot{}, err + } + snapshot := observationSnapshot{data: data, state: state, liveHash: "missing"} + raw, err := service.Home.ReadAuth() + if errors.Is(err, os.ErrNotExist) { + snapshot.observation = observationWithoutLive(data, state) + return snapshot, nil + } + if err != nil { + return observationSnapshot{}, err + } + live, err := authschema.Parse(raw) + if err != nil { + return observationSnapshot{}, err + } + snapshot.live = live + snapshot.liveHash = atomicfile.Hash(raw) + snapshot.hasLive = true + snapshot.observation = observeLive(data, state, live, snapshot.liveHash) + return snapshot, nil +} + +func observationWithoutLive(data vault.Data, state appstate.State) Observation { + observation := Observation{State: AccountStateNoActive} + if state.ActiveProfileID == "" { + return observation + } + observation.State = AccountStateLoggedOut + observation.NeedsSync = true + observation.RecordedProfileID = state.ActiveProfileID + if profile, err := data.Find(state.ActiveProfileID); err == nil { + observation.RecordedAlias = profile.Alias + } + return observation +} + +func observeLive(data vault.Data, state appstate.State, live authschema.Document, liveHash string) Observation { + observation := Observation{ + HasLive: true, + NeedsSync: true, + AccountID: live.Tokens.AccountID, + WorkspaceID: live.WorkspaceID, + Email: live.Email, + RecordedProfileID: state.ActiveProfileID, + } + if state.ActiveProfileID != "" { + if profile, err := data.Find(state.ActiveProfileID); err == nil { + observation.RecordedAlias = profile.Alias + } + } + profile, err := identifyCurrent(&data, live) + if err != nil { + observation.State = AccountStateAmbiguous + return observation + } + if profile == nil { + observation.State = AccountStateUnmanaged + return observation + } + + observation.Managed = true + observation.ProfileID = profile.ID + observation.Alias = profile.Alias + observation.Source = profile.Source + observation.AuthenticatedAt = profile.AuthenticatedAt + observation.LastUsedAt = profile.LastUsedAt + if observation.Email == "" { + observation.Email = profile.Email + } + if observation.WorkspaceID == "" { + observation.WorkspaceID = profile.WorkspaceID + } + saved, parseErr := authschema.Parse(profile.Auth) + if parseErr != nil { + observation.State = AccountStateCredentialConflict + observation.CredentialState = CredentialStateAmbiguous + return observation + } + decision, compareErr := authschema.CompareGeneration(saved, live) + switch { + case errors.Is(compareErr, authschema.ErrAmbiguousGeneration): + observation.CredentialState = CredentialStateAmbiguous + observation.State = AccountStateCredentialConflict + return observation + case compareErr != nil: + observation.CredentialState = CredentialStateAmbiguous + observation.State = AccountStateCredentialConflict + return observation + case decision == authschema.GenerationAdoptLive: + observation.CredentialState = CredentialStateLiveNewer + case decision == authschema.GenerationUseSaved: + observation.CredentialState = CredentialStateSavedNewer + observation.State = AccountStateCredentialConflict + return observation + default: + observation.CredentialState = CredentialStateCurrent + } + + externalLogin := state.ActiveProfileID != profile.ID + switch { + case externalLogin && observation.CredentialState == CredentialStateLiveNewer: + observation.State = AccountStateExternalLoginWithRefresh + case externalLogin: + observation.State = AccountStateExternalLogin + case observation.CredentialState == CredentialStateLiveNewer: + observation.State = AccountStateCredentialRefresh + default: + if state.AuthHash != liveHash { + observation.State = AccountStateStateDrift + } else { + observation.State = AccountStateInSync + observation.NeedsSync = false + } + } + return observation +} + +func (service Service) Sync(options SyncOptions) (SyncResult, error) { + lock, err := service.acquireLock() + if err != nil { + return SyncResult{}, err + } + defer func() { _ = lock.Close() }() + if err := service.recoverUnlocked(); err != nil { + return SyncResult{}, err + } + snapshot, err := service.observeUnlocked() + if err != nil { + return SyncResult{}, err + } + result := SyncResult{DetectedState: snapshot.observation.State} + if !snapshot.hasLive { + if options.Alias != "" { + return result, errors.New("codex is not logged in; --as requires an active ChatGPT login") + } + if snapshot.state.ActiveProfileID == "" && snapshot.state.AuthHash == "" { + return result, nil + } + if err := service.ensureLiveHash(snapshot.liveHash); err != nil { + return result, err + } + if err := appstate.Save(service.Paths.State, appstate.State{}); err != nil { + return result, err + } + result.Changed = true + result.StateRepaired = true + return result, nil + } + + if snapshot.observation.State == AccountStateAmbiguous { + return result, errors.New("multiple saved profiles match the active Codex login; no state was changed") + } + if !snapshot.observation.Managed { + if options.Alias == "" { + return result, errors.New("the active Codex login is unmanaged; run 'codex-switch sync --as ' to preserve it") + } + updatedAt, _ := snapshot.live.GenerationTime() + profile := vault.NewProfile(options.Alias, "sync", snapshot.live.Raw, snapshot.live.Tokens.AccountID, snapshot.live.WorkspaceID, snapshot.live.Email, updatedAt) + if err := snapshot.data.Add(profile, false); err != nil { + return result, err + } + if err := service.ensureLiveHash(snapshot.liveHash); err != nil { + return result, err + } + if err := service.Vault.Save(snapshot.data); err != nil { + return result, err + } + if err := service.ensureLiveHash(snapshot.liveHash); err != nil { + return result, err + } + saved, err := snapshot.data.Find(options.Alias) + if err != nil { + return result, err + } + if err := appstate.Save(service.Paths.State, appstate.State{ActiveProfileID: saved.ID, AuthHash: snapshot.liveHash}); err != nil { + return result, err + } + result.Changed = true + result.CredentialsUpdated = true + result.StateRepaired = true + result.Imported = true + result.ProfileID = saved.ID + result.Alias = saved.Alias + return result, nil + } + if options.Alias != "" { + return result, errors.New("--as can only be used when the active Codex login is unmanaged") + } + + profile, err := snapshot.data.Find(snapshot.observation.ProfileID) + if err != nil { + return result, err + } + credentialsUpdated, decision, err := updateProfileFromLive(profile, snapshot.live, options.PreferLive) + if err != nil { + return result, err + } + if decision == authschema.GenerationUseSaved && !options.PreferLive { + return result, errors.New("the saved credentials are newer than the live Codex login; rerun with --prefer-live to replace them") + } + stateRepaired := snapshot.state.ActiveProfileID != profile.ID || snapshot.state.AuthHash != snapshot.liveHash + if !credentialsUpdated && !stateRepaired { + result.ProfileID = profile.ID + result.Alias = profile.Alias + return result, nil + } + if err := service.ensureLiveHash(snapshot.liveHash); err != nil { + return result, err + } + if credentialsUpdated { + if err := service.Vault.Save(snapshot.data); err != nil { + return result, err + } + } + if stateRepaired { + if err := service.ensureLiveHash(snapshot.liveHash); err != nil { + return result, err + } + if err := appstate.Save(service.Paths.State, appstate.State{ActiveProfileID: profile.ID, AuthHash: snapshot.liveHash}); err != nil { + return result, err + } + } + result.Changed = true + result.CredentialsUpdated = credentialsUpdated + result.StateRepaired = stateRepaired + result.ProfileID = profile.ID + result.Alias = profile.Alias + return result, nil +} + +func updateProfileFromLive(profile *vault.Profile, live authschema.Document, preferLive bool) (bool, authschema.GenerationDecision, error) { + saved, err := authschema.Parse(profile.Auth) + if err != nil { + return false, authschema.GenerationSame, fmt.Errorf("saved active account is invalid: %w", err) + } + decision, err := authschema.CompareGeneration(saved, live) + if errors.Is(err, authschema.ErrAmbiguousGeneration) { + if !preferLive { + return false, authschema.GenerationSame, errors.New("the active Codex credentials changed but their generation is ambiguous; rerun 'codex-switch sync --prefer-live' to adopt the live login") + } + decision = authschema.GenerationAdoptLive + } else if err != nil { + return false, authschema.GenerationSame, err + } + shouldAdopt := decision == authschema.GenerationAdoptLive || preferLive && decision == authschema.GenerationUseSaved + if !shouldAdopt { + return false, decision, nil + } + profile.Auth = append([]byte(nil), live.Raw...) + profile.Email = live.Email + profile.WorkspaceID = live.WorkspaceID + if refreshed, ok := live.GenerationTime(); ok { + profile.TokenUpdatedAt = refreshed + } + return true, decision, nil +} + +func (service Service) ensureLiveHash(expected string) error { + actual, err := service.Home.AuthHash() + if err != nil { + return err + } + if actual != expected { + return errors.New("codex credentials changed while account state was being synchronized; active state was not committed, retry") + } + return nil +} + +func (service Service) acquireLock() (*filelock.Lock, error) { + return filelock.Acquire(filepath.Join(service.Home.Path, ".codex-switch.lock")) +} diff --git a/internal/switcher/switcher.go b/internal/switcher/switcher.go index 0774a49..dcc364d 100644 --- a/internal/switcher/switcher.go +++ b/internal/switcher/switcher.go @@ -34,13 +34,15 @@ type Service struct { } type Result struct { - Changed bool `json:"changed"` - ProfileID string `json:"profileId"` - Alias string `json:"alias"` + Changed bool `json:"changed"` + CredentialsUpdated bool `json:"credentialsUpdated"` + StateRepaired bool `json:"stateRepaired"` + ProfileID string `json:"profileId"` + Alias string `json:"alias"` } func (service Service) Use(alias string, allowRunning bool) (Result, error) { - lock, err := filelock.Acquire(filepath.Join(service.Home.Path, ".codex-switch.lock")) + lock, err := service.acquireLock() if err != nil { return Result{}, err } @@ -48,15 +50,11 @@ func (service Service) Use(alias string, allowRunning bool) (Result, error) { if err := service.recoverUnlocked(); err != nil { return Result{}, err } - if err := service.ensureStopped(allowRunning); err != nil { - return Result{}, err - } - - data, err := service.Vault.Load() + snapshot, err := service.observeUnlocked() if err != nil { return Result{}, err } - target, err := data.Find(alias) + target, err := snapshot.data.Find(alias) if err != nil { return Result{}, err } @@ -65,50 +63,60 @@ func (service Service) Use(alias string, allowRunning bool) (Result, error) { return Result{}, fmt.Errorf("target account is invalid: %w", err) } - currentState, err := appstate.Load(service.Paths.State) - if err != nil { - return Result{}, err - } - oldHash, err := service.Home.AuthHash() - if err != nil { - return Result{}, err - } - liveDocument, hasLive, err := service.readLive() - if err != nil { - return Result{}, err - } - if hasLive { - currentProfile, findErr := identifyCurrent(&data, currentState.ActiveProfileID, liveDocument) + credentialsUpdated := false + if snapshot.hasLive { + if snapshot.observation.State == AccountStateAmbiguous { + return Result{}, errors.New("multiple saved profiles match the active Codex login; run 'codex-switch sync --check' for details") + } + if !snapshot.observation.Managed { + return Result{}, errors.New("the active Codex login is not managed; preserve it with 'codex-switch sync --as ' before switching") + } + currentProfile, findErr := snapshot.data.Find(snapshot.observation.ProfileID) if findErr != nil { return Result{}, findErr } - if currentProfile == nil { - return Result{}, errors.New("the active Codex login is not managed; import it with 'codex-switch account import-current ' before switching") - } - if err := reconcileProfile(service.Vault, data, currentProfile, liveDocument); err != nil { + credentialsUpdated, _, err = updateProfileFromLive(currentProfile, snapshot.live, false) + if err != nil { return Result{}, err } if currentProfile.ID == target.ID { - targetDocument, err = authschema.Parse(target.Auth) - if err != nil { - return Result{}, fmt.Errorf("reconciled target account is invalid: %w", err) + stateRepaired := snapshot.state.ActiveProfileID != target.ID || snapshot.state.AuthHash != snapshot.liveHash + if !credentialsUpdated && !stateRepaired { + return Result{Changed: false, ProfileID: target.ID, Alias: target.Alias}, nil + } + if err := service.ensureLiveHash(snapshot.liveHash); err != nil { + return Result{}, err } - if authschema.SameMaterial(targetDocument, liveDocument) { - if err := appstate.Save(service.Paths.State, appstate.State{ActiveProfileID: target.ID, AuthHash: oldHash}); err != nil { + if credentialsUpdated { + if err := service.Vault.Save(snapshot.data); err != nil { + return Result{}, err + } + } + if stateRepaired { + if err := service.ensureLiveHash(snapshot.liveHash); err != nil { + return Result{}, err + } + if err := appstate.Save(service.Paths.State, appstate.State{ActiveProfileID: target.ID, AuthHash: snapshot.liveHash}); err != nil { return Result{}, err } - return Result{Changed: false, ProfileID: target.ID, Alias: target.Alias}, nil } + return Result{Changed: false, CredentialsUpdated: credentialsUpdated, StateRepaired: stateRepaired, ProfileID: target.ID, Alias: target.Alias}, nil } } + if err := service.ensureStopped(allowRunning); err != nil { + return Result{}, err + } + if err := service.ensureLiveHash(snapshot.liveHash); err != nil { + return Result{}, err + } target.LastUsedAt = time.Now().UTC() - if err := service.Vault.Save(data); err != nil { + if err := service.Vault.Save(snapshot.data); err != nil { return Result{}, fmt.Errorf("update target account metadata: %w", err) } publishedBytes := append(append([]byte(nil), targetDocument.Raw...), '\n') newHash := atomicfile.Hash(publishedBytes) - journal := Journal{Version: 1, Operation: "use", TargetProfileID: target.ID, OldHash: oldHash, NewHash: newHash, PreparedAt: time.Now().UTC()} + journal := Journal{Version: 1, Operation: "use", TargetProfileID: target.ID, OldHash: snapshot.liveHash, NewHash: newHash, PreparedAt: time.Now().UTC()} if err := service.writeJournal(journal); err != nil { return Result{}, err } @@ -117,7 +125,7 @@ func (service Service) Use(alias string, allowRunning bool) (Result, error) { if err != nil { return Result{}, err } - if currentHash != oldHash { + if currentHash != snapshot.liveHash { return Result{}, errors.New("codex credentials changed during the switch; no file was replaced, retry after closing Codex") } if err := service.Home.WriteAuth(targetDocument.Raw); err != nil { @@ -136,7 +144,7 @@ func (service Service) Use(alias string, allowRunning bool) (Result, error) { if err := os.Remove(service.Paths.Journal); err != nil && !errors.Is(err, os.ErrNotExist) { return Result{}, fmt.Errorf("auth switched but journal cleanup failed: %w", err) } - return Result{Changed: true, ProfileID: target.ID, Alias: target.Alias}, nil + return Result{Changed: true, CredentialsUpdated: credentialsUpdated, StateRepaired: snapshot.state.ActiveProfileID != target.ID, ProfileID: target.ID, Alias: target.Alias}, nil } func (service Service) Deactivate(allowRunning bool) error { @@ -155,21 +163,17 @@ func (service Service) Deactivate(allowRunning bool) error { if err != nil { return err } - currentState, err := appstate.Load(service.Paths.State) - if err != nil { - return err - } liveDocument, hasLive, err := service.readLive() if err != nil { return err } if hasLive { - currentProfile, findErr := identifyCurrent(&data, currentState.ActiveProfileID, liveDocument) + currentProfile, findErr := identifyCurrent(&data, liveDocument) if findErr != nil { return findErr } if currentProfile == nil { - return errors.New("the active Codex login is not managed; import it before deactivating") + return errors.New("the active Codex login is not managed; preserve it with 'codex-switch sync --as ' before deactivating") } if err := reconcileProfile(service.Vault, data, currentProfile, liveDocument); err != nil { return err @@ -273,49 +277,43 @@ func (service Service) readLive() (authschema.Document, bool, error) { return document, err == nil, err } -func identifyCurrent(data *vault.Data, activeID string, live authschema.Document) (*vault.Profile, error) { - if activeID != "" { - profile, err := data.Find(activeID) - if err == nil && profile.AccountID == live.Tokens.AccountID { - return profile, nil +func identifyCurrent(data *vault.Data, live authschema.Document) (*vault.Profile, error) { + accountMatches := data.FindByAccount(live.Tokens.AccountID, "") + exactMatches := make([]*vault.Profile, 0, len(accountMatches)) + for _, profile := range accountMatches { + document, err := authschema.Parse(profile.Auth) + if err == nil && authschema.SameMaterial(document, live) { + exactMatches = append(exactMatches, profile) + } + } + if len(exactMatches) == 1 { + return exactMatches[0], nil + } + if len(exactMatches) > 1 { + return nil, errors.New("multiple managed profiles exactly match the active ChatGPT credentials") + } + matches := make([]*vault.Profile, 0, len(accountMatches)) + for _, profile := range accountMatches { + if live.WorkspaceID == "" || profile.WorkspaceID == "" || profile.WorkspaceID == live.WorkspaceID { + matches = append(matches, profile) } } - matches := data.FindByAccount(live.Tokens.AccountID, live.WorkspaceID) if len(matches) == 1 { return matches[0], nil } if len(matches) > 1 { - for _, profile := range matches { - document, err := authschema.Parse(profile.Auth) - if err == nil && authschema.SameMaterial(document, live) { - return profile, nil - } - } - return nil, errors.New("multiple managed profiles match the active ChatGPT account; select one by switching after deactivating the unmanaged login") + return nil, errors.New("multiple managed profiles match the active ChatGPT account; no profile can be selected safely") } return nil, nil } func reconcileProfile(manager *vault.Manager, data vault.Data, profile *vault.Profile, live authschema.Document) error { - saved, err := authschema.Parse(profile.Auth) - if err != nil { - return fmt.Errorf("saved active account is invalid: %w", err) - } - decision, err := authschema.CompareGeneration(saved, live) + updated, _, err := updateProfileFromLive(profile, live, false) if err != nil { - if errors.Is(err, authschema.ErrAmbiguousGeneration) { - return errors.New("the active Codex refresh token changed but its generation cannot be ordered safely; reauthenticate this profile before switching") - } return err } - if decision != authschema.GenerationAdoptLive { + if !updated { return nil } - profile.Auth = append(json.RawMessage(nil), live.Raw...) - profile.Email = live.Email - profile.WorkspaceID = live.WorkspaceID - if refreshed, ok := live.GenerationTime(); ok { - profile.TokenUpdatedAt = refreshed - } return manager.Save(data) } diff --git a/internal/switcher/switcher_test.go b/internal/switcher/switcher_test.go index 6ed13fb..d350986 100644 --- a/internal/switcher/switcher_test.go +++ b/internal/switcher/switcher_test.go @@ -2,6 +2,7 @@ package switcher import ( "encoding/json" + "errors" "os" "path/filepath" "testing" @@ -15,6 +16,292 @@ import ( "github.com/SilkageNet/codex-switch/internal/vault" ) +func TestObserveUsesLiveAccountInsteadOfRecordedState(t *testing.T) { + service, manager, data := testService(t) + kun := profileFor(t, "kun", "account-kun", "refresh-kun", "2026-08-20T00:00:00Z") + silkage := profileFor(t, "silkage", "account-silkage", "refresh-silkage", "2026-08-20T00:00:00Z") + if err := data.Add(kun, false); err != nil { + t.Fatal(err) + } + if err := data.Add(silkage, false); err != nil { + t.Fatal(err) + } + if err := manager.Save(data); err != nil { + t.Fatal(err) + } + savedKun, _ := data.Find("kun") + savedSilkage, _ := data.Find("silkage") + if err := service.Home.WriteAuth(savedSilkage.Auth); err != nil { + t.Fatal(err) + } + if err := appstate.Save(service.Paths.State, appstate.State{ActiveProfileID: savedKun.ID, AuthHash: "stale"}); err != nil { + t.Fatal(err) + } + + observation, err := service.Observe() + if err != nil { + t.Fatal(err) + } + if observation.ProfileID != savedSilkage.ID || observation.Alias != "silkage" || observation.State != AccountStateExternalLogin { + t.Fatalf("unexpected observation: %#v", observation) + } + if observation.RecordedProfileID != savedKun.ID || observation.RecordedAlias != "kun" || !observation.NeedsSync { + t.Fatalf("stale recorded state was not reported: %#v", observation) + } + state, err := appstate.Load(service.Paths.State) + if err != nil { + t.Fatal(err) + } + if state.ActiveProfileID != savedKun.ID { + t.Fatal("read-only observation modified recorded state") + } +} + +func TestSyncRepairsExternalLoginAndAdoptsNewerCredentials(t *testing.T) { + service, manager, data := testService(t) + kun := profileFor(t, "kun", "account-kun", "refresh-kun", "2026-08-20T00:00:00Z") + silkage := profileFor(t, "silkage", "account-silkage", "refresh-old", "2026-08-20T00:00:00Z") + if err := data.Add(kun, false); err != nil { + t.Fatal(err) + } + if err := data.Add(silkage, false); err != nil { + t.Fatal(err) + } + if err := manager.Save(data); err != nil { + t.Fatal(err) + } + savedKun, _ := data.Find("kun") + savedSilkage, _ := data.Find("silkage") + live := authBytes("account-silkage", "refresh-new", "2026-08-20T01:00:00Z") + if err := service.Home.WriteAuth(live); err != nil { + t.Fatal(err) + } + if err := appstate.Save(service.Paths.State, appstate.State{ActiveProfileID: savedKun.ID, AuthHash: "stale"}); err != nil { + t.Fatal(err) + } + + result, err := service.Sync(SyncOptions{}) + if err != nil { + t.Fatal(err) + } + if !result.Changed || !result.CredentialsUpdated || !result.StateRepaired || result.ProfileID != savedSilkage.ID { + t.Fatalf("unexpected sync result: %#v", result) + } + loaded, err := manager.Load() + if err != nil { + t.Fatal(err) + } + updated, _ := loaded.Find("silkage") + document, _ := authschema.Parse(updated.Auth) + if document.Tokens.RefreshToken != "refresh-new" { + t.Fatal("newer live credentials were not saved") + } + state, err := appstate.Load(service.Paths.State) + if err != nil { + t.Fatal(err) + } + liveHash, _ := service.Home.AuthHash() + if state.ActiveProfileID != savedSilkage.ID || state.AuthHash != liveHash { + t.Fatalf("active state was not repaired: %#v", state) + } +} + +func TestSyncRequiresExplicitPreferenceForAmbiguousCredentials(t *testing.T) { + service, manager, data := testService(t) + profile := profileFor(t, "silkage", "account-silkage", "refresh-old", "2026-08-20T00:00:00Z") + if err := data.Add(profile, false); err != nil { + t.Fatal(err) + } + if err := manager.Save(data); err != nil { + t.Fatal(err) + } + live := authBytes("account-silkage", "refresh-new", "2026-08-20T00:00:00Z") + if err := service.Home.WriteAuth(live); err != nil { + t.Fatal(err) + } + if _, err := service.Sync(SyncOptions{}); err == nil { + t.Fatal("expected ambiguous credential protection") + } + loaded, _ := manager.Load() + saved, _ := loaded.Find("silkage") + savedDocument, _ := authschema.Parse(saved.Auth) + if savedDocument.Tokens.RefreshToken != "refresh-old" { + t.Fatal("ambiguous credentials were overwritten without consent") + } + + result, err := service.Sync(SyncOptions{PreferLive: true}) + if err != nil { + t.Fatal(err) + } + if !result.CredentialsUpdated { + t.Fatalf("live credentials were not adopted: %#v", result) + } +} + +func TestSyncImportsUnmanagedLiveLogin(t *testing.T) { + service, manager, _ := testService(t) + if err := service.Home.WriteAuth(authBytes("account-new", "refresh-new", "2026-08-20T00:00:00Z")); err != nil { + t.Fatal(err) + } + if _, err := service.Sync(SyncOptions{}); err == nil { + t.Fatal("expected unmanaged login protection") + } + result, err := service.Sync(SyncOptions{Alias: "new"}) + if err != nil { + t.Fatal(err) + } + if !result.Imported || !result.CredentialsUpdated || result.Alias != "new" { + t.Fatalf("unexpected import result: %#v", result) + } + loaded, _ := manager.Load() + if _, err := loaded.Find("new"); err != nil { + t.Fatal(err) + } +} + +func TestUseRepairsExternallyActivatedTargetWithoutProjection(t *testing.T) { + service, manager, data := testService(t) + kun := profileFor(t, "kun", "account-kun", "refresh-kun", "2026-08-20T00:00:00Z") + silkage := profileFor(t, "silkage", "account-silkage", "refresh-silkage", "2026-08-20T00:00:00Z") + if err := data.Add(kun, false); err != nil { + t.Fatal(err) + } + if err := data.Add(silkage, false); err != nil { + t.Fatal(err) + } + if err := manager.Save(data); err != nil { + t.Fatal(err) + } + savedKun, _ := data.Find("kun") + savedSilkage, _ := data.Find("silkage") + if err := service.Home.WriteAuth(savedSilkage.Auth); err != nil { + t.Fatal(err) + } + if err := appstate.Save(service.Paths.State, appstate.State{ActiveProfileID: savedKun.ID, AuthHash: "stale"}); err != nil { + t.Fatal(err) + } + + result, err := service.Use("silkage", false) + if err != nil { + t.Fatal(err) + } + if result.Changed || !result.StateRepaired { + t.Fatalf("unexpected use result: %#v", result) + } + live, _ := service.Home.ReadAuth() + document, _ := authschema.Parse(live) + if document.Tokens.AccountID != "account-silkage" || document.Tokens.RefreshToken != "refresh-silkage" { + t.Fatal("already-active credentials were re-projected") + } +} + +func TestObserveRejectsDuplicateIdentityWithoutExactCredentialMatch(t *testing.T) { + service, manager, data := testService(t) + for _, alias := range []string{"first", "second"} { + if err := data.Add(profileFor(t, alias, "account-a", "refresh-"+alias, "2026-08-20T00:00:00Z"), false); err != nil { + t.Fatal(err) + } + } + if err := manager.Save(data); err != nil { + t.Fatal(err) + } + if err := service.Home.WriteAuth(authBytes("account-a", "refresh-live", "2026-08-20T01:00:00Z")); err != nil { + t.Fatal(err) + } + observation, err := service.Observe() + if err != nil { + t.Fatal(err) + } + if observation.State != AccountStateAmbiguous || observation.Managed { + t.Fatalf("duplicate identity was not treated as ambiguous: %#v", observation) + } + if _, err := service.Sync(SyncOptions{}); err == nil || errors.Is(err, os.ErrNotExist) { + t.Fatalf("unexpected ambiguous sync error: %v", err) + } +} + +func TestIdentifyCurrentUsesWorkspaceWhenAccountHasMultipleProfiles(t *testing.T) { + _, _, data := testService(t) + first := profileFor(t, "first", "account-a", "refresh-first", "2026-08-20T00:00:00Z") + first.WorkspaceID = "workspace-a" + second := profileFor(t, "second", "account-a", "refresh-second", "2026-08-20T00:00:00Z") + second.WorkspaceID = "workspace-b" + if err := data.Add(first, false); err != nil { + t.Fatal(err) + } + if err := data.Add(second, false); err != nil { + t.Fatal(err) + } + live, err := authschema.Parse(authBytes("account-a", "refresh-live", "2026-08-20T01:00:00Z")) + if err != nil { + t.Fatal(err) + } + live.WorkspaceID = "workspace-b" + profile, err := identifyCurrent(&data, live) + if err != nil { + t.Fatal(err) + } + if profile == nil || profile.Alias != "second" { + t.Fatalf("workspace did not disambiguate account profiles: %#v", profile) + } +} + +func TestSyncRequiresPreferenceBeforeReplacingNewerSavedCredentials(t *testing.T) { + service, manager, data := testService(t) + profile := profileFor(t, "silkage", "account-silkage", "refresh-newer", "2026-08-20T01:00:00Z") + if err := data.Add(profile, false); err != nil { + t.Fatal(err) + } + if err := manager.Save(data); err != nil { + t.Fatal(err) + } + if err := service.Home.WriteAuth(authBytes("account-silkage", "refresh-live", "2026-08-20T00:00:00Z")); err != nil { + t.Fatal(err) + } + if _, err := service.Sync(SyncOptions{}); err == nil { + t.Fatal("expected newer saved credential protection") + } + loaded, _ := manager.Load() + saved, _ := loaded.Find("silkage") + savedDocument, _ := authschema.Parse(saved.Auth) + if savedDocument.Tokens.RefreshToken != "refresh-newer" { + t.Fatal("newer saved credentials were replaced without consent") + } + result, err := service.Sync(SyncOptions{PreferLive: true}) + if err != nil { + t.Fatal(err) + } + if !result.CredentialsUpdated { + t.Fatalf("explicit live preference was not applied: %#v", result) + } +} + +func TestSyncClearsRecordedAccountAfterExternalLogout(t *testing.T) { + service, manager, data := testService(t) + profile := profileFor(t, "silkage", "account-silkage", "refresh", "2026-08-20T00:00:00Z") + if err := data.Add(profile, false); err != nil { + t.Fatal(err) + } + if err := manager.Save(data); err != nil { + t.Fatal(err) + } + saved, _ := data.Find("silkage") + if err := appstate.Save(service.Paths.State, appstate.State{ActiveProfileID: saved.ID, AuthHash: "old"}); err != nil { + t.Fatal(err) + } + result, err := service.Sync(SyncOptions{}) + if err != nil { + t.Fatal(err) + } + if !result.StateRepaired || result.DetectedState != AccountStateLoggedOut { + t.Fatalf("unexpected logout sync result: %#v", result) + } + state, _ := appstate.Load(service.Paths.State) + if state.ActiveProfileID != "" || state.AuthHash != "" { + t.Fatalf("stale state was not cleared: %#v", state) + } +} + func TestUseAdoptsRotatedLiveTokenAndSwitches(t *testing.T) { service, manager, data := testService(t) oldTime := "2026-08-20T00:00:00Z"