diff --git a/README.md b/README.md index e6ea2764..f7572e7f 100644 --- a/README.md +++ b/README.md @@ -1,142 +1,164 @@ # CodexMarathon -Keep your Codex work moving across multiple accounts. +CodexMarathon adds multi-account Marathon controls directly to a custom build +of the Codex CLI. It is a single `codex` executable: users do not need to run +a separate controller or set a Marathon socket environment variable. -CodexMarathon is a companion CLI for the Codex CLI already installed on your -computer. It keeps account profiles, watches usage, and safely moves a long -conversation to another account when the current account reaches a limit. +It stores managed account profiles, lets you sign in to more than one ChatGPT +account, and switches only at a safe boundary between turns. Credential +snapshots are opaque, protected by Codex's configured credential storage, and +never appear in status output, logs, or transition records. -## Why use it? +## Native workflow -- Keep work, personal, and project accounts in one place. -- Switch accounts without manually copying credential files. -- Continue the same conversation after a usage limit. -- Choose an account yourself or let the usage policy choose one. -- Keep your existing Codex version, settings, plugins, and sessions. -- See account health and transition status without exposing tokens. -- Install a small companion beside Codex. - -## Example - -You use one account for work and another for personal projects: +Use Marathon from the shell: ```bash -codexmarathon accounts list -# work Work account -# personal Personal account - -codexmarathon run --codex codex --codex-dir ~/projects/website +codex marathon status +codex marathon login personal --device-code +codex marathon login work --device-code +codex marathon on +codex marathon switch work ``` -If the active account reaches its limit, CodexMarathon can select the other -eligible account and continue the same conversation. You can also switch -manually: +Or use it from an interactive Codex session: -```bash -codexmarathon switch work +```text +/marathon +/marathon login personal +/marathon login work +/marathon on +/marathon switch work ``` -The change happens at a safe point in the current turn. CodexMarathon uses a -live account reload when the installed Codex supports it. Otherwise, it -cleanly restarts a process it launched and resumes the same thread. - -## Install +Running `/marathon` displays the current native state and a help panel. It +also explains the available account, login, enabled-state, and switching +commands. -Install Codex first and check that it works: +## Sign in on a server -```bash -codex --version -``` +`login` supports a browser-link flow and a device-code flow. -Download the latest archive from the -[GitHub releases page](https://github.com/Lem0nTree/codexmarathon/releases), -then install it: +For a headless machine such as an AWS instance, use a device code: ```bash -tar -xzf codexmarathon--linux-.tar.gz -cd codexmarathon--linux- -install -m 0755 codexmarathon "$HOME/.local/bin/codexmarathon" -codexmarathon init +codex marathon login work --device-code ``` -Use `linux-x86_64` for amd64 machines and `linux-aarch64` for ARM64 machines. -CodexMarathon works alongside your existing Codex installation. +Codex prints a verification URL and one-time code. Open the URL on your own +machine, enter the code, and complete the ChatGPT sign-in. Once Codex receives +the native login completion, it stores that identity under the `work` alias. -## Quick start +In an interactive Codex session, use: -```bash -codexmarathon status --json -codexmarathon accounts list -codexmarathon run --codex codex +```text +/marathon login work ``` -If Codex is outside `PATH`, pass its full path: +Codex presents **Browser link** and **Device / auth code** choices. You can +also choose explicitly: -```bash -codexmarathon run --codex "$HOME/.local/bin/codex" +```text +/marathon login work browser +/marathon login work device-code ``` -Use `--codex-dir` for a project and `--codex-home` for a non-default Codex -home. Use `--attach-only` when you want to connect to an existing compatible -Codex session instead of launching one. +For a local browser flow from the shell, omit the flag and choose the prompt, +or pass `--browser` explicitly. ## Commands ```text -codexmarathon init -codexmarathon status [--json] -codexmarathon run [options] -codexmarathon accounts list -codexmarathon accounts status [account-id] -codexmarathon accounts login [options] -codexmarathon accounts activate -codexmarathon accounts use -codexmarathon accounts rename --name -codexmarathon accounts remove -codexmarathon switch +codex marathon status +codex marathon accounts +codex marathon on +codex marathon off +codex marathon import +codex marathon login [--browser | --device-code] +codex marathon switch ``` -Use `codexmarathon accounts list` to find account IDs. Login and refresh use -the authentication setup available in your Codex installation; the detailed -options are in [Features and usage](docs/features-and-usage.md). +`import` saves the identity that is already active in Codex. `login` uses the +normal native Codex ChatGPT login flow and imports the completed account under +the alias automatically. `switch` resolves an alias or account ID and changes +the active identity only when no turn is running. -## Safe handoff +The matching interactive commands are: + +```text +/marathon +/marathon status +/marathon on +/marathon off +/marathon import +/marathon login [browser|device-code] +/marathon switch +``` -CodexMarathon waits for a safe turn boundary before changing accounts. It -reloads credentials through Codex when possible. If a restart is needed, it -only controls a Codex process started by `codexmarathon run`, deploys the next -profile atomically, resumes the same conversation, and verifies the target -account. It never interrupts an unrelated Codex process or creates a duplicate -resume prompt. +Configure the status line with the `marathon` and `marathon-accounts` items. +They display whether native Marathon is enabled and the number of managed +accounts. -## Security +## Safety model -Credential snapshots are treated as private opaque data. They are kept in the -companion's protected state, excluded from logs and release files, and never -printed by status commands. Account metadata and transition records contain no -access or refresh tokens. +- Only managed ChatGPT authentication profiles are switchable initially. +- A switch waits until all account-bound work is idle; it never changes an + active model response, tool call, or subagent turn. +- The source identity is checked again before a transition. The target profile + is validated and persisted atomically before Codex updates its in-memory + credentials. +- A failed or uncertain transition is recorded as such and requires + reconciliation; Marathon never guesses that a credential change succeeded. +- API keys, external bearer authentication, workload identity, and other + unsupported provider modes are rejected for Marathon switching. -## Build and test +## Quota reset actions + +The native runtime includes a mock-tested capability and scheduler for a +provider-supported quota-reset action. Its intended behavior is to use a real +provider reset action only after every eligible managed account has reached +zero weekly quota, then verify the refreshed quota before selecting that +account. + +No real reset action is called by this project during development, tests, or +the current build. Production execution remains disabled until a provider +integration exposes a documented reset capability and is explicitly enabled. + +## Build + +The custom CLI is built from the Codex Rust workspace: + +```bash +cd runtime/codex-rs +cargo build --release -p codex-cli +``` + +The resulting executable is: + +```text +runtime/codex-rs/target/release/codex +``` + +Run focused checks while developing: ```bash -bash scripts/build-release.sh -(cd controller && go test ./...) -(cd controller && go vet ./...) -(cd integration && go test ./...) -python3 scripts/test_package_release.py +cargo check -p codexmarathon-runtime +cargo check -p codex-login +cargo check -p codex-app-server +cargo check -p codex-tui +cargo check -p codex-cli ``` -See [Build and verification](docs/build-test.md) for the complete check list. +## Legacy companion + +Earlier repository versions used a separate Go `codexmarathon` controller. +It remains in the source tree for compatibility and migration work, but the +supported user workflow is the native `codex marathon` and `/marathon` +interface described above. ## Documentation - [Features and usage](docs/features-and-usage.md) -- [Companion design](docs/companion-cli.md) - [Architecture](docs/architecture.md) - [Build and verification](docs/build-test.md) -- [Release checklist](docs/release.md) -- [Project plan](PLAN.md) - -CodexMarathon-specific code is provided in this repository. Imported -Codex-derived source and its notices are documented in -[runtime provenance](runtime/PROVENANCE.md). +- [Runtime provenance](runtime/PROVENANCE.md) diff --git a/controller/app/control.go b/controller/app/control.go new file mode 100644 index 00000000..c1661603 --- /dev/null +++ b/controller/app/control.go @@ -0,0 +1,597 @@ +package app + +// This file exposes the controller-owned local control boundary used by an +// embedded Codex TUI. The TUI never receives credential bytes and never +// writes auth.json. It sends an account target to this server; the +// controller resolves aliases and delegates the complete transition to the +// existing coordinator (or to the installed-Codex session callback). + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/url" + "path/filepath" + goruntime "runtime" + "strings" + "sync" + "time" + + "codexmarathon/controller/internal/runtime" + "codexmarathon/controller/internal/transitions" +) + +const ( + // ControllerProtocolVersion is the local TUI/controller protocol version. + // It is deliberately separate from the controller/runtime protocol: the + // controller remains the only authority allowed to deploy credentials. + ControllerProtocolVersion = 1 + + // ControllerControlEnv names the optional endpoint hint inherited by a + // Codex process launched by the companion. A client may also derive the + // default endpoint from the controller state directory. + ControllerControlEnv = "CODEXMARATHON_CONTROL" + + ControllerMethodNegotiate = "protocol/negotiate" + ControllerMethodStatus = "marathon/status" + ControllerMethodSwitch = "marathon/switch" + + defaultControlMaxLineBytes = 4 << 20 + defaultControlSwitchWait = 2 * time.Minute +) + +var ( + ErrControlNotStarted = errors.New("controller control server is not started") + ErrControlAlreadyStarted = errors.New("controller control server is already started") + ErrControlProtocol = errors.New("invalid controller control protocol message") + ErrControlMethod = errors.New("unknown controller control method") + ErrControlNotNegotiated = errors.New("controller control protocol is not negotiated") + ErrControlTargetRequired = errors.New("account ID or alias is required") + ErrControlAlreadyRunning = errors.New("controller control endpoint is already in use") + ErrUnsafeControlEndpoint = errors.New("controller control endpoint is not user-scoped") + ErrControlUnsupportedOS = errors.New("controller control endpoint is unsupported on this platform") +) + +// DefaultControllerControlEndpoint returns the endpoint used by the +// companion-owned control server. It lives beside the runtime socket but has +// a separate filename and authority. +func DefaultControllerControlEndpoint(stateDir string) (string, error) { + if strings.TrimSpace(stateDir) == "" { + return "", errors.New("state directory is empty") + } + stateDir, err := filepath.Abs(stateDir) + if err != nil { + return "", fmt.Errorf("resolve state directory: %w", err) + } + if goruntime.GOOS == "windows" { + return "pipe://./pipe/" + userScopedPipeName() + "-control", nil + } + return "unix://" + filepath.Join(stateDir, "controller.sock"), nil +} + +// ValidateControllerControlEndpoint keeps the controller listener inside the +// same user-owned boundary as the runtime listener. TCP is rejected because a +// TUI request can initiate credential deployment. +func ValidateControllerControlEndpoint(endpoint, stateDir string) error { + if strings.TrimSpace(endpoint) == "" { + return fmt.Errorf("%w: endpoint is empty", ErrUnsafeControlEndpoint) + } + if goruntime.GOOS == "windows" { + parsed, err := url.Parse(endpoint) + if err != nil || (strings.ToLower(parsed.Scheme) != "pipe" && strings.ToLower(parsed.Scheme) != "npipe" && strings.ToLower(parsed.Scheme) != "namedpipe") { + return fmt.Errorf("%w: controller endpoint must be a named pipe", ErrUnsafeControlEndpoint) + } + expected := `\\.\pipe\` + userScopedPipeName() + "-control" + if namedPipePath(parsed) != expected { + return fmt.Errorf("%w: named pipe must use the current user's controller endpoint", ErrUnsafeControlEndpoint) + } + return nil + } + if err := ValidateManagedEndpoint(endpoint, stateDir); err != nil { + return fmt.Errorf("%w: %v", ErrUnsafeControlEndpoint, err) + } + return nil +} + +// ControlSwitchResult is a secret-free result suitable for a TUI status +// surface. Mode is "runtime", "live-reload", or "restart-resume". +// TransitionID is present for the Marathon runtime coordinator path and lets +// a caller reconcile an uncertain result later. +type ControlSwitchResult struct { + AccountID string `json:"account_id"` + Alias string `json:"alias,omitempty"` + Outcome string `json:"outcome"` + Mode string `json:"mode,omitempty"` + TransitionID string `json:"transition_id,omitempty"` + RuntimeID string `json:"runtime_id,omitempty"` + ExpectedGeneration uint64 `json:"expected_generation,omitempty"` + FinalGeneration uint64 `json:"final_generation,omitempty"` + IdentityVerified bool `json:"identity_verified"` + Reason string `json:"reason,omitempty"` +} + +// ControlAccount is the non-secret account view exposed to the TUI. +type ControlAccount struct { + AccountID string `json:"account_id"` + Alias string `json:"alias,omitempty"` + Active bool `json:"active"` + CredentialPresent bool `json:"credential_present"` + CredentialHealth string `json:"credential_health,omitempty"` + LastTelemetryAt *time.Time `json:"last_telemetry_at,omitempty"` + TelemetrySource string `json:"telemetry_source,omitempty"` +} + +// ControlRuntimeStatus reports only runtime identity and transition metadata. +// It intentionally has no auth snapshot or provider token fields. +type ControlRuntimeStatus struct { + Connected bool `json:"connected"` + RuntimeID string `json:"runtime_id,omitempty"` + AccountID string `json:"account_id,omitempty"` + AuthGeneration uint64 `json:"auth_generation,omitempty"` + ActiveTurnCount int `json:"active_turn_count"` + PendingTransition *runtime.PendingTransition `json:"pending_transition,omitempty"` +} + +// ControlStatus is the status response consumed by a future /marathon UI. +// Runtime is nil when the controller is running in installed-Codex mode and +// no Marathon runtime protocol peer is attached. +type ControlStatus struct { + ProtocolVersion int `json:"protocol_version"` + Endpoint string `json:"endpoint"` + RuntimeConnected bool `json:"runtime_connected"` + ActiveAccountID string `json:"active_account_id,omitempty"` + Accounts []ControlAccount `json:"accounts"` + Runtime *ControlRuntimeStatus `json:"runtime,omitempty"` + Transition *transitions.State `json:"transition,omitempty"` +} + +// ControlSwitchHandler lets the installed-Codex companion reuse the same +// local API while retaining its process ownership and restart/resume logic. +// The handler receives a canonical account ID, never an untrusted alias. +type ControlSwitchHandler func(context.Context, string) (ControlSwitchResult, error) + +// ControlStatusHandler can fill the runtime portion of status for an +// installed app-server session. It is optional; the Marathon runtime path +// derives status from Controller.RuntimeState automatically. +type ControlStatusHandler func(context.Context) (*ControlRuntimeStatus, error) + +// ControllerControlServerConfig configures the local TUI/controller server. +type ControllerControlServerConfig struct { + Endpoint string + Switch ControlSwitchHandler + Status ControlStatusHandler + MaxLineBytes int +} + +// ControllerControlServer is a user-scoped newline-delimited JSON-RPC +// endpoint. The listener is intentionally separate from the runtime socket: +// only this server can resolve aliases and initiate a controller transition. +type ControllerControlServer struct { + controller *Controller + config ControllerControlServerConfig + endpoint string + + mu sync.Mutex + listener net.Listener + cancel context.CancelFunc + done chan struct{} + closeOnce sync.Once + connWG sync.WaitGroup + closeErr error +} + +// NewControllerControlServer validates and composes a server without opening +// a socket. Start owns the listener side effect. +func NewControllerControlServer(controller *Controller, config ControllerControlServerConfig) (*ControllerControlServer, error) { + if controller == nil { + return nil, errors.New("controller is nil") + } + if strings.TrimSpace(config.Endpoint) == "" { + endpoint, err := DefaultControllerControlEndpoint(controller.Config().StateDir) + if err != nil { + return nil, err + } + config.Endpoint = endpoint + } + if err := ValidateControllerControlEndpoint(config.Endpoint, controller.Config().StateDir); err != nil { + return nil, err + } + if config.MaxLineBytes <= 0 { + config.MaxLineBytes = defaultControlMaxLineBytes + } + return &ControllerControlServer{ + controller: controller, + config: config, + endpoint: config.Endpoint, + }, nil +} + +// Endpoint returns the configured user-scoped endpoint. +func (s *ControllerControlServer) Endpoint() string { + if s == nil { + return "" + } + return s.endpoint +} + +// Start opens the listener and starts accepting TUI requests. Connections +// are handled independently, while the transition coordinator still rejects +// competing state-changing requests with its durable in-progress guard. +func (s *ControllerControlServer) Start(ctx context.Context) error { + if s == nil { + return errors.New("nil controller control server") + } + if ctx == nil { + ctx = context.Background() + } + if err := ctx.Err(); err != nil { + return err + } + s.mu.Lock() + if s.listener != nil { + s.mu.Unlock() + return ErrControlAlreadyStarted + } + listener, socketPath, err := listenControllerControlEndpoint(s.endpoint, s.controller.Config().StateDir) + if err != nil { + s.mu.Unlock() + return err + } + serverCtx, cancel := context.WithCancel(ctx) + s.listener = listener + s.cancel = cancel + s.done = make(chan struct{}) + s.mu.Unlock() + + go s.acceptLoop(serverCtx, socketPath) + return nil +} + +// Close stops the listener and waits for in-flight requests. It is safe to +// call more than once and removes only the socket created by this server. +func (s *ControllerControlServer) Close() error { + if s == nil { + return nil + } + s.closeOnce.Do(func() { + s.mu.Lock() + listener := s.listener + cancel := s.cancel + done := s.done + s.mu.Unlock() + if cancel != nil { + cancel() + } + if listener != nil { + _ = listener.Close() + } + if done != nil { + <-done + } + s.connWG.Wait() + s.mu.Lock() + s.closeErr = removeControllerControlEndpoint(s.endpoint) + s.mu.Unlock() + }) + s.mu.Lock() + err := s.closeErr + s.mu.Unlock() + return err +} + +func (s *ControllerControlServer) acceptLoop(ctx context.Context, socketPath string) { + defer func() { + s.mu.Lock() + done := s.done + s.mu.Unlock() + if done != nil { + close(done) + } + _ = socketPath // retained for platform-specific listener cleanup. + }() + s.mu.Lock() + listener := s.listener + s.mu.Unlock() + for { + conn, err := listener.Accept() + if err != nil { + if ctx.Err() != nil || errors.Is(err, net.ErrClosed) { + return + } + continue + } + s.connWG.Add(1) + go func() { + defer s.connWG.Done() + _ = s.serveConn(ctx, conn) + }() + } +} + +func (s *ControllerControlServer) serveConn(ctx context.Context, conn net.Conn) error { + defer conn.Close() + reader := bufio.NewReader(conn) + negotiated := false + for { + line, err := readControlLine(reader, s.config.MaxLineBytes) + if len(line) > 0 { + response := s.handleLine(ctx, line, &negotiated) + if _, writeErr := conn.Write(response); writeErr != nil { + return writeErr + } + } + if err != nil { + if errors.Is(err, io.EOF) { + return nil + } + return err + } + } +} + +func readControlLine(reader *bufio.Reader, maxBytes int) ([]byte, error) { + line, err := reader.ReadBytes('\n') + if len(line) > maxBytes { + return nil, fmt.Errorf("control frame exceeds %d bytes", maxBytes) + } + return bytesTrimSpace(line), err +} + +// bytesTrimSpace avoids retaining a bufio.Reader buffer in the request and +// accepts the final JSON object without requiring a trailing newline. +func bytesTrimSpace(value []byte) []byte { + return []byte(strings.TrimSpace(string(value))) +} + +type controlRequest struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params json.RawMessage `json:"params"` +} + +type controlResponse struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Result any `json:"result,omitempty"` + Error *controlError `json:"error,omitempty"` +} + +type controlError struct { + Code int `json:"code"` + Message string `json:"message"` + Data any `json:"data,omitempty"` +} + +type controlNegotiateParams struct { + SupportedVersions []int `json:"supported_versions"` +} + +type controlNegotiateResult struct { + ProtocolVersion int `json:"protocol_version"` + ServerVersions []int `json:"server_versions"` +} + +type controlSwitchParams struct { + Target string `json:"target"` + TimeoutMS int `json:"timeout_ms,omitempty"` +} + +func (s *ControllerControlServer) handleLine(ctx context.Context, line []byte, negotiated *bool) []byte { + request := controlRequest{} + if err := json.Unmarshal(line, &request); err != nil { + return marshalControlResponse(nil, nil, &controlError{Code: -32700, Message: ErrControlProtocol.Error()}) + } + if request.JSONRPC != "2.0" || len(request.ID) == 0 || string(request.ID) == "null" || strings.TrimSpace(request.Method) == "" { + return marshalControlResponse(request.ID, nil, &controlError{Code: -32600, Message: ErrControlProtocol.Error()}) + } + if request.Method == ControllerMethodNegotiate { + var params controlNegotiateParams + if err := json.Unmarshal(request.Params, ¶ms); err != nil || !containsVersion(params.SupportedVersions, ControllerProtocolVersion) { + return marshalControlResponse(request.ID, nil, &controlError{Code: -32602, Message: "supported_versions must include controller protocol v1"}) + } + *negotiated = true + return marshalControlResponse(request.ID, controlNegotiateResult{ProtocolVersion: ControllerProtocolVersion, ServerVersions: []int{ControllerProtocolVersion}}, nil) + } + if !*negotiated { + return marshalControlResponse(request.ID, nil, &controlError{Code: -32001, Message: ErrControlNotNegotiated.Error()}) + } + + switch request.Method { + case ControllerMethodStatus: + status, err := s.status(ctx) + if err != nil { + return marshalControlResponse(request.ID, nil, controlServerError(err)) + } + return marshalControlResponse(request.ID, status, nil) + case ControllerMethodSwitch: + result, err := s.switchAccount(ctx, request.Params) + if err != nil { + return marshalControlResponse(request.ID, nil, controlServerError(err)) + } + return marshalControlResponse(request.ID, result, nil) + default: + return marshalControlResponse(request.ID, nil, &controlError{Code: -32601, Message: ErrControlMethod.Error()}) + } +} + +func controlServerError(err error) *controlError { + if err == nil { + return nil + } + return &controlError{Code: -32000, Message: err.Error()} +} + +func marshalControlResponse(id json.RawMessage, result any, err *controlError) []byte { + if len(id) == 0 { + id = json.RawMessage("null") + } + response, marshalErr := json.Marshal(controlResponse{JSONRPC: "2.0", ID: id, Result: result, Error: err}) + if marshalErr != nil { + response = []byte(`{"jsonrpc":"2.0","id":null,"error":{"code":-32000,"message":"failed to encode controller response"}}`) + } + return append(response, '\n') +} + +func containsVersion(versions []int, want int) bool { + for _, version := range versions { + if version == want { + return true + } + } + return false +} + +func (s *ControllerControlServer) status(ctx context.Context) (ControlStatus, error) { + if s == nil || s.controller == nil { + return ControlStatus{}, errors.New("controller is unavailable") + } + profiles, err := s.controller.AccountManager().List() + if err != nil { + return ControlStatus{}, err + } + activeID, err := s.controller.Registry().ActiveID() + if err != nil { + return ControlStatus{}, err + } + status := ControlStatus{ + ProtocolVersion: ControllerProtocolVersion, + Endpoint: s.endpoint, + RuntimeConnected: s.controller.RuntimeConnected(), + ActiveAccountID: activeID, + Accounts: make([]ControlAccount, 0, len(profiles)), + } + for _, profile := range profiles { + status.Accounts = append(status.Accounts, ControlAccount{ + AccountID: profile.AccountID, + Alias: profile.Alias, + Active: profile.Active, + CredentialPresent: profile.CredentialPresent, + CredentialHealth: string(profile.CredentialHealth), + LastTelemetryAt: profile.LastTelemetryAt, + TelemetrySource: profile.TelemetrySource, + }) + } + if s.config.Status != nil { + runtimeStatus, statusErr := s.config.Status(ctx) + if statusErr != nil { + return ControlStatus{}, statusErr + } + status.Runtime = runtimeStatus + } else if s.controller.RuntimeConnected() { + state, stateErr := s.controller.RuntimeState(ctx) + if stateErr != nil { + return ControlStatus{}, stateErr + } + runtimeStatus := &ControlRuntimeStatus{ + Connected: true, + RuntimeID: state.Identity.RuntimeID, + AccountID: optionalAccountID(state.Identity.AccountID), + AuthGeneration: state.Identity.AuthGeneration, + ActiveTurnCount: state.ActiveTurnCount, + PendingTransition: state.PendingTransition, + } + status.Runtime = runtimeStatus + } + if state, ok := s.controller.Coordinator().State(); ok { + status.Transition = &state + } + return status, nil +} + +func (s *ControllerControlServer) switchAccount(parent context.Context, raw json.RawMessage) (ControlSwitchResult, error) { + params := controlSwitchParams{} + if len(raw) != 0 && string(raw) != "null" { + if err := json.Unmarshal(raw, ¶ms); err != nil { + return ControlSwitchResult{}, fmt.Errorf("decode switch params: %w", err) + } + } + target := strings.TrimSpace(params.Target) + if target == "" { + return ControlSwitchResult{}, ErrControlTargetRequired + } + account, err := s.resolveAccountTarget(target) + if err != nil { + return ControlSwitchResult{}, err + } + handler := s.config.Switch + if handler == nil { + handler = func(ctx context.Context, accountID string) (ControlSwitchResult, error) { + transition, requestErr := s.controller.RequestTransition(ctx, accountID) + if transition == nil { + return ControlSwitchResult{}, requestErr + } + result := ControlSwitchResult{ + AccountID: accountID, + Outcome: string(transition.Outcome), + Mode: "runtime", + TransitionID: transition.TransitionID, + RuntimeID: transition.RuntimeID, + ExpectedGeneration: transition.ExpectedGeneration, + FinalGeneration: transition.FinalGeneration, + IdentityVerified: transition.Outcome == transitions.TransitionCommitted, + Reason: transition.Reason, + } + if requestErr != nil && transition.Outcome != transitions.TransitionUncertain && transition.Outcome != transitions.TransitionRejected { + return result, requestErr + } + return result, nil + } + } + requestCtx := parent + if requestCtx == nil { + requestCtx = context.Background() + } + wait := defaultControlSwitchWait + if params.TimeoutMS > 0 { + wait = time.Duration(params.TimeoutMS) * time.Millisecond + } + if wait <= 0 { + return ControlSwitchResult{}, errors.New("switch timeout must be positive") + } + requestCtx, cancel := context.WithTimeout(requestCtx, wait) + defer cancel() + result, err := handler(requestCtx, account.AccountID) + if result.AccountID == "" { + result.AccountID = account.AccountID + } + result.Alias = account.Alias + return result, err +} + +func (s *ControllerControlServer) resolveAccountTarget(target string) (ControlAccount, error) { + profiles, err := s.controller.AccountManager().List() + if err != nil { + return ControlAccount{}, err + } + var match *ControlAccount + for _, profile := range profiles { + candidate := ControlAccount{ + AccountID: profile.AccountID, + Alias: profile.Alias, + Active: profile.Active, + CredentialPresent: profile.CredentialPresent, + CredentialHealth: string(profile.CredentialHealth), + LastTelemetryAt: profile.LastTelemetryAt, + TelemetrySource: profile.TelemetrySource, + } + if profile.AccountID == target { + return candidate, nil + } + if profile.Alias == target { + if match != nil { + return ControlAccount{}, fmt.Errorf("account alias %q is ambiguous", target) + } + copy := candidate + match = © + } + } + if match == nil { + return ControlAccount{}, fmt.Errorf("account %q was not found", target) + } + return *match, nil +} diff --git a/controller/app/control_test.go b/controller/app/control_test.go new file mode 100644 index 00000000..c6de88dc --- /dev/null +++ b/controller/app/control_test.go @@ -0,0 +1,158 @@ +package app + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "net" + "path/filepath" + "strings" + "testing" + "time" + + "codexmarathon/controller/internal/accounts" +) + +func TestControllerControlServerNegotiatesStatusAndResolvesAlias(t *testing.T) { + stateDir := t.TempDir() + controller, err := New(Config{ + StateDir: stateDir, + AuthPath: filepath.Join(stateDir, "auth.json"), + }) + if err != nil { + t.Fatalf("New() error = %v", err) + } + if err := controller.EnsureLayout(); err != nil { + t.Fatalf("EnsureLayout() error = %v", err) + } + for _, account := range []struct { + id string + alias string + token string + }{ + {id: "acct-a", alias: "personal", token: "token-a"}, + {id: "acct-b", alias: "work", token: "token-b"}, + } { + _, err := controller.AccountManager().Import(context.Background(), accounts.ImportRequest{ + AccountID: account.id, + Alias: account.alias, + AuthJSON: []byte(fmt.Sprintf(`{"tokens":{"account_id":%q,"access_token":%q}}`, account.id, account.token)), + }) + if err != nil { + t.Fatalf("Import(%s) error = %v", account.id, err) + } + } + + var switchedTo string + server, err := NewControllerControlServer(controller, ControllerControlServerConfig{ + Switch: func(_ context.Context, accountID string) (ControlSwitchResult, error) { + switchedTo = accountID + return ControlSwitchResult{AccountID: accountID, Outcome: "committed", Mode: "test", IdentityVerified: true}, nil + }, + }) + if err != nil { + t.Fatalf("NewControllerControlServer() error = %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := server.Start(ctx); err != nil { + t.Fatalf("Start() error = %v", err) + } + defer func() { _ = server.Close() }() + + path := strings.TrimPrefix(server.Endpoint(), "unix://") + conn, err := net.Dial("unix", path) + if err != nil { + t.Fatalf("dial controller endpoint: %v", err) + } + defer conn.Close() + reader := bufio.NewReader(conn) + + writeControlRequest(t, conn, `{"jsonrpc":"2.0","id":"1","method":"protocol/negotiate","params":{"supported_versions":[1]}}`) + var negotiated struct { + Result controlNegotiateResult `json:"result"` + } + readControlResponse(t, conn, reader, &negotiated) + if negotiated.Result.ProtocolVersion != ControllerProtocolVersion { + t.Fatalf("protocol version = %d, want %d", negotiated.Result.ProtocolVersion, ControllerProtocolVersion) + } + + writeControlRequest(t, conn, `{"jsonrpc":"2.0","id":"2","method":"marathon/status","params":{}}`) + var status struct { + Result ControlStatus `json:"result"` + } + readControlResponse(t, conn, reader, &status) + if status.Result.ActiveAccountID != "acct-a" { + t.Fatalf("active account = %q, want acct-a", status.Result.ActiveAccountID) + } + if len(status.Result.Accounts) != 2 || status.Result.Accounts[1].Alias != "work" { + t.Fatalf("status accounts = %#v", status.Result.Accounts) + } + + writeControlRequest(t, conn, `{"jsonrpc":"2.0","id":"3","method":"marathon/switch","params":{"target":"work","timeout_ms":1000}}`) + var switched struct { + Result ControlSwitchResult `json:"result"` + } + readControlResponse(t, conn, reader, &switched) + if switchedTo != "acct-b" { + t.Fatalf("switch target = %q, want acct-b", switchedTo) + } + if switched.Result.AccountID != "acct-b" || switched.Result.Alias != "work" || switched.Result.Outcome != "committed" { + t.Fatalf("switch result = %#v", switched.Result) + } +} + +func TestControllerControlServerRequiresNegotiation(t *testing.T) { + controller, err := New(Config{StateDir: t.TempDir(), AuthPath: filepath.Join(t.TempDir(), "auth.json")}) + if err != nil { + t.Fatal(err) + } + server, err := NewControllerControlServer(controller, ControllerControlServerConfig{}) + if err != nil { + t.Fatal(err) + } + response := server.handleLine(context.Background(), []byte(`{"jsonrpc":"2.0","id":"1","method":"marathon/status","params":{}}`), new(bool)) + var decoded struct { + Error *controlError `json:"error"` + } + if err := json.Unmarshal(response, &decoded); err != nil { + t.Fatal(err) + } + if decoded.Error == nil || decoded.Error.Code != -32001 { + t.Fatalf("response error = %#v, want not-negotiated", decoded.Error) + } +} + +func TestControllerControlEndpointRejectsNetworkAndOutsidePaths(t *testing.T) { + stateDir := t.TempDir() + if err := ValidateControllerControlEndpoint("tcp://127.0.0.1:1", stateDir); err == nil { + t.Fatal("TCP controller endpoint was accepted") + } + outside := filepath.Join(t.TempDir(), "controller.sock") + if err := ValidateControllerControlEndpoint("unix://"+outside, stateDir); err == nil { + t.Fatal("controller endpoint outside state directory was accepted") + } + if _, err := DefaultControllerControlEndpoint(stateDir); err != nil { + t.Fatalf("DefaultControllerControlEndpoint() error = %v", err) + } +} + +func writeControlRequest(t *testing.T, conn net.Conn, request string) { + t.Helper() + if _, err := fmt.Fprintf(conn, "%s\n", request); err != nil { + t.Fatalf("write control request: %v", err) + } +} + +func readControlResponse(t *testing.T, conn net.Conn, reader *bufio.Reader, out any) { + t.Helper() + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + line, err := reader.ReadBytes('\n') + if err != nil { + t.Fatalf("read control response: %v", err) + } + if err := json.Unmarshal(line, out); err != nil { + t.Fatalf("decode control response: %v", err) + } +} diff --git a/controller/app/control_unix.go b/controller/app/control_unix.go new file mode 100644 index 00000000..27752364 --- /dev/null +++ b/controller/app/control_unix.go @@ -0,0 +1,78 @@ +//go:build !windows + +package app + +import ( + "errors" + "fmt" + "net" + "net/url" + "os" + "path/filepath" + "strings" + "time" +) + +func listenControllerControlEndpoint(endpoint, stateDir string) (net.Listener, string, error) { + if err := ValidateControllerControlEndpoint(endpoint, stateDir); err != nil { + return nil, "", err + } + parsed, err := url.Parse(endpoint) + if err != nil { + return nil, "", fmt.Errorf("parse controller endpoint: %w", err) + } + if strings.ToLower(parsed.Scheme) != "unix" { + return nil, "", fmt.Errorf("%w: only Unix sockets are available on this platform", ErrControlUnsupportedOS) + } + path := filepath.Clean(filepath.FromSlash(parsed.Path)) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return nil, "", fmt.Errorf("create controller socket directory: %w", err) + } + if err := os.Chmod(filepath.Dir(path), 0o700); err != nil && !isPermissionMetadataError(err) { + return nil, "", fmt.Errorf("tighten controller socket directory: %w", err) + } + if info, statErr := os.Lstat(path); statErr == nil { + if info.Mode()&os.ModeSymlink != 0 || info.Mode()&os.ModeSocket == 0 { + return nil, "", fmt.Errorf("%w: existing endpoint is not a Unix socket", ErrUnsafeControlEndpoint) + } + probe, probeErr := net.DialTimeout("unix", path, 150*time.Millisecond) + if probeErr == nil { + _ = probe.Close() + return nil, "", ErrControlAlreadyRunning + } + if removeErr := os.Remove(path); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) { + return nil, "", fmt.Errorf("remove stale controller endpoint: %w", removeErr) + } + } else if !errors.Is(statErr, os.ErrNotExist) { + return nil, "", fmt.Errorf("inspect controller endpoint: %w", statErr) + } + listener, err := net.Listen("unix", path) + if err != nil { + return nil, "", fmt.Errorf("listen on controller endpoint %q: %w", path, err) + } + if err := os.Chmod(path, 0o600); err != nil { + _ = listener.Close() + _ = os.Remove(path) + return nil, "", fmt.Errorf("tighten controller endpoint permissions: %w", err) + } + return listener, path, nil +} + +func removeControllerControlEndpoint(endpoint string) error { + parsed, err := url.Parse(endpoint) + if err != nil || strings.ToLower(parsed.Scheme) != "unix" { + return nil + } + path := filepath.Clean(filepath.FromSlash(parsed.Path)) + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + if info.Mode()&os.ModeSocket == 0 || info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("%w: refusing to remove non-socket endpoint", ErrUnsafeControlEndpoint) + } + return os.Remove(path) +} diff --git a/controller/app/control_windows.go b/controller/app/control_windows.go new file mode 100644 index 00000000..7f646b7b --- /dev/null +++ b/controller/app/control_windows.go @@ -0,0 +1,18 @@ +//go:build windows + +package app + +import ( + "fmt" + "net" +) + +// Named-pipe serving is intentionally left behind the same platform seam as +// the runtime connector. The Rust runtime owns the protected Windows pipe; +// a future TUI/controller build can add a DACL-backed implementation here +// without weakening the Linux Unix-socket boundary. +func listenControllerControlEndpoint(endpoint, stateDir string) (net.Listener, string, error) { + return nil, "", fmt.Errorf("%w: controller named-pipe serving is not implemented", ErrControlUnsupportedOS) +} + +func removeControllerControlEndpoint(endpoint string) error { return nil } diff --git a/controller/app/lifecycle.go b/controller/app/lifecycle.go index ba85ff89..f10fde32 100644 --- a/controller/app/lifecycle.go +++ b/controller/app/lifecycle.go @@ -486,6 +486,11 @@ func (s *RuntimeSupervisor) startManagedProcess(_ context.Context) error { // flag to a Codex binary that does not know that flag; the supervisor owns // the integration boundary and the runtime remains a normal Codex binary. env = append(env, "CODEXMARATHON_LISTEN="+endpoint) + controlEndpoint, err := DefaultControllerControlEndpoint(s.controller.Config().StateDir) + if err != nil { + return fmt.Errorf("resolve controller control endpoint: %w", err) + } + env = append(env, ControllerControlEnv+"="+controlEndpoint) if isCodexAppServer(command[0]) && !hasFlag(args, "--listen") { // The supervisor owns the process over local IPC. Inheriting the // app-server's stdio transport would make it exit when the child has no diff --git a/controller/cmd/codexmarathon/accounts_commands.go b/controller/cmd/codexmarathon/accounts_commands.go index 22a580d1..9c8447af 100644 --- a/controller/cmd/codexmarathon/accounts_commands.go +++ b/controller/cmd/codexmarathon/accounts_commands.go @@ -6,6 +6,7 @@ import ( "flag" "fmt" "io" + "os" "strings" "time" @@ -23,6 +24,8 @@ func runAccounts(args []string, stdout, stderr io.Writer) int { return runAccountsList(args[1:], stdout, stderr) case "login", "add": return runAccountsLogin(args[1:], stdout, stderr) + case "import": + return runAccountsImport(args[1:], stdout, stderr) case "status": return runAccountsStatus(args[1:], stdout, stderr) case "rename": @@ -44,6 +47,7 @@ Usage: codexmarathon accounts list [options] codexmarathon accounts login [options] codexmarathon accounts add [options] + codexmarathon accounts import [options] codexmarathon accounts status [account-id] [options] codexmarathon accounts rename --name [options] codexmarathon accounts activate [options] @@ -53,6 +57,57 @@ Usage: ` +func runAccountsImport(args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("accounts import", flag.ContinueOnError) + fs.SetOutput(stderr) + var paths pathFlags + paths.bind(fs) + fromAuth := fs.String("from-auth", "", "existing Codex auth.json to import (default: --auth path)") + accountID := fs.String("id", "", "optional stable account ID when the auth snapshot has no account_id") + alias := fs.String("name", "", "local profile alias") + activate := fs.Bool("activate", false, "make this profile active after import") + overwrite := fs.Bool("overwrite", false, "replace an existing profile with the same account ID") + jsonOutput := fs.Bool("json", false, "print machine-readable JSON") + if err := fs.Parse(args); err != nil { + return 2 + } + if fs.NArg() != 0 { + return usageError(stderr, "accounts import does not accept positional arguments; use --from-auth, --id, and --name") + } + source := strings.TrimSpace(*fromAuth) + if source == "" { + source = paths.authPath + } + raw, err := os.ReadFile(source) + if err != nil { + return printError(stderr, fmt.Errorf("read auth snapshot %q: %w", source, err)) + } + controller, err := app.New(paths.config()) + if err != nil { + return printError(stderr, err) + } + defer func() { _ = controller.Close() }() + outcome, err := controller.AccountManager().Import(context.Background(), accounts.ImportRequest{ + AccountID: *accountID, + Alias: *alias, + AuthJSON: raw, + Activate: *activate, + Overwrite: *overwrite, + }) + if err != nil { + return printError(stderr, err) + } + if *jsonOutput { + return writeJSON(stdout, outcome) + } + if outcome.Activated { + _, _ = fmt.Fprintf(stdout, "imported and activated account: %s\n", outcome.AccountID) + } else { + _, _ = fmt.Fprintf(stdout, "imported account: %s\n", outcome.AccountID) + } + return 0 +} + func runAccountsLogin(args []string, stdout, stderr io.Writer) int { fs := flag.NewFlagSet("accounts login", flag.ContinueOnError) fs.SetOutput(stderr) diff --git a/controller/cmd/codexmarathon/accounts_commands_test.go b/controller/cmd/codexmarathon/accounts_commands_test.go new file mode 100644 index 00000000..5ef53124 --- /dev/null +++ b/controller/cmd/codexmarathon/accounts_commands_test.go @@ -0,0 +1,41 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestAccountsImportUsesExistingAuthWithoutRuntime(t *testing.T) { + root := t.TempDir() + authPath := filepath.Join(root, "codex", "auth.json") + if err := os.MkdirAll(filepath.Dir(authPath), 0o700); err != nil { + t.Fatal(err) + } + const secret = "opaque-stock-token" + if err := os.WriteFile(authPath, []byte(`{"auth_mode":"chatgpt","tokens":{"account_id":"stock-account","access_token":"`+secret+`"}}`), 0o600); err != nil { + t.Fatal(err) + } + + var stdout, stderr bytes.Buffer + code := run([]string{ + "accounts", "import", + "--state-dir", filepath.Join(root, "state"), + "--auth", authPath, + "--name", "personal", + }, &stdout, &stderr) + if code != 0 { + t.Fatalf("accounts import exit code = %d, stderr = %s", code, stderr.String()) + } + if got := stdout.String(); !strings.Contains(got, "imported and activated account: stock-account") { + t.Fatalf("accounts import output = %q", got) + } + if strings.Contains(stdout.String(), secret) || strings.Contains(stderr.String(), secret) { + t.Fatalf("accounts import exposed auth material: stdout=%q stderr=%q", stdout.String(), stderr.String()) + } + if _, err := os.Stat(filepath.Join(root, "state", "credentials", "stock-account.json")); err != nil { + t.Fatalf("imported vault snapshot missing: %v", err) + } +} diff --git a/controller/cmd/codexmarathon/main.go b/controller/cmd/codexmarathon/main.go index 0ff18a67..76eb3232 100644 --- a/controller/cmd/codexmarathon/main.go +++ b/controller/cmd/codexmarathon/main.go @@ -30,6 +30,7 @@ Usage: codexmarathon accounts list [options] codexmarathon accounts login [options] codexmarathon accounts add [options] + codexmarathon accounts import [options] codexmarathon accounts status [account-id] [options] codexmarathon accounts rename --name [options] codexmarathon accounts activate [options] diff --git a/controller/cmd/codexmarathon/run_command.go b/controller/cmd/codexmarathon/run_command.go index 5a2bb0c9..214d2b90 100644 --- a/controller/cmd/codexmarathon/run_command.go +++ b/controller/cmd/codexmarathon/run_command.go @@ -81,11 +81,19 @@ func runManagedRuntimeCommand(args []string, stdout, stderr io.Writer) int { } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() + controlServer, err := app.NewControllerControlServer(controller, app.ControllerControlServerConfig{}) + if err != nil { + return printError(stderr, err) + } + if err := controlServer.Start(ctx); err != nil { + return printError(stderr, err) + } + defer func() { _ = controlServer.Close() }() if err := supervisor.Start(ctx); err != nil { return printError(stderr, err) } status := supervisor.Status() - _, _ = fmt.Fprintf(stdout, "codexmarathon runtime ready: endpoint=%s protocol=%d pid=%d\n", status.Endpoint, status.ProtocolVersion, status.PID) + _, _ = fmt.Fprintf(stdout, "codexmarathon runtime ready: endpoint=%s protocol=%d pid=%d control=%s\n", status.Endpoint, status.ProtocolVersion, status.PID, controlServer.Endpoint()) monitorDone := make(chan struct{}) go func() { _ = supervisor.Wait() @@ -172,7 +180,14 @@ func runInstalledCompanionCommand(args []string, stdout, stderr io.Writer) int { command := []string{installation.Executable} command = append(command, codexArgs...) command = append(command, fs.Args()...) - environment := []string{"CODEX_HOME=" + installation.CodexHome} + controlEndpointHint, err := app.DefaultControllerControlEndpoint(controller.Config().StateDir) + if err != nil { + return printError(stderr, err) + } + environment := []string{ + "CODEX_HOME=" + installation.CodexHome, + app.ControllerControlEnv + "=" + controlEndpointHint, + } supervisor, err := app.NewCompanionSupervisor(app.CompanionConfig{ Command: command, WorkingDir: *workingDir, @@ -240,41 +255,65 @@ func runInstalledCompanionSession(ctx context.Context, controller *app.Controlle // end of the user-facing command, so an intentional fallback restart cannot // race the old process's Wait result. var transitionMu sync.Mutex - transition := func(transitionCtx context.Context, accountID string) error { + switchTarget := func(transitionCtx context.Context, accountID string) (app.ControlSwitchResult, error) { transitionMu.Lock() defer transitionMu.Unlock() threadID := holder.ThreadID() resume := app.CompanionResumeTarget{ThreadID: threadID, Last: threadID == ""} result, err := controller.TransitionInstalledOrRestart(transitionCtx, holder.Client(), supervisor, accountID, resume) if err != nil { - return err + return app.ControlSwitchResult{}, err } if err := controller.RegisterUsageProvider(accountID, provider); err != nil { - return err + return app.ControlSwitchResult{}, err } if cache := controller.Cache(); cache != nil { cache.Invalidate(accountID) } + controlResult := app.ControlSwitchResult{ + AccountID: result.AccountID, + Outcome: "committed", + Mode: string(result.Mode), + RuntimeID: "installed-codex", + IdentityVerified: result.IdentityVerified, + } if result.Mode != app.InstalledTransitionRestart { - return nil + return controlResult, nil } connectCtx, cancel := context.WithTimeout(transitionCtx, connectTimeout) next, connectErr := connectInstalledUntil(connectCtx, installation, endpoint) cancel() if connectErr != nil { - return fmt.Errorf("reconnect installed Codex app-server after restart: %w", connectErr) + return app.ControlSwitchResult{}, fmt.Errorf("reconnect installed Codex app-server after restart: %w", connectErr) } // The initialize handshake proves the control endpoint is alive. Read // and compare the deployed auth identity before replacing the // provider's connection or confirming the policy transition. if _, verifyErr := controller.VerifyInstalledIdentity(transitionCtx, next, accountID); verifyErr != nil { _ = next.Close() - return fmt.Errorf("verify resumed installed Codex identity: %w", verifyErr) + return app.ControlSwitchResult{}, fmt.Errorf("verify resumed installed Codex identity: %w", verifyErr) } holder.SetClient(next) - return nil + controlResult.IdentityVerified = true + return controlResult, nil + } + transition := func(transitionCtx context.Context, accountID string) error { + _, err := switchTarget(transitionCtx, accountID) + return err + } + + controlServer, err := app.NewControllerControlServer(controller, app.ControllerControlServerConfig{ + Switch: switchTarget, + }) + if err != nil { + return printError(stderr, err) + } + if err := controlServer.Start(ctx); err != nil { + return printError(stderr, err) } + defer func() { _ = controlServer.Close() }() + _, _ = fmt.Fprintf(stdout, "codexmarathon controller control ready: endpoint=%s\n", controlServer.Endpoint()) loop, err := controller.NewAutomationLoopWithTransition(transition) if err != nil { diff --git a/controller/internal/accounts/manager.go b/controller/internal/accounts/manager.go index b0a3454a..1c022e7a 100644 --- a/controller/internal/accounts/manager.go +++ b/controller/internal/accounts/manager.go @@ -67,6 +67,18 @@ type LoginResult struct { Metadata map[string]string } +// ImportRequest describes an already authenticated Codex auth.json snapshot. +// AuthJSON is sensitive and is consumed only in memory before it is persisted +// to the protected vault; callers must not log or print it. +type ImportRequest struct { + AccountID string + Alias string + AuthJSON []byte + Activate bool + Overwrite bool + Metadata map[string]string +} + // RefreshRequest is the in-memory handoff for a native refresh. The runtime // receives the opaque current snapshot so it can use its own auth schema and // refresh implementation. The manager validates the returned token set and @@ -312,11 +324,65 @@ func (m *Manager) Login(ctx context.Context, request LoginRequest) (LoginOutcome metadata[key] = value } } + return m.persistSnapshotLocked(ctx, accountID, alias, raw, metadata, request.Overwrite, request.Activate) +} + +// Import stores an existing Codex auth.json snapshot without invoking a login +// runtime. This is the supported bootstrap path for a stock Codex CLI whose +// active authentication already exists but which does not expose Marathon's +// native login endpoint. +func (m *Manager) Import(ctx context.Context, request ImportRequest) (LoginOutcome, error) { + if err := contextError(ctx); err != nil { + return LoginOutcome{}, err + } + m.mu.Lock() + defer m.mu.Unlock() + if err := m.validateLocked(false); err != nil { + return LoginOutcome{}, err + } + if err := ValidateAccountIDOptional(request.AccountID); err != nil { + return LoginOutcome{}, err + } + if err := ValidateAlias(request.Alias); err != nil { + return LoginOutcome{}, err + } + raw, err := credentials.ValidateSnapshot(request.AuthJSON) + if err != nil { + return LoginOutcome{}, err + } + identity, err := credentials.ExtractAccountID(raw) + if err != nil { + return LoginOutcome{}, err + } + accountID := request.AccountID + if accountID == "" { + accountID = identity + } + if accountID == "" { + return LoginOutcome{}, ErrLoginMissingIdentity + } + if err := ValidateAccountID(accountID); err != nil { + return LoginOutcome{}, err + } + if identity != "" && identity != accountID { + return LoginOutcome{}, fmt.Errorf("%w: snapshot is for %q, requested %q", ErrLoginIdentityMismatch, identity, accountID) + } + alias := request.Alias + if alias == "" { + alias = accountID + } + if err := ValidateAlias(alias); err != nil { + return LoginOutcome{}, err + } + return m.persistSnapshotLocked(ctx, accountID, alias, raw, request.Metadata, request.Overwrite, request.Activate) +} + +func (m *Manager) persistSnapshotLocked(ctx context.Context, accountID, alias string, raw []byte, metadata map[string]string, overwrite, activateRequested bool) (LoginOutcome, error) { account := Account{ ID: accountID, Alias: alias, CredentialRef: accountID, - Metadata: metadata, + Metadata: cloneMetadata(metadata), CredentialHealth: CredentialHealthHealthy, } if err := ValidateAccount(account); err != nil { @@ -331,7 +397,7 @@ func (m *Manager) Login(ctx context.Context, request LoginRequest) (LoginOutcome if err != nil { return LoginOutcome{}, err } - if existed && !request.Overwrite { + if existed && !overwrite { return LoginOutcome{}, fmt.Errorf("%w: %s", ErrAccountExists, accountID) } if !existed && hadPreviousRaw { @@ -355,7 +421,7 @@ func (m *Manager) Login(ctx context.Context, request LoginRequest) (LoginOutcome // An overwrite of the currently selected profile must redeploy the newly // authenticated snapshot as well; otherwise the vault and auth.json would // silently diverge until a later manual activation. - activate := request.Activate || activeID == "" || activeID == accountID + activate := activateRequested || activeID == "" || activeID == accountID if !activate { return LoginOutcome{AccountID: accountID, Alias: alias}, nil } diff --git a/controller/internal/accounts/manager_test.go b/controller/internal/accounts/manager_test.go index 0f2fca1d..abe3fe85 100644 --- a/controller/internal/accounts/manager_test.go +++ b/controller/internal/accounts/manager_test.go @@ -188,6 +188,51 @@ func TestManagerLoginSwitchRefreshAndRenameLifecycle(t *testing.T) { } } +func TestManagerImportExistingSnapshotWithoutAuthService(t *testing.T) { + manager, vault, registry, authPath := newManagerFixture(t, nil, nil) + raw := loginSnapshot("stock-account", "stock-token") + + outcome, err := manager.Import(context.Background(), ImportRequest{ + AuthJSON: raw, + Alias: "personal", + }) + if err != nil { + t.Fatalf("Import() error = %v", err) + } + if !outcome.Activated || outcome.AccountID != "stock-account" || outcome.Alias != "personal" { + t.Fatalf("Import() = %#v, want activated stock-account/personal", outcome) + } + stored, err := vault.Load("stock-account") + if err != nil { + t.Fatal(err) + } + if string(stored) != string(raw) { + t.Fatalf("stored snapshot changed during import: got %s want %s", stored, raw) + } + deployed := mustReadFile(t, authPath) + if string(deployed) != string(raw) { + t.Fatalf("deployed snapshot changed during import: got %s want %s", deployed, raw) + } + account, err := registry.Get("stock-account") + if err != nil { + t.Fatal(err) + } + if account.Alias != "personal" || account.CredentialRef != "stock-account" { + t.Fatalf("imported account = %#v", account) + } +} + +func TestManagerImportRejectsSnapshotIdentityMismatch(t *testing.T) { + manager, _, _, _ := newManagerFixture(t, nil, nil) + _, err := manager.Import(context.Background(), ImportRequest{ + AccountID: "requested-account", + AuthJSON: loginSnapshot("snapshot-account", "stock-token"), + }) + if !errors.Is(err, ErrLoginIdentityMismatch) { + t.Fatalf("Import() error = %v, want ErrLoginIdentityMismatch", err) + } +} + func TestManagerOverwriteOfActiveProfileRedeploysNewSnapshot(t *testing.T) { service := &fakeAuthService{ loginResults: []LoginResult{ diff --git a/docs/account-manager.md b/docs/account-manager.md index d68639f9..e3b6b594 100644 --- a/docs/account-manager.md +++ b/docs/account-manager.md @@ -26,6 +26,11 @@ reimplementation of OAuth or a shell command. The imported Codex-derived runtime under `runtime/codex-rs` implements the same seam for optional development and diagnostics. +For a stock Codex installation that is already authenticated, `accounts +import` reads the configured `auth.json`, validates only its JSON object and +optional account identity, and stores the document as an opaque snapshot. It +does not require a runtime endpoint or expose token fields. + ## Storage and mutation rules * `accounts.json` stores only stable IDs, aliases, telemetry markers, and a @@ -34,6 +39,8 @@ development and diagnostics. account ID with owner-only permissions. * Login saves the snapshot before selecting it; the first account is selected automatically, later accounts require `--activate` or `accounts activate`. +* Import uses the same protected save and activation path as login, but takes + the existing authenticated snapshot instead of invoking a native login. * Activation deploys `auth.json` atomically, then updates the active marker. A failed or cancelled activation restores the prior file and marker. * Refresh returns token fields only from the native service, merges non-empty diff --git a/docs/features-and-usage.md b/docs/features-and-usage.md index 2029a620..9f0fa0c0 100644 --- a/docs/features-and-usage.md +++ b/docs/features-and-usage.md @@ -73,6 +73,31 @@ observations are not treated as proof of available quota. The archive contains the companion and its documentation. It never contains your `auth.json`, credential snapshots, journals, or controller state. +## Use the native Codex integration + +The embedded Codex build also exposes Marathon directly. These commands run +inside the single `codex` executable and use its native app-server service: + +```bash +codex marathon status +codex marathon on +codex marathon off +codex marathon import personal +codex marathon login work +codex marathon switch work +``` + +Inside an interactive Codex session, the same operations are available as +`/marathon status`, `/marathon on`, `/marathon off`, `/marathon import ALIAS`, +`/marathon login ALIAS`, and `/marathon switch ALIAS_OR_ID`. + +`login` starts Codex's normal browser login flow, prints the URL, waits for +Codex to report that login completed, and then stores the resulting native +credential under the alias. It never asks you to paste a token. If the +browser does not open automatically, open the printed URL yourself. `import` +captures the currently authenticated Codex identity after a normal `codex login`; +it does not accept credential text. + ## Install CodexMarathon Install and authenticate Codex using its normal distribution first: @@ -189,6 +214,23 @@ word `work` is an example alias, not a special built-in account. ## Add or refresh credentials +If Codex is already authenticated through a normal stock Codex CLI install, +bootstrap the first profile by importing its active `auth.json` snapshot: + +```bash +codexmarathon accounts import \ + --auth "$HOME/.codex/auth.json" \ + --name personal \ + --activate +``` + +The import reads the existing file, keeps the authentication document opaque, +and stores it in CodexMarathon's owner-only vault. It reports only the account +ID and alias; token values are never printed or written to the registry. Use +`--from-auth ` when the source file differs from the `--auth` deployment +path. If the snapshot does not contain an `account_id`, supply a stable value +with `--id `. + The account login and refresh commands use the Codex authentication service through a compatible Marathon runtime endpoint: @@ -205,8 +247,7 @@ codexmarathon accounts refresh \ The endpoint is a local IPC endpoint, not a public network service. Do not place token values in `--runtime` or any other command argument. If your installed Codex release does not expose the compatible login endpoint, use its -normal `codex login` flow and follow the repository's current onboarding -instructions before adding the resulting profile. +normal `codex login` flow, then run `accounts import` as shown above. ## Switch accounts diff --git a/protocol/README.md b/protocol/README.md index cec003ce..caf0e219 100644 --- a/protocol/README.md +++ b/protocol/README.md @@ -20,6 +20,33 @@ journal, registry, logs, or CLI output. The imported Rust runtime implements the same seam for optional protocol development; it is not required by the default companion package. +## TUI to controller control socket + +When `codexmarathon run` owns the Codex session it also exposes a separate +user-scoped local socket for TUI commands. The default Unix endpoint is +`unix:///controller.sock`; a launched Codex process receives the +same value in `CODEXMARATHON_CONTROL`. This endpoint is distinct from the +runtime socket because it can initiate a credential transition. + +The client first sends the same `protocol/negotiate` request with +`supported_versions: [1]`. The controller then accepts these secret-free +requests: + +* `marathon/status` with `{}` returns the registered account aliases, active + marker, runtime identity/turn count when a Marathon runtime is attached, + and any controller transition metadata. +* `marathon/switch` with + `{"target":"","timeout_ms":120000}` resolves the + alias in the controller and runs the complete controller-owned transition. + The request path is `prepare -> wait for the runtime's safe boundary -> + atomically deploy the selected snapshot -> commit/reload -> verify identity`; + the TUI never edits `auth.json`. + +The switch result contains only account ID/alias, outcome, mode, generation, +transition ID, and a redacted reason. A `committed` result is safe to display +as the new active identity. An `uncertain` result carries the transition ID +for controller reconciliation and must not be treated as a successful switch. + Codext owns the `UsageLimitExceeded` synthetic recovery turn. It emits `recovery_parked` with a recovery/thread correlation, and the controller may send `recovery/release` only after a committed identity-changing transition. diff --git a/runtime/README.md b/runtime/README.md index 22d5a0e2..913e13e8 100644 --- a/runtime/README.md +++ b/runtime/README.md @@ -25,3 +25,20 @@ The resulting app-server is never packaged by default. Use `--include-embedded-runtime` or `CODEXMARATHON_INCLUDE_EMBEDDED_RUNTIME=1` only for controlled protocol diagnostics. No donor path appears in the Cargo workspace. + +## Native Marathon user flow + +The integrated Codex build exposes Marathon from the `codex` executable itself: + +```text +codex marathon status +codex marathon login work --device-code +codex marathon switch work +``` + +`codex marathon login ALIAS` prompts in an interactive terminal to choose a +browser link or device/auth-code login. Use `--browser` or `--device-code` for +scripted or headless use. Inside the TUI, `/marathon` shows the current status +and help, and `/marathon login ALIAS` opens the same choice. Device-code login +prints a verification URL and one-time code that can be completed from another +machine; the account is imported automatically after native auth reloads. diff --git a/runtime/codex-rs/Cargo.lock b/runtime/codex-rs/Cargo.lock index 82e693d7..b458799f 100644 --- a/runtime/codex-rs/Cargo.lock +++ b/runtime/codex-rs/Cargo.lock @@ -2398,6 +2398,7 @@ dependencies = [ "clap_complete", "codex-api", "codex-app-server", + "codex-app-server-client", "codex-app-server-daemon", "codex-app-server-protocol", "codex-app-server-test-client", @@ -2413,6 +2414,7 @@ dependencies = [ "codex-execpolicy", "codex-extension-api", "codex-features", + "codex-feedback", "codex-git-attribution", "codex-git-utils", "codex-history", @@ -3601,6 +3603,7 @@ dependencies = [ "url", "urlencoding", "webbrowser", + "windows-sys 0.52.0", "wiremock", ] @@ -4898,6 +4901,7 @@ dependencies = [ name = "codexmarathon-runtime" version = "0.152.0" dependencies = [ + "chrono", "codex-backend-client", "codex-core", "codex-http-client", @@ -4907,6 +4911,7 @@ dependencies = [ "serde", "serde_json", "tempfile", + "thiserror 2.0.18", "tokio", ] diff --git a/runtime/codex-rs/app-server-protocol/src/protocol/common.rs b/runtime/codex-rs/app-server-protocol/src/protocol/common.rs index 63e03675..c9ec5169 100644 --- a/runtime/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/runtime/codex-rs/app-server-protocol/src/protocol/common.rs @@ -1078,6 +1078,37 @@ client_request_definitions! { serialization: global_shared_read("remote-control"), response: v2::RemoteControlStatusReadResponse, }, + /// Read secret-free state from the native Marathon service. + MarathonStatus => "marathon/status" { + params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + serialization: global_shared_read("marathon"), + response: v2::MarathonStatusResponse, + }, + /// Enable or disable the native Marathon service. + MarathonEnabledSet => "marathon/enabled/set" { + params: v2::MarathonEnabledSetParams, + serialization: global("marathon"), + response: v2::MarathonEnabledSetResponse, + }, + /// Enable or disable automatic use of a provider-supported quota reset + /// action after every eligible managed account reaches zero weekly quota. + MarathonAutoResetSet => "marathon/autoReset/set" { + params: v2::MarathonAutoResetSetParams, + serialization: global("marathon"), + response: v2::MarathonAutoResetSetResponse, + }, + /// Manually switch the native Codex auth identity at an idle boundary. + MarathonSwitch => "marathon/switch" { + params: v2::MarathonSwitchParams, + serialization: global("marathon"), + response: v2::MarathonSwitchResponse, + }, + /// Store the currently authenticated native Codex account under a Marathon alias. + MarathonImport => "marathon/import" { + params: v2::MarathonImportParams, + serialization: global("marathon"), + response: v2::MarathonImportResponse, + }, #[experimental("remoteControl/pairing/start")] RemoteControlPairingStart => "remoteControl/pairing/start" { params: v2::RemoteControlPairingStartParams, diff --git a/runtime/codex-rs/app-server-protocol/src/protocol/v2/marathon.rs b/runtime/codex-rs/app-server-protocol/src/protocol/v2/marathon.rs new file mode 100644 index 00000000..2de47aa1 --- /dev/null +++ b/runtime/codex-rs/app-server-protocol/src/protocol/v2/marathon.rs @@ -0,0 +1,130 @@ +use crate::JsonSchema; +use crate::TS; +use serde::Deserialize; +use serde::Serialize; + +/// Secret-free view of one account managed by the native Marathon service. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub struct MarathonAccount { + pub account_id: String, + pub alias: String, + pub active: bool, + pub credential_present: bool, + pub credential_health: String, + /// Last native provider observation for the weekly/long quota window. + /// `null` means the provider has not supplied a weekly window yet. + #[serde(default)] + pub weekly_quota_remaining_percent: Option, + /// Whether the provider reported an immediate quota-reset action for this + /// account. This is capability metadata, never an inferred timestamp. + #[serde(default)] + pub reset_action_available: bool, +} + +/// Status of the in-process Marathon account manager. +/// +/// This type intentionally contains identity and lifecycle metadata only. It +/// must never grow fields containing auth snapshots or token material. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub struct MarathonStatusResponse { + pub enabled: bool, + pub active_account_id: Option, + pub current_account_id: Option, + pub auth_generation: u64, + pub active_turn_count: u32, + pub accounts: Vec, + #[serde(default)] + pub auto_reset_enabled: bool, + #[serde(default)] + pub auto_reset_phase: String, + #[serde(default)] + pub auto_reset_last_error: Option, +} + +/// Enable or disable automatic use of a provider reset action when every +/// eligible managed account has zero weekly quota. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub struct MarathonAutoResetSetParams { + pub enabled: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub struct MarathonAutoResetSetResponse { + pub enabled: bool, + pub phase: String, +} + +/// Enable or disable the native Marathon service. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub struct MarathonEnabledSetParams { + pub enabled: bool, +} + +/// Result of changing the native Marathon enabled state. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub struct MarathonEnabledSetResponse { + pub enabled: bool, +} + +/// Request a manual switch to an account id or registered alias. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub struct MarathonSwitchParams { + pub target: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub enum MarathonSwitchOutcome { + Committed, + Rejected, + Deferred, +} + +/// Secret-free result of a manual account switch. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub struct MarathonSwitchResponse { + pub account_id: Option, + pub outcome: MarathonSwitchOutcome, + pub auth_generation: u64, + pub active_turn_count: u32, + pub reason: Option, +} + +/// Capture the currently authenticated Codex identity in the Marathon vault. +/// +/// The auth document is read only through Codex's native AuthManager. Clients +/// never send token material to this request. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub struct MarathonImportParams { + pub alias: String, +} + +/// Secret-free result of importing the current native Codex identity. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub struct MarathonImportResponse { + pub account_id: String, + pub alias: String, + pub active: bool, + pub replaced: bool, +} diff --git a/runtime/codex-rs/app-server-protocol/src/protocol/v2/mod.rs b/runtime/codex-rs/app-server-protocol/src/protocol/v2/mod.rs index 75416d13..785a9698 100644 --- a/runtime/codex-rs/app-server-protocol/src/protocol/v2/mod.rs +++ b/runtime/codex-rs/app-server-protocol/src/protocol/v2/mod.rs @@ -17,6 +17,7 @@ mod feedback; mod fs; mod hook; mod item; +mod marathon; mod mcp; mod model; mod notification; @@ -51,6 +52,7 @@ pub use feedback::*; pub use fs::*; pub use hook::*; pub use item::*; +pub use marathon::*; pub use mcp::*; pub use model::*; pub use notification::*; diff --git a/runtime/codex-rs/app-server/src/lib.rs b/runtime/codex-rs/app-server/src/lib.rs index c468a09e..8a5fe190 100644 --- a/runtime/codex-rs/app-server/src/lib.rs +++ b/runtime/codex-rs/app-server/src/lib.rs @@ -121,6 +121,7 @@ mod mcp_refresh; mod message_processor; mod models; mod models_refresh_worker; +mod marathon_service; mod notification_media; mod otel_reloader; mod outgoing_message; diff --git a/runtime/codex-rs/app-server/src/marathon_service.rs b/runtime/codex-rs/app-server/src/marathon_service.rs new file mode 100644 index 00000000..dd2c1ff4 --- /dev/null +++ b/runtime/codex-rs/app-server/src/marathon_service.rs @@ -0,0 +1,534 @@ +//! Native Marathon account service owned by app-server. +//! +//! This service composes the existing AuthManager, ThreadManager and turn +//! watcher with Marathon's secret-free registry and opaque snapshot vault. It +//! is intentionally a small manual-control surface: it reports status, +//! enables/disables Marathon, and performs an explicit account switch at an +//! idle boundary. Automatic recovery is outside this service. + +use codex_app_server_protocol::{ + MarathonAccount, MarathonAutoResetSetResponse, MarathonEnabledSetResponse, + MarathonImportResponse, MarathonStatusResponse, MarathonSwitchOutcome, MarathonSwitchResponse, +}; +use codex_core::ThreadManager; +use codex_login::{AuthDotJson, AuthManager, AuthReloadStatus, AuthTransitionSnapshot}; +use codexmarathon_runtime::{ + AccountStore, AutoResetStore, CredentialHealth, DomainError, FileAccountRegistry, + FileSnapshotVault, MarathonConfig, SnapshotVault, +}; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use thiserror::Error; +use tokio::sync::{Mutex, watch}; + +#[derive(Debug, Error)] +pub(crate) enum MarathonServiceError { + #[error("Marathon state is unavailable")] + StateUnavailable, + #[error("Marathon account was not found")] + AccountNotFound, + #[error("Marathon account alias is ambiguous")] + AmbiguousAlias, + #[error("Marathon account alias is already used by another account")] + AliasConflict, + #[error("Marathon account alias is invalid")] + InvalidAlias, + #[error("Codex authentication is required")] + AuthenticationRequired, + #[error("Marathon account has no stored credentials")] + CredentialsUnavailable, + #[error("Marathon native auth transition failed")] + NativeTransition, + #[error("Marathon persistence failed")] + Persistence, +} + +/// App-server-owned native Marathon composition. +/// +/// `transition_mutex` is shared with the normal app-server account and turn +/// processors. Holding it across the active-turn check, guarded AuthManager +/// operation, and model transport invalidation closes the turn-start race. +pub(crate) struct MarathonService { + auth_manager: Arc, + thread_manager: Arc, + running_turns: watch::Receiver, + transition_mutex: Arc>, + registry: Arc, + vault: Arc, + auto_reset: Arc, + auth_generation: AtomicU64, +} + +impl MarathonService { + pub(crate) fn from_config( + auth_manager: Arc, + thread_manager: Arc, + running_turns: watch::Receiver, + transition_mutex: Arc>, + config: &MarathonConfig, + ) -> Result { + config + .validate() + .map_err(|_| MarathonServiceError::StateUnavailable)?; + Ok(Self { + auth_manager, + thread_manager, + running_turns, + transition_mutex, + registry: Arc::new(FileAccountRegistry::new(config.registry_path())), + vault: Arc::new(FileSnapshotVault::new(config.vault_dir())), + auto_reset: Arc::new(AutoResetStore::new(config.auto_reset_state_path())), + auth_generation: AtomicU64::new(1), + }) + } + + #[cfg(test)] + pub(crate) fn with_stores_for_test( + auth_manager: Arc, + thread_manager: Arc, + running_turns: watch::Receiver, + transition_mutex: Arc>, + registry: Arc, + vault: Arc, + ) -> Self { + Self { + auth_manager, + thread_manager, + running_turns, + transition_mutex, + registry: Arc::clone(®istry), + vault, + auto_reset: Arc::new(AutoResetStore::new( + registry + .path() + .parent() + .unwrap_or_else(|| std::path::Path::new(".")) + .join("auto-reset.json"), + )), + auth_generation: AtomicU64::new(1), + } + } + + pub(crate) fn status(&self) -> Result { + let state = self + .registry + .state() + .map_err(|_| MarathonServiceError::StateUnavailable)?; + let current_account_id = self + .auth_manager + .auth_cached() + .and_then(|auth| auth.get_account_id()); + let active_turn_count = (*self.running_turns.borrow()).min(u32::MAX as usize) as u32; + let accounts = state + .accounts + .values() + .map(|account| { + let credential_ref = if account.credential_ref.is_empty() { + &account.id + } else { + &account.credential_ref + }; + let credential_present = self.vault.contains(credential_ref).unwrap_or(false); + MarathonAccount { + account_id: account.id.clone(), + alias: account.alias.clone(), + active: state.active_account_id == account.id, + credential_present, + credential_health: credential_health_label(account.credential_health), + weekly_quota_remaining_percent: None, + reset_action_available: false, + } + }) + .collect(); + let auto_reset = self + .auto_reset + .state() + .map_err(|_| MarathonServiceError::StateUnavailable)?; + Ok(MarathonStatusResponse { + enabled: state.enabled, + active_account_id: (!state.active_account_id.is_empty()) + .then_some(state.active_account_id), + current_account_id, + auth_generation: self.auth_generation.load(Ordering::Acquire), + active_turn_count, + accounts, + auto_reset_enabled: auto_reset.enabled, + auto_reset_phase: auto_reset_phase_label(auto_reset.phase).to_string(), + auto_reset_last_error: (!auto_reset.last_error.is_empty()) + .then_some(auto_reset.last_error), + }) + } + + pub(crate) async fn set_auto_reset_enabled( + &self, + enabled: bool, + ) -> Result { + let _transition_guard = self.transition_mutex.lock().await; + let state = self + .auto_reset + .set_enabled(enabled) + .map_err(|_| MarathonServiceError::Persistence)?; + Ok(MarathonAutoResetSetResponse { + enabled: state.enabled, + phase: auto_reset_phase_label(state.phase).to_string(), + }) + } + + pub(crate) async fn set_enabled( + &self, + enabled: bool, + ) -> Result { + let _transition_guard = self.transition_mutex.lock().await; + self.registry + .set_enabled(enabled) + .map_err(|_| MarathonServiceError::Persistence)?; + Ok(MarathonEnabledSetResponse { enabled }) + } + + /// Capture the identity currently held by Codex's native AuthManager. + /// + /// This is the supported way to add an account from the TUI: the user + /// completes the normal Codex login flow first, then this operation saves + /// the resulting opaque snapshot under an operator-facing alias. No auth + /// material crosses the app-server protocol boundary. + pub(crate) async fn import_current( + &self, + alias: &str, + ) -> Result { + let _transition_guard = self.transition_mutex.lock().await; + let state = self + .registry + .state() + .map_err(|_| MarathonServiceError::StateUnavailable)?; + let current_account_id = self + .auth_manager + .auth_cached() + .and_then(|auth| auth.get_account_id()) + .ok_or(MarathonServiceError::AuthenticationRequired)?; + // AccountRecord::new performs the canonical alias validation while + // preserving the exact alias supplied by the user. + let validated = codexmarathon_runtime::AccountRecord::new(¤t_account_id, alias) + .map_err(|_| MarathonServiceError::InvalidAlias)?; + let mut account = state + .accounts + .get(¤t_account_id) + .cloned() + .unwrap_or_else(|| validated.clone()); + if state + .accounts + .values() + .any(|candidate| candidate.id != current_account_id && candidate.alias == alias) + { + return Err(MarathonServiceError::AliasConflict); + } + account.alias = validated.alias; + account.credential_ref = current_account_id.clone(); + account.credential_health = CredentialHealth::Healthy; + + let source_snapshot = self + .auth_manager + .snapshot_for_transition(¤t_account_id) + .await + .map_err(|_| MarathonServiceError::NativeTransition)?; + let source_json = serde_json::to_vec(source_snapshot.as_auth_dot_json()) + .map_err(|_| MarathonServiceError::NativeTransition)?; + let source_snapshot = + codexmarathon_runtime::AuthSnapshot::for_account(¤t_account_id, source_json) + .map_err(|_| MarathonServiceError::NativeTransition)?; + let previous_snapshot = self.vault.load(¤t_account_id).ok(); + self.vault + .save(¤t_account_id, &source_snapshot) + .map_err(|_| MarathonServiceError::Persistence)?; + + let previous_record = state.accounts.get(¤t_account_id).cloned(); + if let Err(error) = self.registry.upsert(account.clone()) { + rollback_import( + &self.registry, + &self.vault, + ¤t_account_id, + previous_record.as_ref(), + previous_snapshot.as_ref(), + ); + let _ = error; + return Err(MarathonServiceError::Persistence); + } + if let Err(error) = self.registry.set_active(¤t_account_id) { + rollback_import( + &self.registry, + &self.vault, + ¤t_account_id, + previous_record.as_ref(), + previous_snapshot.as_ref(), + ); + let _ = error; + return Err(MarathonServiceError::Persistence); + } + + Ok(MarathonImportResponse { + account_id: current_account_id, + alias: account.alias, + active: true, + replaced: previous_snapshot.is_some(), + }) + } + + pub(crate) async fn switch( + &self, + target: &str, + ) -> Result { + let _transition_guard = self.transition_mutex.lock().await; + let state = self + .registry + .state() + .map_err(|_| MarathonServiceError::StateUnavailable)?; + let active_turn_count = (*self.running_turns.borrow()).min(u32::MAX as usize) as u32; + let auth_generation = self.auth_generation.load(Ordering::Acquire); + if let Some((outcome, reason)) = switch_admission(state.enabled, active_turn_count) { + return Ok(MarathonSwitchResponse { + account_id: None, + outcome, + auth_generation, + active_turn_count, + reason: Some(reason.to_string()), + }); + } + + let target_account = resolve_target(&state.accounts, target)?; + let current_account_id = self + .auth_manager + .auth_cached() + .and_then(|auth| auth.get_account_id()) + .ok_or(MarathonServiceError::AuthenticationRequired)?; + if current_account_id == target_account.id { + self.registry + .set_active(&target_account.id) + .map_err(|_| MarathonServiceError::Persistence)?; + return Ok(MarathonSwitchResponse { + account_id: Some(target_account.id), + outcome: MarathonSwitchOutcome::Committed, + auth_generation, + active_turn_count, + reason: Some("account is already active".into()), + }); + } + + // Capture any refreshed source credentials through the guarded native + // AuthManager API before loading the target snapshot. + let source_snapshot = self + .auth_manager + .snapshot_for_transition(¤t_account_id) + .await + .map_err(|_| MarathonServiceError::NativeTransition)?; + let source_json = serde_json::to_vec(source_snapshot.as_auth_dot_json()) + .map_err(|_| MarathonServiceError::NativeTransition)?; + let source_snapshot = + codexmarathon_runtime::AuthSnapshot::for_account(¤t_account_id, source_json) + .map_err(|_| MarathonServiceError::NativeTransition)?; + let source_credential_ref = state + .accounts + .get(¤t_account_id) + .map(|account| { + if account.credential_ref.is_empty() { + account.id.clone() + } else { + account.credential_ref.clone() + } + }) + .unwrap_or_else(|| current_account_id.clone()); + self.vault + .save(&source_credential_ref, &source_snapshot) + .map_err(|_| MarathonServiceError::Persistence)?; + + let target_credential_ref = if target_account.credential_ref.is_empty() { + target_account.id.as_str() + } else { + target_account.credential_ref.as_str() + }; + let target_snapshot = + self.vault + .load(target_credential_ref) + .map_err(|error| match error { + DomainError::SnapshotNotFound => MarathonServiceError::CredentialsUnavailable, + _ => MarathonServiceError::Persistence, + })?; + let target_auth: AuthDotJson = serde_json::from_slice(target_snapshot.bytes()) + .map_err(|_| MarathonServiceError::NativeTransition)?; + let target_auth = AuthTransitionSnapshot::from_auth_dot_json(target_auth) + .map_err(|_| MarathonServiceError::NativeTransition)?; + if target_auth.account_id() != target_account.id { + return Err(MarathonServiceError::NativeTransition); + } + + let status = self + .auth_manager + .install_snapshot_for_transition(target_auth, ¤t_account_id) + .await + .map_err(|_| MarathonServiceError::NativeTransition)?; + let changed = match status { + AuthReloadStatus::Reloaded { changed } => changed, + AuthReloadStatus::Failed => return Err(MarathonServiceError::NativeTransition), + }; + let observed = self + .auth_manager + .auth_cached() + .and_then(|auth| auth.get_account_id()); + if observed.as_deref() != Some(target_account.id.as_str()) { + return Err(MarathonServiceError::NativeTransition); + } + // The service still owns the shared transition mutex while the + // account-bound model transport caches are invalidated. + self.thread_manager + .invalidate_model_transport_caches() + .await; + self.registry + .set_active(&target_account.id) + .map_err(|_| MarathonServiceError::Persistence)?; + let auth_generation = if changed { + self.auth_generation.fetch_add(1, Ordering::AcqRel) + 1 + } else { + self.auth_generation.load(Ordering::Acquire) + }; + Ok(MarathonSwitchResponse { + account_id: Some(target_account.id), + outcome: MarathonSwitchOutcome::Committed, + auth_generation, + active_turn_count, + reason: None, + }) + } +} + +fn resolve_target( + accounts: &std::collections::BTreeMap, + target: &str, +) -> Result { + if target.trim().is_empty() { + return Err(MarathonServiceError::AccountNotFound); + } + if let Some(account) = accounts.get(target) { + return Ok(account.clone()); + } + let matches: Vec<_> = accounts + .values() + .filter(|account| account.alias == target) + .collect(); + match matches.as_slice() { + [account] => Ok((*account).clone()), + [] => Err(MarathonServiceError::AccountNotFound), + _ => Err(MarathonServiceError::AmbiguousAlias), + } +} + +fn credential_health_label(health: CredentialHealth) -> String { + match health { + CredentialHealth::Unknown => "unknown", + CredentialHealth::Healthy => "healthy", + CredentialHealth::Stale => "stale", + CredentialHealth::Invalid => "invalid", + } + .to_string() +} + +fn auto_reset_phase_label(phase: codexmarathon_runtime::AutoResetPhase) -> &'static str { + match phase { + codexmarathon_runtime::AutoResetPhase::Idle => "idle", + codexmarathon_runtime::AutoResetPhase::Attempting => "attempting", + codexmarathon_runtime::AutoResetPhase::Succeeded => "succeeded", + codexmarathon_runtime::AutoResetPhase::Blocked => "blocked", + } +} + +fn rollback_import( + registry: &FileAccountRegistry, + vault: &FileSnapshotVault, + account_id: &str, + previous_record: Option<&codexmarathon_runtime::AccountRecord>, + previous_snapshot: Option<&codexmarathon_runtime::AuthSnapshot>, +) { + if let Some(previous_record) = previous_record { + let _ = registry.upsert(previous_record.clone()); + } else { + let _ = registry.remove(account_id, true); + } + if let Some(previous_snapshot) = previous_snapshot { + let _ = vault.save(account_id, previous_snapshot); + } else { + let _ = vault.delete(account_id); + } +} + +fn switch_admission( + enabled: bool, + active_turn_count: u32, +) -> Option<(MarathonSwitchOutcome, &'static str)> { + if !enabled { + Some((MarathonSwitchOutcome::Rejected, "Marathon is disabled")) + } else if active_turn_count != 0 { + Some(( + MarathonSwitchOutcome::Deferred, + "wait for active turns to finish before switching accounts", + )) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Utc; + use tempfile::tempdir; + + #[test] + fn switching_is_deferred_while_a_turn_is_active() { + assert_eq!( + switch_admission(true, 1), + Some(( + MarathonSwitchOutcome::Deferred, + "wait for active turns to finish before switching accounts" + )) + ); + assert_eq!(switch_admission(true, 0), None); + } + + #[test] + fn disabled_service_rejects_before_account_resolution() { + assert_eq!( + switch_admission(false, 0), + Some((MarathonSwitchOutcome::Rejected, "Marathon is disabled")) + ); + } + + #[test] + fn alias_resolution_requires_one_unambiguous_match() { + let mut accounts = std::collections::BTreeMap::new(); + for id in ["account-a", "account-b"] { + let mut account = + codexmarathon_runtime::AccountRecord::new(id, "same").expect("account"); + account.updated_at = Utc::now(); + accounts.insert(id.to_string(), account); + } + assert!(matches!( + resolve_target(&accounts, "same"), + Err(MarathonServiceError::AmbiguousAlias) + )); + assert_eq!( + resolve_target(&accounts, "account-a") + .expect("id target") + .id, + "account-a" + ); + } + + #[test] + fn enabled_state_is_persisted_without_auth_material() { + let directory = tempdir().expect("tempdir"); + let registry = FileAccountRegistry::new(directory.path().join("accounts.json")); + assert!(!registry.enabled().expect("default state")); + registry.set_enabled(true).expect("enable"); + assert!(registry.enabled().expect("persisted state")); + let contents = std::fs::read_to_string(registry.path()).expect("registry"); + assert!(!contents.contains("access_token")); + assert!(!contents.contains("refresh_token")); + } +} diff --git a/runtime/codex-rs/app-server/src/message_processor.rs b/runtime/codex-rs/app-server/src/message_processor.rs index 5c89fe14..febc5959 100644 --- a/runtime/codex-rs/app-server/src/message_processor.rs +++ b/runtime/codex-rs/app-server/src/message_processor.rs @@ -5,8 +5,8 @@ use std::sync::OnceLock; use std::sync::atomic::AtomicBool; use crate::attestation::app_server_attestation_provider; -use crate::config_manager::ConfigManager; use crate::codexmarathon_bridge::CodexMarathonRecoveryBridge; +use crate::config_manager::ConfigManager; use crate::connection_rpc_gate::ConnectionRpcGate; use crate::current_time::app_server_time_provider; use crate::error_code::internal_error; @@ -33,6 +33,7 @@ use crate::request_processors::FeedbackRequestProcessor; use crate::request_processors::FsRequestProcessor; use crate::request_processors::GitRequestProcessor; use crate::request_processors::InitializeRequestProcessor; +use crate::request_processors::MarathonRequestProcessor; use crate::request_processors::MarketplaceRequestProcessor; use crate::request_processors::McpEventStreamReady; use crate::request_processors::McpEventStreams; @@ -80,7 +81,6 @@ use codex_feedback::CodexFeedback; use codex_goal_extension::GoalService; use codex_home::CodexHomeUserInstructionsProvider; use codex_login::AuthManager; -use codexmarathon_runtime::{CodexNativeRuntime, NativeAuthConfig}; use codex_protocol::ThreadId; use codex_protocol::mcp::ClientMcpExtensions; use codex_protocol::protocol::SessionSource; @@ -90,6 +90,7 @@ use codex_rollout::StateDbHandle; use codex_state::log_db::LogDbLayer; use codex_thread_store::LocalQueueStore; use codex_thread_store::QueueStore; +use codexmarathon_runtime::{CodexNativeRuntime, MarathonConfig, NativeAuthConfig}; use tokio::sync::Mutex; use tokio::sync::Semaphore; use tokio::sync::broadcast; @@ -99,6 +100,7 @@ use tokio::time::timeout; use tokio_util::sync::CancellationToken; use tracing::Instrument; +use crate::marathon_service::MarathonService; use crate::models_refresh_worker::ModelsRefreshWorker; const CONNECTION_RPC_DRAIN_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 30); @@ -153,6 +155,7 @@ pub(crate) struct MessageProcessor { git_processor: GitRequestProcessor, initialize_processor: InitializeRequestProcessor, marketplace_processor: MarketplaceRequestProcessor, + marathon_processor: MarathonRequestProcessor, mcp_processor: McpRequestProcessor, plugin_processor: PluginRequestProcessor, project_processor: ProjectRequestProcessor, @@ -514,7 +517,7 @@ impl MessageProcessor { config_warnings, ); let turn_processor = TurnRequestProcessor::new( - auth_manager, + Arc::clone(&auth_manager), Arc::clone(&thread_manager), outgoing.clone(), analytics_events_client.clone(), @@ -524,11 +527,24 @@ impl MessageProcessor { pending_thread_unloads, thread_state_manager, thread_watch_manager, - auth_transition_lock, + Arc::clone(&auth_transition_lock), thread_list_state_permit, Arc::clone(&skills_watcher), turn_cost_worker.as_ref().map(TurnCostWorker::handle), ); + let marathon_config = MarathonConfig::new(config.codex_home.to_path_buf()) + .expect("Codex config must contain a non-empty codex home"); + let marathon_service = Arc::new( + MarathonService::from_config( + Arc::clone(&auth_manager), + Arc::clone(&thread_manager), + thread_processor.subscribe_running_assistant_turn_count(), + Arc::clone(&auth_transition_lock), + &marathon_config, + ) + .expect("Codex config must produce valid Marathon state paths"), + ); + let marathon_processor = MarathonRequestProcessor::new(marathon_service); if matches!(plugin_startup_tasks, crate::PluginStartupTasks::Start) { // Keep plugin startup warmups aligned at app-server startup. let on_effective_plugins_changed = @@ -585,6 +601,7 @@ impl MessageProcessor { git_processor, initialize_processor, marketplace_processor, + marathon_processor, mcp_processor, plugin_processor, project_processor, @@ -1092,6 +1109,19 @@ impl MessageProcessor { .clients_revoke(params) .await .map(|response| Some(response.into())), + ClientRequest::MarathonStatus { .. } => self.marathon_processor.status(), + ClientRequest::MarathonEnabledSet { params, .. } => { + self.marathon_processor.enabled_set(params).await + } + ClientRequest::MarathonAutoResetSet { params, .. } => { + self.marathon_processor.auto_reset_set(params).await + } + ClientRequest::MarathonSwitch { params, .. } => { + self.marathon_processor.switch(params).await + } + ClientRequest::MarathonImport { params, .. } => { + self.marathon_processor.import(params).await + } ClientRequest::ConfigRequirementsRead { params: _, .. } => self .config_processor .config_requirements_read() diff --git a/runtime/codex-rs/app-server/src/request_processors.rs b/runtime/codex-rs/app-server/src/request_processors.rs index c2d236cc..92fc957a 100644 --- a/runtime/codex-rs/app-server/src/request_processors.rs +++ b/runtime/codex-rs/app-server/src/request_processors.rs @@ -592,9 +592,8 @@ async fn handle_auth_reload_status( match status { AuthReloadStatus::Reloaded { changed } => { if changed { - let invalidated_thread_count = thread_manager - .invalidate_model_transport_caches() - .await; + let invalidated_thread_count = + thread_manager.invalidate_model_transport_caches().await; info!( "auth reloaded from storage before {reason}; invalidated model transport caches for {invalidated_thread_count} tracked thread(s)" ); @@ -638,6 +637,7 @@ mod feedback_processor; mod fs_processor; mod git_processor; mod initialize_processor; +mod marathon_processor; mod marketplace_processor; mod mcp_event_stream; mod mcp_processor; @@ -668,6 +668,7 @@ pub(crate) use feedback_processor::FeedbackRequestProcessor; pub(crate) use fs_processor::FsRequestProcessor; pub(crate) use git_processor::GitRequestProcessor; pub(crate) use initialize_processor::InitializeRequestProcessor; +pub(crate) use marathon_processor::MarathonRequestProcessor; pub(crate) use marketplace_processor::MarketplaceRequestProcessor; pub(crate) use mcp_event_stream::McpEventStreamReady; pub(crate) use mcp_event_stream::McpEventStreams; diff --git a/runtime/codex-rs/app-server/src/request_processors/marathon_processor.rs b/runtime/codex-rs/app-server/src/request_processors/marathon_processor.rs new file mode 100644 index 00000000..e508e194 --- /dev/null +++ b/runtime/codex-rs/app-server/src/request_processors/marathon_processor.rs @@ -0,0 +1,79 @@ +use crate::error_code::{internal_error, invalid_params}; +use crate::marathon_service::{MarathonService, MarathonServiceError}; +use codex_app_server_protocol::{ + ClientResponsePayload, JSONRPCErrorError, MarathonAutoResetSetParams, MarathonEnabledSetParams, + MarathonImportParams, MarathonSwitchParams, +}; +use std::sync::Arc; + +#[derive(Clone)] +pub(crate) struct MarathonRequestProcessor { + service: Arc, +} + +impl MarathonRequestProcessor { + pub(crate) fn new(service: Arc) -> Self { + Self { service } + } + + pub(crate) fn status(&self) -> Result, JSONRPCErrorError> { + self.service + .status() + .map(|response| Some(response.into())) + .map_err(map_service_error) + } + + pub(crate) async fn enabled_set( + &self, + params: MarathonEnabledSetParams, + ) -> Result, JSONRPCErrorError> { + self.service + .set_enabled(params.enabled) + .await + .map(|response| Some(response.into())) + .map_err(map_service_error) + } + + pub(crate) async fn auto_reset_set( + &self, + params: MarathonAutoResetSetParams, + ) -> Result, JSONRPCErrorError> { + self.service + .set_auto_reset_enabled(params.enabled) + .await + .map(|response| Some(response.into())) + .map_err(map_service_error) + } + + pub(crate) async fn switch( + &self, + params: MarathonSwitchParams, + ) -> Result, JSONRPCErrorError> { + self.service + .switch(¶ms.target) + .await + .map(|response| Some(response.into())) + .map_err(map_service_error) + } + + pub(crate) async fn import( + &self, + params: MarathonImportParams, + ) -> Result, JSONRPCErrorError> { + self.service + .import_current(¶ms.alias) + .await + .map(|response| Some(response.into())) + .map_err(map_service_error) + } +} + +fn map_service_error(error: MarathonServiceError) -> JSONRPCErrorError { + match error { + MarathonServiceError::AccountNotFound + | MarathonServiceError::AmbiguousAlias + | MarathonServiceError::AliasConflict + | MarathonServiceError::InvalidAlias => invalid_params(error.to_string()), + _ => internal_error(error.to_string()), + } +} diff --git a/runtime/codex-rs/cli/Cargo.toml b/runtime/codex-rs/cli/Cargo.toml index 946fab80..1fb1ded9 100644 --- a/runtime/codex-rs/cli/Cargo.toml +++ b/runtime/codex-rs/cli/Cargo.toml @@ -28,6 +28,7 @@ chrono = { workspace = true } clap = { workspace = true, features = ["derive", "env"] } clap_complete = { workspace = true } codex-app-server = { workspace = true } +codex-app-server-client = { workspace = true } codex-app-server-daemon = { workspace = true } codex-app-server-protocol = { workspace = true } codex-app-server-test-client = { workspace = true } @@ -48,6 +49,7 @@ codex-exec-server = { workspace = true } codex-execpolicy = { workspace = true } codex-extension-api = { workspace = true } codex-features = { workspace = true } +codex-feedback = { workspace = true } codex-git-attribution = { workspace = true } codex-git-utils = { workspace = true } codex-install-context = { workspace = true } diff --git a/runtime/codex-rs/cli/src/main.rs b/runtime/codex-rs/cli/src/main.rs index 0bfea044..4b701d5c 100644 --- a/runtime/codex-rs/cli/src/main.rs +++ b/runtime/codex-rs/cli/src/main.rs @@ -50,6 +50,7 @@ mod cloud_config; mod desktop_app; mod doctor; mod exec_server_telemetry; +mod marathon; mod marketplace_cmd; mod mcp_cmd; mod migrate_rollouts; @@ -152,6 +153,9 @@ enum Subcommand { /// Manage Codex plugins. Plugin(PluginCli), + /// Manage native CodexMarathon accounts and switching. + Marathon(marathon::MarathonCommand), + /// Start Codex as an MCP server (stdio). McpServer(McpServerCommand), @@ -1242,6 +1246,23 @@ async fn cli_main( } } } + Some(Subcommand::Marathon(marathon_cli)) => { + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "marathon", + )?; + let loader_overrides = + loader_overrides_for_profile(interactive.config_profile_v2.as_ref())?; + marathon::run( + marathon_cli, + &root_config_overrides, + root_strict_config, + arg0_paths.clone(), + loader_overrides, + ) + .await?; + } Some(Subcommand::AppServer(app_server_cli)) => { let AppServerCommand { subcommand, @@ -1840,6 +1861,7 @@ fn profile_v2_for_subcommand<'a>( | Subcommand::Unarchive(_) | Subcommand::Fork(_) | Subcommand::Mcp(_) + | Subcommand::Marathon(_) | Subcommand::Sandbox(_) | Subcommand::Debug(DebugCommand { subcommand: DebugSubcommand::PromptInput(_), @@ -2422,6 +2444,7 @@ fn unsupported_subcommand_name_for_strict_config( | Some(Subcommand::Delete(_)) | Some(Subcommand::Unarchive(_)) | Some(Subcommand::Fork(_)) + | Some(Subcommand::Marathon(_)) | Some(Subcommand::Doctor(_)) => None, Some(Subcommand::AppServer(app_server)) if app_server.subcommand.is_none() => None, Some(Subcommand::AppServer(app_server)) => { diff --git a/runtime/codex-rs/cli/src/marathon.rs b/runtime/codex-rs/cli/src/marathon.rs new file mode 100644 index 00000000..3b0e53da --- /dev/null +++ b/runtime/codex-rs/cli/src/marathon.rs @@ -0,0 +1,591 @@ +//! Native CodexMarathon command-line controls. +//! +//! This module talks to an embedded app-server client in the same `codex` +//! process. It deliberately does not know about the legacy Go controller or +//! its Unix socket environment variable; all state changes go through the +//! typed app-server Marathon requests. + +use clap::Parser; +use codex_app_server_client::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY; +use codex_app_server_client::InProcessAppServerClient; +use codex_app_server_client::InProcessClientStartArgs; +use codex_app_server_client::InProcessServerEvent; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::LoginAccountResponse; +use codex_app_server_protocol::MarathonAccount; +use codex_app_server_protocol::MarathonAutoResetSetParams; +use codex_app_server_protocol::MarathonAutoResetSetResponse; +use codex_app_server_protocol::MarathonEnabledSetParams; +use codex_app_server_protocol::MarathonEnabledSetResponse; +use codex_app_server_protocol::MarathonImportParams; +use codex_app_server_protocol::MarathonImportResponse; +use codex_app_server_protocol::MarathonStatusResponse; +use codex_app_server_protocol::MarathonSwitchOutcome; +use codex_app_server_protocol::MarathonSwitchParams; +use codex_app_server_protocol::MarathonSwitchResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_arg0::Arg0DispatchPaths; +use codex_cloud_config::cloud_config_bundle_loader_for_storage; +use codex_config::LoaderOverrides; +use codex_core::config::ConfigBuilder; +use codex_exec_server::EnvironmentManager; +use codex_feedback::CodexFeedback; +use codex_utils_cli::CliConfigOverrides; +use serde::de::DeserializeOwned; +use std::io::IsTerminal; +use std::io::Write; +use std::sync::Arc; +use std::time::Duration; + +#[derive(Debug, Parser)] +pub(crate) struct MarathonCommand { + /// Control the native Marathon service. With no action, print its status. + #[command(subcommand)] + pub action: Option, +} + +#[derive(Debug, clap::Subcommand)] +pub(crate) enum MarathonAction { + /// Print enabled state and the registered account list. + Status, + + /// Alias for `status` that emphasizes the account list. + Accounts, + + /// Enable automatic native Marathon account management. + On, + + /// Disable automatic native Marathon account management. + Off, + + /// Configure automatic use of a provider-supported quota-reset action + /// once every eligible managed account reaches zero weekly quota. + AutoReset { + #[command(subcommand)] + action: AutoResetAction, + }, + + /// Store the currently authenticated native Codex identity under ALIAS. + Import { + /// Local account alias, for example `personal` or `work`. + alias: String, + }, + + /// Start the normal native ChatGPT login flow, then store it under ALIAS. + Login { + /// Local account alias to assign after login succeeds. + alias: String, + /// Use the browser-link flow. This is the default choice in an interactive terminal. + #[arg(long, conflicts_with = "device_code")] + browser: bool, + /// Use device authentication and enter the one-time code from another machine. + #[arg(long, conflicts_with = "browser")] + device_code: bool, + }, + + /// Switch the native Codex identity to an account id or alias. + Switch { + /// Registered account alias or account id. + target: String, + }, +} + +#[derive(Debug, clap::Subcommand)] +pub(crate) enum AutoResetAction { + /// Show automatic reset-action state. + Status, + /// Enable automatic reset-action use. It remains disabled by default. + On, + /// Disable automatic reset-action use. + Off, +} + +pub(crate) async fn run( + command: MarathonCommand, + root_config_overrides: &CliConfigOverrides, + strict_config: bool, + arg0_paths: Arg0DispatchPaths, + loader_overrides: LoaderOverrides, +) -> anyhow::Result<()> { + let action = command.action.unwrap_or(MarathonAction::Status); + let mut client = start_client( + root_config_overrides, + strict_config, + arg0_paths, + loader_overrides, + ) + .await?; + + let result = run_action(&mut client, action).await; + let shutdown_result = client.shutdown().await; + result?; + shutdown_result.map_err(anyhow::Error::from) +} + +async fn start_client( + root_config_overrides: &CliConfigOverrides, + strict_config: bool, + arg0_paths: Arg0DispatchPaths, + loader_overrides: LoaderOverrides, +) -> anyhow::Result { + let cli_overrides = root_config_overrides + .parse_overrides() + .map_err(anyhow::Error::msg)?; + + // Build once without managed cloud config so the native auth configuration + // can choose the same cloud loader as the interactive TUI. + let bootstrap_config = ConfigBuilder::default() + .cli_overrides(cli_overrides.clone()) + .loader_overrides(loader_overrides.clone()) + .strict_config(strict_config) + .build() + .await?; + let cloud_config_bundle = cloud_config_bundle_loader_for_storage( + bootstrap_config.auth_config(), + /*enable_codex_api_key_env*/ false, + ) + .await?; + let config = ConfigBuilder::default() + .cli_overrides(cli_overrides.clone()) + .loader_overrides(loader_overrides.clone()) + .strict_config(strict_config) + .cloud_config_bundle(cloud_config_bundle.clone()) + .build() + .await?; + + let config_warnings = config + .startup_warnings + .iter() + .map( + |summary| codex_app_server_protocol::ConfigWarningNotification { + summary: summary.clone(), + details: None, + path: None, + range: None, + }, + ) + .collect(); + let environment_manager = Arc::new(EnvironmentManager::without_environments( + config.http_client_factory(), + )); + + Ok(InProcessAppServerClient::start(InProcessClientStartArgs { + arg0_paths, + config: Arc::new(config), + cli_overrides, + loader_overrides, + strict_config, + cloud_config_bundle, + feedback: CodexFeedback::new(), + log_db: None, + state_db: None, + environment_manager, + config_warnings, + session_source: serde_json::from_value(serde_json::json!("cli"))?, + enable_codex_api_key_env: false, + client_name: "codex-cli".to_string(), + client_version: env!("CARGO_PKG_VERSION").to_string(), + experimental_api: true, + mcp_server_openai_form_elicitation: false, + opt_out_notification_methods: Vec::new(), + channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, + }) + .await?) +} + +async fn run_action( + client: &mut InProcessAppServerClient, + action: MarathonAction, +) -> anyhow::Result<()> { + match action { + MarathonAction::Status | MarathonAction::Accounts => { + let status = request( + client, + ClientRequest::MarathonStatus { + request_id: request_id("marathon-status"), + params: None, + }, + ) + .await?; + print_status(&status); + } + MarathonAction::On | MarathonAction::Off => { + let enabled = matches!(action, MarathonAction::On); + let response: MarathonEnabledSetResponse = request( + client, + ClientRequest::MarathonEnabledSet { + request_id: request_id("marathon-enabled"), + params: MarathonEnabledSetParams { enabled }, + }, + ) + .await?; + println!( + "Native Marathon is {}.", + if response.enabled { + "enabled" + } else { + "disabled" + } + ); + } + MarathonAction::AutoReset { action } => match action { + AutoResetAction::Status => { + let status: MarathonStatusResponse = request( + client, + ClientRequest::MarathonStatus { + request_id: request_id("marathon-auto-reset-status"), + params: None, + }, + ) + .await?; + println!( + "Automatic quota reset: {} (phase: {}).{}", + if status.auto_reset_enabled { + "enabled" + } else { + "disabled" + }, + status.auto_reset_phase, + status + .auto_reset_last_error + .as_deref() + .map(|error| format!(" Last result: {error}.")) + .unwrap_or_default() + ); + } + AutoResetAction::On | AutoResetAction::Off => { + let enabled = matches!(action, AutoResetAction::On); + let response: MarathonAutoResetSetResponse = request( + client, + ClientRequest::MarathonAutoResetSet { + request_id: request_id("marathon-auto-reset-set"), + params: MarathonAutoResetSetParams { enabled }, + }, + ) + .await?; + println!( + "Automatic provider quota reset is {} (phase: {}).", + if response.enabled { + "enabled" + } else { + "disabled" + }, + response.phase + ); + } + }, + MarathonAction::Import { alias } => { + let response: MarathonImportResponse = request( + client, + ClientRequest::MarathonImport { + request_id: request_id("marathon-import"), + params: MarathonImportParams { alias }, + }, + ) + .await?; + println!( + "Imported native account `{}` as `{}` ({}).", + response.account_id, + response.alias, + if response.replaced { + "updated existing account" + } else { + "created account" + } + ); + } + MarathonAction::Switch { target } => { + let response: MarathonSwitchResponse = request( + client, + ClientRequest::MarathonSwitch { + request_id: request_id("marathon-switch"), + params: MarathonSwitchParams { target }, + }, + ) + .await?; + print_switch(&response)?; + } + MarathonAction::Login { + alias, + browser, + device_code, + } => { + let mode = if browser { + LoginMode::Browser + } else if device_code { + LoginMode::DeviceCode + } else { + choose_login_mode()? + }; + login_and_import(client, alias, mode).await? + } + } + Ok(()) +} + +async fn login_and_import( + client: &mut InProcessAppServerClient, + alias: String, + mode: LoginMode, +) -> anyhow::Result<()> { + let params = match mode { + LoginMode::Browser => codex_app_server_protocol::LoginAccountParams::Chatgpt { + codex_streamlined_login: false, + use_hosted_login_success_page: false, + app_brand: None, + }, + LoginMode::DeviceCode => codex_app_server_protocol::LoginAccountParams::ChatgptDeviceCode, + }; + let response: LoginAccountResponse = request( + client, + ClientRequest::LoginAccount { + request_id: request_id("marathon-login"), + params, + }, + ) + .await?; + + let login_id = match response { + LoginAccountResponse::Chatgpt { login_id, auth_url } => { + println!("Open this URL to sign in:\n{auth_url}"); + login_id + } + LoginAccountResponse::ChatgptDeviceCode { + login_id, + verification_url, + user_code, + } => { + println!( + "Open {verification_url} on another machine and enter this one-time code:\n{user_code}" + ); + login_id + } + other => anyhow::bail!("native ChatGPT login returned unexpected response: {other:?}"), + }; + + let completion = tokio::time::timeout( + Duration::from_secs(10 * 60), + wait_for_login_completion(client, &login_id), + ) + .await + .map_err(|_| anyhow::anyhow!("native login timed out"))??; + if !completion.success { + anyhow::bail!( + "native login failed{}", + completion + .error + .as_deref() + .map(|error| format!(": {error}")) + .unwrap_or_default() + ); + } + + let imported: MarathonImportResponse = request( + client, + ClientRequest::MarathonImport { + request_id: request_id("marathon-login-import"), + params: MarathonImportParams { alias }, + }, + ) + .await?; + println!( + "Login completed; imported native account `{}` as `{}`.", + imported.account_id, imported.alias + ); + Ok(()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LoginMode { + Browser, + DeviceCode, +} + +fn choose_login_mode() -> anyhow::Result { + if !(std::io::stdin().is_terminal() && std::io::stderr().is_terminal()) { + anyhow::bail!( + "choose a login flow explicitly when no terminal is attached: use --browser or --device-code" + ); + } + + eprintln!("Choose how to sign in the Marathon account:"); + eprintln!(" 1) Browser link (this machine)"); + eprintln!(" 2) Device/auth code (headless server or another machine)"); + eprint!("Choice [1]: "); + std::io::stderr().flush()?; + let mut choice = String::new(); + std::io::stdin().read_line(&mut choice)?; + match choice.trim() { + "" | "1" => Ok(LoginMode::Browser), + "2" => Ok(LoginMode::DeviceCode), + _ => anyhow::bail!("invalid choice; enter 1 for browser link or 2 for device/auth code"), + } +} + +async fn wait_for_login_completion( + client: &mut InProcessAppServerClient, + login_id: &str, +) -> anyhow::Result { + let mut completion = None; + loop { + let event = client + .next_event() + .await + .ok_or_else(|| anyhow::anyhow!("native app-server disconnected during login"))?; + let InProcessServerEvent::ServerNotification(notification) = event else { + continue; + }; + match notification.as_ref() { + ServerNotification::AccountLoginCompleted(login_completion) + if login_completion.login_id.as_deref() == Some(login_id) => + { + if !login_completion.success { + return Ok(login_completion.clone()); + } + completion = Some(login_completion.clone()); + } + // The app-server emits completion before it reloads AuthManager, + // then emits AccountUpdated. Waiting for this second notification + // ensures `marathon/import` snapshots the new native identity. + ServerNotification::AccountUpdated(_) if completion.is_some() => { + return Ok(completion.take().expect("completion was checked")); + } + _ => {} + } + } +} + +async fn request( + client: &InProcessAppServerClient, + request: ClientRequest, +) -> anyhow::Result { + client + .request_typed(request) + .await + .map_err(|error| anyhow::anyhow!(error.to_string())) +} + +fn request_id(label: &str) -> RequestId { + RequestId::String(label.to_string()) +} + +fn print_status(status: &MarathonStatusResponse) { + println!( + "Native Marathon: {}", + if status.enabled { + "enabled" + } else { + "disabled" + } + ); + println!( + "Active account: {}", + status.active_account_id.as_deref().unwrap_or("none") + ); + println!( + "Automatic quota reset: {} (phase: {})", + if status.auto_reset_enabled { + "enabled" + } else { + "disabled" + }, + status.auto_reset_phase + ); + if let Some(error) = &status.auto_reset_last_error { + println!("Automatic quota reset last result: {error}"); + } + println!("Accounts: {}", status.accounts.len()); + for account in &status.accounts { + print_account(account); + } +} + +fn print_account(account: &MarathonAccount) { + let marker = if account.active { "*" } else { " " }; + println!( + " {marker} {} ({}) — credential {}, {}, weekly quota {}, reset action {}", + account.alias, + account.account_id, + if account.credential_present { + "present" + } else { + "missing" + }, + account.credential_health, + account.weekly_quota_remaining_percent.map_or_else( + || "unknown".to_string(), + |value| format!("{value:.1}% remaining") + ), + if account.reset_action_available { + "available" + } else { + "unavailable" + } + ); +} + +fn print_switch(response: &MarathonSwitchResponse) -> anyhow::Result<()> { + match response.outcome { + MarathonSwitchOutcome::Committed => { + println!( + "Switched native Codex account to {}.", + response.account_id.as_deref().unwrap_or("unknown") + ); + Ok(()) + } + MarathonSwitchOutcome::Deferred => anyhow::bail!( + "switch deferred until active turns finish{}", + response + .reason + .as_deref() + .map(|reason| format!(": {reason}")) + .unwrap_or_default() + ), + MarathonSwitchOutcome::Rejected => anyhow::bail!( + "switch rejected{}", + response + .reason + .as_deref() + .map(|reason| format!(": {reason}")) + .unwrap_or_default() + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bare_command_defaults_to_status() { + let command = MarathonCommand::try_parse_from(["marathon"]).expect("valid command"); + assert!(command.action.is_none()); + } + + #[test] + fn parses_account_actions() { + let command = + MarathonCommand::try_parse_from(["marathon", "import", "work"]).expect("valid command"); + assert!(matches!( + command.action, + Some(MarathonAction::Import { alias }) if alias == "work" + )); + + let command = MarathonCommand::try_parse_from(["marathon", "switch", "personal"]) + .expect("valid command"); + assert!(matches!( + command.action, + Some(MarathonAction::Switch { target }) if target == "personal" + )); + + let command = MarathonCommand::try_parse_from(["marathon", "auto-reset", "on"]) + .expect("valid command"); + assert!(matches!( + command.action, + Some(MarathonAction::AutoReset { + action: AutoResetAction::On + }) + )); + } +} diff --git a/runtime/codex-rs/codexmarathon-runtime/Cargo.toml b/runtime/codex-rs/codexmarathon-runtime/Cargo.toml index 925e2676..55829721 100644 --- a/runtime/codex-rs/codexmarathon-runtime/Cargo.toml +++ b/runtime/codex-rs/codexmarathon-runtime/Cargo.toml @@ -9,13 +9,15 @@ description = "Embedded CodexMarathon runtime bridge for the Codex CLI" workspace = true [dependencies] +chrono = { workspace = true, features = ["serde"] } codex-backend-client = { workspace = true } codex-core = { workspace = true } codex-http-client = { workspace = true } codex-login = { workspace = true } codex-protocol = { workspace = true } codexmarathon-runtime-adapter = { workspace = true } -serde = { workspace = true } +serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +thiserror = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true, features = ["io-util", "net", "rt", "sync", "time"] } diff --git a/runtime/codex-rs/codexmarathon-runtime/src/accounts.rs b/runtime/codex-rs/codexmarathon-runtime/src/accounts.rs new file mode 100644 index 00000000..507ecd18 --- /dev/null +++ b/runtime/codex-rs/codexmarathon-runtime/src/accounts.rs @@ -0,0 +1,559 @@ +//! Native account records and the non-secret account registry. +//! +//! Account records contain identity and operator metadata only. Authentication +//! material lives behind [`crate::vault::SnapshotVault`] and is referenced by +//! `credential_ref`; keeping the two stores separate prevents a registry or +//! journal read from becoming an accidental token read. + +use crate::errors::{DomainError, DomainResult}; +use crate::persistence::{atomic_write, ensure_private_dir, reject_unsafe_file}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +/// Current on-disk account registry schema version. +pub const REGISTRY_VERSION: u32 = 1; + +/// Secret-free state of the credential snapshot referenced by an account. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum CredentialHealth { + /// No successful validation or refresh has been recorded. + #[default] + Unknown, + /// The native runtime last accepted the snapshot. + Healthy, + /// The snapshot needs a native refresh before it can be used. + Stale, + /// The snapshot failed a native validation step. + Invalid, +} + +/// A non-secret account/profile record. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct AccountRecord { + /// Stable provider or locally assigned identity. + pub id: String, + /// Optional operator-facing display label. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub alias: String, + /// Opaque vault key. Defaults to `id` when persisted. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub credential_ref: String, + /// Descriptive metadata only. Sensitive keys are rejected. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub metadata: BTreeMap, + /// Last time account telemetry was observed, if any. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_telemetry_at: Option>, + /// Non-secret source label for telemetry diagnostics. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub telemetry_source: String, + /// Controller knowledge about the referenced snapshot. + #[serde(default)] + pub credential_health: CredentialHealth, + /// Registry creation time. + pub created_at: DateTime, + /// Last registry mutation time. + pub updated_at: DateTime, +} + +impl AccountRecord { + /// Construct a record with the current UTC time and default vault key. + pub fn new(id: impl Into, alias: impl Into) -> DomainResult { + let id = id.into(); + let alias = alias.into(); + validate_account_id(&id)?; + validate_alias(&alias)?; + let now = Utc::now(); + Ok(Self { + credential_ref: id.clone(), + id, + alias, + metadata: BTreeMap::new(), + last_telemetry_at: None, + telemetry_source: String::new(), + credential_health: CredentialHealth::Unknown, + created_at: now, + updated_at: now, + }) + } + + /// Validate the identity and all non-secret metadata fields. + pub fn validate(&self) -> DomainResult<()> { + validate_account_id(&self.id)?; + validate_alias(&self.alias)?; + if !self.credential_ref.is_empty() { + validate_account_id(&self.credential_ref).map_err(|_| { + DomainError::InvalidAccountRecord("credential_ref is not a valid vault key") + })?; + } + for (key, value) in &self.metadata { + validate_metadata_entry(key, value)?; + } + validate_text_field(&self.telemetry_source, "telemetry_source")?; + if self.created_at > self.updated_at { + return Err(DomainError::InvalidAccountRecord( + "created_at is after updated_at", + )); + } + Ok(()) + } + + /// Return the display label, falling back to the stable identity. + pub fn display_name(&self) -> &str { + if self.alias.is_empty() { + &self.id + } else { + &self.alias + } + } +} + +/// Validate an account ID before using it as a filename component. +pub fn validate_account_id(value: &str) -> DomainResult<()> { + if value.is_empty() || value == "." || value == ".." || value.len() > 240 { + return Err(DomainError::InvalidAccountId); + } + if value + .chars() + .any(|character| character == '\0' || character.is_control()) + { + return Err(DomainError::InvalidAccountId); + } + if value + .chars() + .any(|character| "/\\<>:\"|?*".contains(character)) + || value.ends_with('.') + || value.ends_with(' ') + { + return Err(DomainError::InvalidAccountId); + } + let base = value.split('.').next().unwrap_or("").to_ascii_uppercase(); + if matches!( + base.as_str(), + "CON" + | "PRN" + | "AUX" + | "NUL" + | "COM1" + | "COM2" + | "COM3" + | "COM4" + | "COM5" + | "COM6" + | "COM7" + | "COM8" + | "COM9" + | "LPT1" + | "LPT2" + | "LPT3" + | "LPT4" + | "LPT5" + | "LPT6" + | "LPT7" + | "LPT8" + | "LPT9" + ) { + return Err(DomainError::InvalidAccountId); + } + Ok(()) +} + +/// Validate an optional, single-line operator-facing alias. +pub fn validate_alias(value: &str) -> DomainResult<()> { + if value.trim() != value || value.len() > 256 { + return Err(DomainError::InvalidAlias); + } + if value + .chars() + .any(|character| character == '\0' || character.is_control()) + { + return Err(DomainError::InvalidAlias); + } + Ok(()) +} + +fn validate_text_field(value: &str, field: &'static str) -> DomainResult<()> { + if value.len() > 4096 + || value + .chars() + .any(|character| character == '\0' || character.is_control()) + { + return Err(DomainError::InvalidAccountRecord(field)); + } + Ok(()) +} + +fn validate_metadata_entry(key: &str, value: &str) -> DomainResult<()> { + if key.trim().is_empty() + || key + .chars() + .any(|character| character == '\0' || character.is_control()) + { + return Err(DomainError::InvalidAccountRecord("invalid metadata key")); + } + if value + .chars() + .any(|character| character == '\0' || character.is_control()) + { + return Err(DomainError::InvalidAccountRecord("invalid metadata value")); + } + let normalized = key.replace('-', "_").replace(' ', "_").to_ascii_lowercase(); + if [ + "access_token", + "id_token", + "refresh_token", + "authorization", + "api_key", + "password", + "secret", + "auth_json", + "credential", + "private_key", + ] + .iter() + .any(|marker| normalized.contains(marker)) + { + return Err(DomainError::InvalidAccountRecord( + "metadata key may contain credential material", + )); + } + Ok(()) +} + +/// Serializable registry state. +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct RegistryState { + /// Persisted schema version. + #[serde(default = "default_registry_version")] + pub version: u32, + /// Selected account, if one has been activated. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub active_account_id: String, + /// Whether the native Marathon service may perform account transitions. + /// Defaults to disabled for existing registries so adding the service is + /// behavior preserving until an operator explicitly enables it. + #[serde(default)] + pub enabled: bool, + /// Records keyed by stable account ID. + #[serde(default)] + pub accounts: BTreeMap, +} + +fn default_registry_version() -> u32 { + REGISTRY_VERSION +} + +/// Storage-neutral account registry surface. +pub trait AccountStore: Send + Sync { + /// Insert a new account and reject duplicate IDs. + fn register(&self, account: AccountRecord) -> DomainResult<()>; + /// Insert or update an account while preserving omitted vault metadata. + fn upsert(&self, account: AccountRecord) -> DomainResult<()>; + /// Read one account record. + fn get(&self, account_id: &str) -> DomainResult; + /// List records in stable ID order. + fn list(&self) -> DomainResult>; + /// Mark an existing account as active. + fn set_active(&self, account_id: &str) -> DomainResult<()>; + /// Clear the active marker. + fn clear_active(&self) -> DomainResult<()>; + /// Return the active record, if configured. + fn active(&self) -> DomainResult>; + /// Remove an account. Active removal requires `force`. + fn remove(&self, account_id: &str, force: bool) -> DomainResult<()>; +} + +/// JSON-backed account registry with owner-only persistence and atomic writes. +pub struct FileAccountRegistry { + path: PathBuf, + gate: Mutex<()>, + clock: Arc DateTime + Send + Sync>, +} + +impl FileAccountRegistry { + /// Construct a registry without creating its file. + pub fn new(path: impl Into) -> Self { + Self::with_clock(path, Arc::new(Utc::now)) + } + + /// Construct a registry with an injectable clock for deterministic tests. + pub fn with_clock( + path: impl Into, + clock: Arc DateTime + Send + Sync>, + ) -> Self { + Self { + path: path.into(), + gate: Mutex::new(()), + clock, + } + } + + /// Return the registry path without reading its contents. + pub fn path(&self) -> &Path { + &self.path + } + + /// Read the full state for diagnostics and migration code. + pub fn state(&self) -> DomainResult { + let _guard = self.gate.lock().map_err(|_| DomainError::InvalidRegistry)?; + self.load_unlocked() + } + + /// Return whether native Marathon transitions are enabled. + pub fn enabled(&self) -> DomainResult { + let _guard = self.gate.lock().map_err(|_| DomainError::InvalidRegistry)?; + Ok(self.load_unlocked()?.enabled) + } + + /// Persist the native Marathon enabled flag atomically. + pub fn set_enabled(&self, enabled: bool) -> DomainResult<()> { + let _guard = self.gate.lock().map_err(|_| DomainError::InvalidRegistry)?; + let mut state = self.load_unlocked()?; + state.enabled = enabled; + self.save_unlocked(state) + } + + fn now(&self) -> DateTime { + (self.clock)() + } + + fn load_unlocked(&self) -> DomainResult { + if self.path.as_os_str().is_empty() { + return Err(DomainError::InvalidRegistry); + } + reject_unsafe_file(&self.path)?; + let raw = match fs::read(&self.path) { + Ok(raw) => raw, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(RegistryState { + version: REGISTRY_VERSION, + ..RegistryState::default() + }); + } + Err(error) => return Err(error.into()), + }; + if raw.is_empty() { + return Ok(RegistryState { + version: REGISTRY_VERSION, + ..RegistryState::default() + }); + } + let mut state: RegistryState = + serde_json::from_slice(&raw).map_err(|_| DomainError::InvalidRegistry)?; + if state.version == 0 { + state.version = REGISTRY_VERSION; + } + if state.version != REGISTRY_VERSION { + return Err(DomainError::InvalidRegistry); + } + for (key, account) in &state.accounts { + if key != &account.id { + return Err(DomainError::InvalidRegistry); + } + account + .validate() + .map_err(|_| DomainError::InvalidRegistry)?; + } + if !state.active_account_id.is_empty() { + validate_account_id(&state.active_account_id)?; + if !state.accounts.contains_key(&state.active_account_id) { + return Err(DomainError::InvalidRegistry); + } + } + Ok(state) + } + + fn save_unlocked(&self, mut state: RegistryState) -> DomainResult<()> { + state.version = REGISTRY_VERSION; + for account in state.accounts.values() { + account.validate()?; + } + if !state.active_account_id.is_empty() + && !state.accounts.contains_key(&state.active_account_id) + { + return Err(DomainError::InvalidRegistry); + } + let mut raw = serde_json::to_vec_pretty(&state)?; + raw.push(b'\n'); + if let Some(parent) = self.path.parent() { + ensure_private_dir(parent)?; + } + atomic_write(&self.path, &raw) + } +} + +impl AccountStore for FileAccountRegistry { + fn register(&self, mut account: AccountRecord) -> DomainResult<()> { + account.validate()?; + let _guard = self.gate.lock().map_err(|_| DomainError::InvalidRegistry)?; + let mut state = self.load_unlocked()?; + if state.accounts.contains_key(&account.id) { + return Err(DomainError::AccountExists); + } + let now = self.now(); + account.credential_ref = if account.credential_ref.is_empty() { + account.id.clone() + } else { + account.credential_ref + }; + if account.created_at.timestamp() == 0 && account.created_at.timestamp_subsec_nanos() == 0 { + account.created_at = now; + } + account.updated_at = now; + state.accounts.insert(account.id.clone(), account); + self.save_unlocked(state) + } + + fn upsert(&self, mut account: AccountRecord) -> DomainResult<()> { + validate_account_id(&account.id)?; + validate_alias(&account.alias)?; + let _guard = self.gate.lock().map_err(|_| DomainError::InvalidRegistry)?; + let mut state = self.load_unlocked()?; + if let Some(existing) = state.accounts.get(&account.id) { + if account.credential_ref.is_empty() { + account.credential_ref = existing.credential_ref.clone(); + } + if account.created_at.timestamp() == 0 + && account.created_at.timestamp_subsec_nanos() == 0 + { + account.created_at = existing.created_at; + } + if account.last_telemetry_at.is_none() { + account.last_telemetry_at = existing.last_telemetry_at; + } + if account.telemetry_source.is_empty() { + account.telemetry_source = existing.telemetry_source.clone(); + } + if account.credential_health == CredentialHealth::Unknown { + account.credential_health = existing.credential_health; + } + } + if account.credential_ref.is_empty() { + account.credential_ref = account.id.clone(); + } + if account.created_at.timestamp() == 0 && account.created_at.timestamp_subsec_nanos() == 0 { + account.created_at = self.now(); + } + account.updated_at = self.now(); + account.validate()?; + state.accounts.insert(account.id.clone(), account); + self.save_unlocked(state) + } + + fn get(&self, account_id: &str) -> DomainResult { + validate_account_id(account_id)?; + let _guard = self.gate.lock().map_err(|_| DomainError::InvalidRegistry)?; + self.load_unlocked()? + .accounts + .remove(account_id) + .ok_or(DomainError::AccountNotFound) + } + + fn list(&self) -> DomainResult> { + let _guard = self.gate.lock().map_err(|_| DomainError::InvalidRegistry)?; + Ok(self.load_unlocked()?.accounts.into_values().collect()) + } + + fn set_active(&self, account_id: &str) -> DomainResult<()> { + validate_account_id(account_id)?; + let _guard = self.gate.lock().map_err(|_| DomainError::InvalidRegistry)?; + let mut state = self.load_unlocked()?; + if !state.accounts.contains_key(account_id) { + return Err(DomainError::AccountNotFound); + } + state.active_account_id = account_id.to_string(); + self.save_unlocked(state) + } + + fn clear_active(&self) -> DomainResult<()> { + let _guard = self.gate.lock().map_err(|_| DomainError::InvalidRegistry)?; + let mut state = self.load_unlocked()?; + state.active_account_id.clear(); + self.save_unlocked(state) + } + + fn active(&self) -> DomainResult> { + let _guard = self.gate.lock().map_err(|_| DomainError::InvalidRegistry)?; + let state = self.load_unlocked()?; + Ok(state.accounts.get(&state.active_account_id).cloned()) + } + + fn remove(&self, account_id: &str, force: bool) -> DomainResult<()> { + validate_account_id(account_id)?; + let _guard = self.gate.lock().map_err(|_| DomainError::InvalidRegistry)?; + let mut state = self.load_unlocked()?; + if !state.accounts.contains_key(account_id) { + return Err(DomainError::AccountNotFound); + } + if state.active_account_id == account_id && !force { + return Err(DomainError::AccountIsActive); + } + state.accounts.remove(account_id); + if state.active_account_id == account_id { + state.active_account_id.clear(); + } + self.save_unlocked(state) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn identifier_and_alias_rules_reject_path_and_control_data() { + assert!(validate_account_id("account-a").is_ok()); + assert!(validate_account_id("../account").is_err()); + assert!(validate_account_id("CON.txt").is_err()); + assert!(validate_alias("work account").is_ok()); + assert!(validate_alias(" work").is_err()); + assert!(validate_alias("work\naccount").is_err()); + } + + #[test] + fn registry_keeps_active_selection_and_stable_order() { + let directory = tempdir().expect("tempdir"); + let registry = FileAccountRegistry::new(directory.path().join("accounts.json")); + let mut second = AccountRecord::new("b", "B").expect("record"); + second + .metadata + .insert("team".to_string(), "dev".to_string()); + registry.register(second).expect("register b"); + registry + .register(AccountRecord::new("a", "A").expect("record")) + .expect("register a"); + let ids: Vec<_> = registry + .list() + .expect("list") + .into_iter() + .map(|account| account.id) + .collect(); + assert_eq!(ids, vec!["a", "b"]); + registry.set_active("b").expect("activate"); + assert_eq!( + registry.active().expect("active").map(|a| a.id), + Some("b".to_string()) + ); + assert!(matches!( + registry.remove("b", false), + Err(DomainError::AccountIsActive) + )); + registry.remove("b", true).expect("forced remove"); + assert!(registry.active().expect("active").is_none()); + } + + #[test] + fn sensitive_metadata_is_rejected() { + let mut record = AccountRecord::new("account-a", "A").expect("record"); + record + .metadata + .insert("access_token".to_string(), "secret".to_string()); + assert!(record.validate().is_err()); + } +} diff --git a/runtime/codex-rs/codexmarathon-runtime/src/auto_reset.rs b/runtime/codex-rs/codexmarathon-runtime/src/auto_reset.rs new file mode 100644 index 00000000..86e71a30 --- /dev/null +++ b/runtime/codex-rs/codexmarathon-runtime/src/auto_reset.rs @@ -0,0 +1,663 @@ +//! Durable, provider-backed automatic quota-reset coordination. +//! +//! The provider is the authority for whether a reset action exists. This +//! module only coordinates that action: it never constructs a provider URL, +//! edits credentials, or treats a weekly reset timestamp as an action. The +//! application supplies a [`QuotaResetExecutor`] backed by Codex's existing +//! account API. The executor is deliberately a trait so the policy can be +//! tested without contacting a provider or consuming a real reset credit. + +use crate::errors::{DomainError, DomainResult}; +use crate::persistence::{atomic_write, ensure_private_dir, reject_unsafe_file}; +use chrono::{DateTime, Duration, Utc}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +/// Current on-disk schema for automatic reset state. +pub const AUTO_RESET_STATE_VERSION: u32 = 1; + +/// Provider capability observed for one managed account. +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum QuotaResetCapability { + /// No provider-supported immediate reset action was reported. + #[default] + Unavailable, + /// A provider reset action can be consumed. The id is opaque and may be + /// omitted when the provider selects the next available credit. + Available { + #[serde(default, skip_serializing_if = "Option::is_none")] + credit_id: Option, + }, +} + +impl QuotaResetCapability { + pub fn is_available(&self) -> bool { + matches!(self, Self::Available { .. }) + } + + pub fn credit_id(&self) -> Option<&str> { + match self { + Self::Available { credit_id } => credit_id.as_deref(), + Self::Unavailable => None, + } + } +} + +/// Secret-free quota observation used by the reset policy. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct AccountQuotaTelemetry { + pub account_id: String, + /// Whether this account may participate in an automatic reset decision. + pub eligible: bool, + /// Provider-reported remaining percentage for the weekly/long window. + /// `None` means the provider did not expose a weekly window. + pub weekly_remaining_percent: Option, + #[serde(default)] + pub reset_capability: QuotaResetCapability, + pub observed_at: DateTime, +} + +impl AccountQuotaTelemetry { + /// Construct a strict zero-quota observation from provider used percent. + pub fn from_weekly_used_percent( + account_id: impl Into, + eligible: bool, + used_percent: Option, + reset_capability: QuotaResetCapability, + observed_at: DateTime, + ) -> Self { + Self { + account_id: account_id.into(), + eligible, + weekly_remaining_percent: used_percent.map(|used| (100.0 - used).max(0.0)), + reset_capability, + observed_at, + } + } + + pub fn weekly_quota_is_zero(&self) -> bool { + self.weekly_remaining_percent + .is_some_and(|remaining| remaining <= 0.0) + } +} + +/// One provider reset invocation. The idempotency key is persisted before +/// the executor is called and must be reused if the process is restarted. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct QuotaResetRequest { + pub account_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub credit_id: Option, + pub idempotency_key: String, +} + +/// Provider outcome for an immediate reset action. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum QuotaResetOutcome { + Reset, + AlreadyRedeemed, + NothingToReset, + NoCredit, +} + +/// Result returned by the provider adapter after it has refreshed telemetry. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct QuotaResetExecution { + pub outcome: QuotaResetOutcome, + /// The executor must return a fresh observation. A successful provider + /// response without refreshed quota is not enough to resume Codex. + pub refreshed_accounts: Vec, +} + +/// Provider adapter for the real, existing reset-credit API. +pub trait QuotaResetExecutor: Send + Sync { + fn execute(&self, request: &QuotaResetRequest) -> DomainResult; +} + +/// Durable lifecycle of automatic reset. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AutoResetPhase { + #[default] + Idle, + Attempting, + Succeeded, + Blocked, +} + +/// Secret-free persisted state. The attempt key is safe to persist and is +/// the idempotency identity supplied to the provider. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct AutoResetState { + pub version: u32, + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub phase: AutoResetPhase, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub attempt_key: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_attempt_at: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cooldown_until: Option>, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub last_error: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub verified_account_id: String, +} + +impl Default for AutoResetState { + fn default() -> Self { + Self { + version: AUTO_RESET_STATE_VERSION, + enabled: false, + phase: AutoResetPhase::Idle, + attempt_key: String::new(), + last_attempt_at: None, + cooldown_until: None, + last_error: String::new(), + verified_account_id: String::new(), + } + } +} + +impl AutoResetState { + fn validate(&self) -> DomainResult<()> { + if self.version != AUTO_RESET_STATE_VERSION + || self.attempt_key.len() > 512 + || self.last_error.len() > 4096 + || self.verified_account_id.len() > 240 + || self + .last_error + .chars() + .any(|character| character == '\0' || character.is_control()) + { + return Err(DomainError::InvalidAutoResetState); + } + Ok(()) + } +} + +/// Private JSON store for the state above. +pub struct AutoResetStore { + path: PathBuf, + gate: Mutex<()>, + clock: Arc DateTime + Send + Sync>, +} + +impl AutoResetStore { + pub fn new(path: impl Into) -> Self { + Self::with_clock(path, Arc::new(Utc::now)) + } + + pub fn with_clock( + path: impl Into, + clock: Arc DateTime + Send + Sync>, + ) -> Self { + Self { + path: path.into(), + gate: Mutex::new(()), + clock, + } + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub fn state(&self) -> DomainResult { + let _guard = self + .gate + .lock() + .map_err(|_| DomainError::InvalidAutoResetState)?; + self.load_unlocked() + } + + pub fn set_enabled(&self, enabled: bool) -> DomainResult { + let _guard = self + .gate + .lock() + .map_err(|_| DomainError::InvalidAutoResetState)?; + let mut state = self.load_unlocked()?; + state.enabled = enabled; + if !enabled { + state.phase = AutoResetPhase::Idle; + state.cooldown_until = None; + state.last_error.clear(); + } + self.save_unlocked(&state)?; + Ok(state) + } + + /// Persist the idempotency key before any provider invocation. + pub fn begin_attempt(&self, attempt_key: &str) -> DomainResult { + if attempt_key.trim().is_empty() { + return Err(DomainError::InvalidAutoResetState); + } + let _guard = self + .gate + .lock() + .map_err(|_| DomainError::InvalidAutoResetState)?; + let mut state = self.load_unlocked()?; + let now = (self.clock)(); + if !state.enabled { + return Ok(AutoResetBegin::Disabled(state)); + } + if state.cooldown_until.is_some_and(|until| until > now) { + return Ok(AutoResetBegin::Cooldown(state)); + } + // A successful attempt for the same exhaustion epoch is terminal + // until fresh telemetry proves a new zero-quota epoch. + if state.phase == AutoResetPhase::Succeeded && state.attempt_key == attempt_key { + return Ok(AutoResetBegin::AlreadySucceeded(state)); + } + state.phase = AutoResetPhase::Attempting; + state.attempt_key = attempt_key.to_string(); + state.last_attempt_at = Some(now); + state.cooldown_until = None; + state.last_error.clear(); + self.save_unlocked(&state)?; + Ok(AutoResetBegin::Execute(state)) + } + + pub fn mark_succeeded( + &self, + attempt_key: &str, + account_id: &str, + ) -> DomainResult { + self.update_attempt(attempt_key, AutoResetPhase::Succeeded, "", account_id, None) + } + + pub fn mark_blocked( + &self, + attempt_key: &str, + reason: &str, + cooldown: Duration, + ) -> DomainResult { + let now = (self.clock)(); + self.update_attempt( + attempt_key, + AutoResetPhase::Blocked, + reason, + "", + Some(now + cooldown.max(Duration::seconds(1))), + ) + } + + fn update_attempt( + &self, + attempt_key: &str, + phase: AutoResetPhase, + error: &str, + account_id: &str, + cooldown_until: Option>, + ) -> DomainResult { + let _guard = self + .gate + .lock() + .map_err(|_| DomainError::InvalidAutoResetState)?; + let mut state = self.load_unlocked()?; + if state.attempt_key != attempt_key { + return Err(DomainError::AutoResetAttemptMismatch); + } + state.phase = phase; + state.last_error = error.to_string(); + state.verified_account_id = account_id.to_string(); + state.cooldown_until = cooldown_until; + self.save_unlocked(&state)?; + Ok(state) + } + + fn load_unlocked(&self) -> DomainResult { + reject_unsafe_file(&self.path)?; + let raw = match fs::read(&self.path) { + Ok(raw) => raw, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(AutoResetState::default()); + } + Err(error) => return Err(error.into()), + }; + if raw.is_empty() { + return Ok(AutoResetState::default()); + } + let mut state: AutoResetState = + serde_json::from_slice(&raw).map_err(|_| DomainError::InvalidAutoResetState)?; + if state.version == 0 { + state.version = AUTO_RESET_STATE_VERSION; + } + state.validate()?; + Ok(state) + } + + fn save_unlocked(&self, state: &AutoResetState) -> DomainResult<()> { + state.validate()?; + let mut raw = serde_json::to_vec_pretty(state)?; + raw.push(b'\n'); + if let Some(parent) = self.path.parent() { + ensure_private_dir(parent)?; + } + atomic_write(&self.path, &raw) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub enum AutoResetBegin { + Disabled(AutoResetState), + Cooldown(AutoResetState), + AlreadySucceeded(AutoResetState), + Execute(AutoResetState), +} + +/// Result of evaluating automatic reset against fresh managed-account data. +#[derive(Clone, Debug, PartialEq)] +pub enum AutoResetDecision { + Disabled, + NotAllAccountsExhausted, + TelemetryBlocked(&'static str), + ActionUnavailable, + Cooldown(DateTime), + AlreadySucceeded, + ResetApplied { account_id: String }, + ResetBlocked(String), +} + +/// Executes at most one provider action for one all-zero quota epoch. +pub struct AutoResetCoordinator<'a, E: QuotaResetExecutor> { + pub store: &'a AutoResetStore, + pub executor: &'a E, + pub cooldown: Duration, +} + +impl<'a, E: QuotaResetExecutor> AutoResetCoordinator<'a, E> { + pub fn evaluate( + &self, + accounts: &[AccountQuotaTelemetry], + now: DateTime, + ) -> DomainResult { + let state = self.store.state()?; + if !state.enabled { + return Ok(AutoResetDecision::Disabled); + } + let eligible: Vec<_> = accounts.iter().filter(|account| account.eligible).collect(); + if eligible.is_empty() { + return Ok(AutoResetDecision::TelemetryBlocked( + "no eligible managed accounts", + )); + } + if eligible.iter().any(|account| { + account.account_id.is_empty() + || account.weekly_remaining_percent.is_none() + || !account.weekly_quota_is_zero() + }) { + return Ok(AutoResetDecision::NotAllAccountsExhausted); + } + let Some(candidate) = eligible + .iter() + .filter(|account| account.reset_capability.is_available()) + .min_by(|left, right| { + left.account_id.cmp(&right.account_id).then_with(|| { + left.reset_capability + .credit_id() + .cmp(&right.reset_capability.credit_id()) + }) + }) + else { + return Ok(AutoResetDecision::ActionUnavailable); + }; + + let attempt_key = make_attempt_key(eligible.iter().map(|account| { + ( + account.account_id.as_str(), + account.reset_capability.credit_id(), + account.observed_at.timestamp_millis(), + ) + })); + let begin = self.store.begin_attempt(&attempt_key)?; + match begin { + AutoResetBegin::Disabled(_) => return Ok(AutoResetDecision::Disabled), + AutoResetBegin::Cooldown(state) => { + return Ok(AutoResetDecision::Cooldown( + state.cooldown_until.unwrap_or(now), + )); + } + AutoResetBegin::AlreadySucceeded(_) => { + return Ok(AutoResetDecision::AlreadySucceeded); + } + AutoResetBegin::Execute(_) => {} + } + + let request = QuotaResetRequest { + account_id: candidate.account_id.clone(), + credit_id: candidate.reset_capability.credit_id().map(str::to_string), + idempotency_key: attempt_key.clone(), + }; + let execution = match self.executor.execute(&request) { + Ok(execution) => execution, + Err(error) => { + self.store + .mark_blocked(&attempt_key, error.code(), self.cooldown)?; + return Ok(AutoResetDecision::ResetBlocked(error.code().to_string())); + } + }; + if !matches!( + execution.outcome, + QuotaResetOutcome::Reset | QuotaResetOutcome::AlreadyRedeemed + ) { + let reason = match execution.outcome { + QuotaResetOutcome::NothingToReset => "provider reported nothing to reset", + QuotaResetOutcome::NoCredit => "provider reported no reset action available", + _ => "provider reset failed", + }; + self.store + .mark_blocked(&attempt_key, reason, self.cooldown)?; + return Ok(AutoResetDecision::ResetBlocked(reason.to_string())); + } + let refreshed = execution.refreshed_accounts.iter().any(|account| { + account.account_id == candidate.account_id + && account.eligible + && account + .weekly_remaining_percent + .is_some_and(|remaining| remaining > 0.0) + }); + if !refreshed { + let reason = "reset succeeded but refreshed quota was not verified"; + self.store + .mark_blocked(&attempt_key, reason, self.cooldown)?; + return Ok(AutoResetDecision::ResetBlocked(reason.to_string())); + } + self.store + .mark_succeeded(&attempt_key, &candidate.account_id)?; + Ok(AutoResetDecision::ResetApplied { + account_id: candidate.account_id.clone(), + }) + } +} + +fn make_attempt_key<'a>( + accounts: impl IntoIterator, i64)>, +) -> String { + let mut parts: Vec = accounts + .into_iter() + .map(|(account, credit, observed_at)| { + format!("{account}:{}:{observed_at}", credit.unwrap_or("auto")) + }) + .collect(); + parts.sort(); + format!("weekly-zero:{}", parts.join(",")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use tempfile::tempdir; + + struct FakeExecutor { + calls: AtomicUsize, + result: Option, + } + + impl QuotaResetExecutor for FakeExecutor { + fn execute(&self, _request: &QuotaResetRequest) -> DomainResult { + self.calls.fetch_add(1, Ordering::SeqCst); + self.result + .clone() + .ok_or(DomainError::InvalidAutoResetState) + } + } + + fn now() -> DateTime { + DateTime::parse_from_rfc3339("2026-09-10T00:00:00Z") + .expect("timestamp") + .with_timezone(&Utc) + } + + fn account(id: &str, capability: QuotaResetCapability) -> AccountQuotaTelemetry { + AccountQuotaTelemetry { + account_id: id.to_string(), + eligible: true, + weekly_remaining_percent: Some(0.0), + reset_capability: capability, + observed_at: now(), + } + } + + #[test] + fn default_disabled_never_calls_executor() { + let directory = tempdir().expect("tempdir"); + let store = + AutoResetStore::with_clock(directory.path().join("auto-reset.json"), Arc::new(now)); + let executor = FakeExecutor { + calls: AtomicUsize::new(0), + result: Some(QuotaResetExecution { + outcome: QuotaResetOutcome::Reset, + refreshed_accounts: vec![], + }), + }; + let coordinator = AutoResetCoordinator { + store: &store, + executor: &executor, + cooldown: Duration::hours(1), + }; + assert_eq!( + coordinator + .evaluate( + &[account( + "a", + QuotaResetCapability::Available { credit_id: None } + )], + now() + ) + .expect("decision"), + AutoResetDecision::Disabled + ); + assert_eq!(executor.calls.load(Ordering::SeqCst), 0); + } + + #[test] + fn no_action_is_blocked_without_provider_capability() { + let directory = tempdir().expect("tempdir"); + let store = + AutoResetStore::with_clock(directory.path().join("auto-reset.json"), Arc::new(now)); + store.set_enabled(true).expect("enable"); + let executor = FakeExecutor { + calls: AtomicUsize::new(0), + result: None, + }; + let coordinator = AutoResetCoordinator { + store: &store, + executor: &executor, + cooldown: Duration::hours(1), + }; + assert_eq!( + coordinator + .evaluate(&[account("a", QuotaResetCapability::Unavailable)], now()) + .expect("decision"), + AutoResetDecision::ActionUnavailable + ); + assert_eq!(executor.calls.load(Ordering::SeqCst), 0); + } + + #[test] + fn reset_requires_all_zero_and_freshly_refreshed_quota() { + let directory = tempdir().expect("tempdir"); + let store = + AutoResetStore::with_clock(directory.path().join("auto-reset.json"), Arc::new(now)); + store.set_enabled(true).expect("enable"); + let executor = FakeExecutor { + calls: AtomicUsize::new(0), + result: Some(QuotaResetExecution { + outcome: QuotaResetOutcome::Reset, + refreshed_accounts: vec![AccountQuotaTelemetry { + account_id: "a".to_string(), + eligible: true, + weekly_remaining_percent: Some(25.0), + reset_capability: QuotaResetCapability::Unavailable, + observed_at: now(), + }], + }), + }; + let coordinator = AutoResetCoordinator { + store: &store, + executor: &executor, + cooldown: Duration::hours(1), + }; + let accounts = vec![ + account( + "a", + QuotaResetCapability::Available { + credit_id: Some("credit-a".to_string()), + }, + ), + account("b", QuotaResetCapability::Unavailable), + ]; + assert_eq!( + coordinator.evaluate(&accounts, now()).expect("decision"), + AutoResetDecision::ResetApplied { + account_id: "a".to_string() + } + ); + assert_eq!(executor.calls.load(Ordering::SeqCst), 1); + assert_eq!( + coordinator.evaluate(&accounts, now()).expect("decision"), + AutoResetDecision::AlreadySucceeded + ); + assert_eq!(executor.calls.load(Ordering::SeqCst), 1); + } + + #[test] + fn nonzero_account_prevents_reset() { + let directory = tempdir().expect("tempdir"); + let store = + AutoResetStore::with_clock(directory.path().join("auto-reset.json"), Arc::new(now)); + store.set_enabled(true).expect("enable"); + let executor = FakeExecutor { + calls: AtomicUsize::new(0), + result: None, + }; + let coordinator = AutoResetCoordinator { + store: &store, + executor: &executor, + cooldown: Duration::hours(1), + }; + let mut other = account("b", QuotaResetCapability::Unavailable); + other.weekly_remaining_percent = Some(10.0); + assert_eq!( + coordinator + .evaluate( + &[ + account("a", QuotaResetCapability::Available { credit_id: None }), + other + ], + now() + ) + .expect("decision"), + AutoResetDecision::NotAllAccountsExhausted + ); + assert_eq!(executor.calls.load(Ordering::SeqCst), 0); + } +} diff --git a/runtime/codex-rs/codexmarathon-runtime/src/config.rs b/runtime/codex-rs/codexmarathon-runtime/src/config.rs new file mode 100644 index 00000000..6f0037f2 --- /dev/null +++ b/runtime/codex-rs/codexmarathon-runtime/src/config.rs @@ -0,0 +1,153 @@ +//! Configuration for the in-process Marathon domain stores. +//! +//! This module only resolves paths and pure policy settings. It does not open +//! the native Codex AuthManager, mutate `auth.json`, or start any server. + +use crate::errors::{DomainError, DomainResult}; +use crate::policy::PolicyConfig; +use std::path::{Path, PathBuf}; + +/// Native Marathon storage configuration. +#[derive(Clone, Debug, PartialEq)] +pub struct MarathonConfig { + codex_home: PathBuf, + state_dir: PathBuf, + registry_path: PathBuf, + vault_dir: PathBuf, + journal_path: PathBuf, + auto_reset_state_path: PathBuf, + legacy_auth_path: Option, + policy: PolicyConfig, +} + +impl MarathonConfig { + /// Build the default state layout below a Codex home. + pub fn new(codex_home: impl Into) -> DomainResult { + let codex_home = codex_home.into(); + if codex_home.as_os_str().is_empty() { + return Err(DomainError::InvalidConfig("codex_home is empty")); + } + let state_dir = codex_home.join("marathon"); + Ok(Self { + codex_home, + registry_path: state_dir.join("accounts.json"), + vault_dir: state_dir.join("vault"), + journal_path: state_dir.join("transitions.jsonl"), + auto_reset_state_path: state_dir.join("auto-reset.json"), + state_dir, + legacy_auth_path: None, + policy: PolicyConfig::default(), + }) + } + + /// Override the Marathon state directory while preserving its file names. + pub fn with_state_dir(mut self, state_dir: impl Into) -> DomainResult { + let state_dir = state_dir.into(); + if state_dir.as_os_str().is_empty() { + return Err(DomainError::InvalidConfig("state_dir is empty")); + } + self.registry_path = state_dir.join("accounts.json"); + self.vault_dir = state_dir.join("vault"); + self.journal_path = state_dir.join("transitions.jsonl"); + self.auto_reset_state_path = state_dir.join("auto-reset.json"); + self.state_dir = state_dir; + Ok(self) + } + + /// Configure a legacy Codex auth document as an import source. + pub fn with_legacy_auth_path(mut self, path: impl Into) -> DomainResult { + let path = path.into(); + if path.as_os_str().is_empty() { + return Err(DomainError::InvalidConfig("legacy auth path is empty")); + } + self.legacy_auth_path = Some(path); + Ok(self) + } + + /// Replace pure policy settings. + pub fn with_policy(mut self, policy: PolicyConfig) -> Self { + self.policy = policy.normalized(); + self + } + + /// Validate path relationships and policy values. + pub fn validate(&self) -> DomainResult<()> { + for path in [ + &self.codex_home, + &self.state_dir, + &self.registry_path, + &self.vault_dir, + &self.journal_path, + &self.auto_reset_state_path, + ] { + if path.as_os_str().is_empty() { + return Err(DomainError::InvalidConfig("configuration path is empty")); + } + } + if self.registry_path == self.journal_path || self.registry_path == self.vault_dir { + return Err(DomainError::InvalidConfig("state paths overlap")); + } + if self.policy.freshness_ttl <= chrono::Duration::zero() + || self.policy.cooldown <= chrono::Duration::zero() + { + return Err(DomainError::InvalidConfig( + "policy durations must be positive", + )); + } + Ok(()) + } + + /// Codex home used to derive defaults. + pub fn codex_home(&self) -> &Path { + &self.codex_home + } + + /// Marathon state directory. + pub fn state_dir(&self) -> &Path { + &self.state_dir + } + + /// Non-secret account registry path. + pub fn registry_path(&self) -> &Path { + &self.registry_path + } + + /// Protected opaque snapshot directory. + pub fn vault_dir(&self) -> &Path { + &self.vault_dir + } + + /// Metadata-only transition journal path. + pub fn journal_path(&self) -> &Path { + &self.journal_path + } + + /// Durable automatic reset state path. The file contains only enablement, + /// idempotency, cooldown, and outcome metadata. + pub fn auto_reset_state_path(&self) -> &Path { + &self.auto_reset_state_path + } + + /// Optional stock Codex auth file used only by explicit import scaffolding. + pub fn legacy_auth_path(&self) -> Option<&Path> { + self.legacy_auth_path.as_deref() + } + + /// Pure policy settings. + pub fn policy(&self) -> PolicyConfig { + self.policy + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn config_derives_private_store_paths() { + let config = MarathonConfig::new("/tmp/codex").expect("config"); + config.validate().expect("valid"); + assert_eq!(config.state_dir(), Path::new("/tmp/codex/marathon")); + assert_eq!(config.vault_dir(), Path::new("/tmp/codex/marathon/vault")); + } +} diff --git a/runtime/codex-rs/codexmarathon-runtime/src/errors.rs b/runtime/codex-rs/codexmarathon-runtime/src/errors.rs new file mode 100644 index 00000000..70c74350 --- /dev/null +++ b/runtime/codex-rs/codexmarathon-runtime/src/errors.rs @@ -0,0 +1,136 @@ +//! Errors shared by the native CodexMarathon domain. +//! +//! The error types in this module deliberately avoid carrying credential +//! bytes or provider responses. Callers can attach an operation context at +//! the application boundary, but the domain itself never formats an opaque +//! authentication snapshot into an error. + +use std::io; + +/// Result type used by the native Marathon domain modules. +pub type DomainResult = Result; + +/// Errors returned by account, vault, journal, policy, and transition code. +#[derive(Debug, thiserror::Error)] +pub enum DomainError { + /// A stable account identifier failed the path-safe identifier contract. + #[error("invalid account id")] + InvalidAccountId, + + /// An operator-facing alias failed the single-line label contract. + #[error("invalid account alias")] + InvalidAlias, + + /// An account record failed validation. + #[error("invalid account record: {0}")] + InvalidAccountRecord(&'static str), + + /// The requested account does not exist in the registry. + #[error("account not found")] + AccountNotFound, + + /// Registering an existing account was not permitted. + #[error("account already exists")] + AccountExists, + + /// Removing the active account requires an explicit force operation. + #[error("account is active")] + AccountIsActive, + + /// The persisted account registry could not be decoded or validated. + #[error("invalid account registry")] + InvalidRegistry, + + /// An opaque auth snapshot was not a JSON object. + #[error("invalid auth snapshot")] + InvalidSnapshot, + + /// A snapshot was addressed to an account other than its embedded + /// identity. + #[error("auth snapshot account mismatch")] + SnapshotAccountMismatch, + + /// No snapshot exists for the requested account. + #[error("auth snapshot not found")] + SnapshotNotFound, + + /// A vault path or journal path was a symbolic link or directory where a + /// regular file was required. + #[error("unsafe persistence path")] + UnsafePath, + + /// The append-only journal contains invalid or truncated data. + #[error("corrupt journal")] + CorruptJournal, + + /// A journal event failed the metadata-only contract. + #[error("invalid journal event")] + InvalidJournalEvent, + + /// A journal reason or metadata field looked like credential material. + #[error("journal event contains sensitive material")] + SensitiveJournalEvent, + + /// A transition state or transition result was internally inconsistent. + #[error("invalid transition state: {0}")] + InvalidTransitionState(&'static str), + + /// Runtime/domain paths or policy settings are inconsistent. + #[error("invalid Marathon configuration: {0}")] + InvalidConfig(&'static str), + + /// Persisted automatic reset state failed validation. + #[error("invalid automatic reset state")] + InvalidAutoResetState, + + /// A completion attempted to update a different reset attempt. + #[error("automatic reset attempt mismatch")] + AutoResetAttemptMismatch, + + /// A transition identifier did not refer to the expected state. + #[error("transition not found")] + TransitionNotFound, + + /// A policy input did not contain enough complete telemetry to decide. + #[error("insufficient telemetry")] + InsufficientTelemetry, + + /// A filesystem operation failed. The path may be included by the + /// standard I/O error, but opaque snapshot data is never part of it. + #[error("persistence I/O failed: {0}")] + Io(#[from] io::Error), + + /// JSON decoding failed without retaining the source bytes. + #[error("persistence encoding failed")] + Json(#[from] serde_json::Error), +} + +impl DomainError { + /// Return a stable, secret-free diagnostic code for UI and logs. + pub const fn code(&self) -> &'static str { + match self { + Self::InvalidAccountId => "invalid_account_id", + Self::InvalidAlias => "invalid_alias", + Self::InvalidAccountRecord(_) => "invalid_account_record", + Self::AccountNotFound => "account_not_found", + Self::AccountExists => "account_exists", + Self::AccountIsActive => "account_is_active", + Self::InvalidRegistry => "invalid_registry", + Self::InvalidSnapshot => "invalid_snapshot", + Self::SnapshotAccountMismatch => "snapshot_account_mismatch", + Self::SnapshotNotFound => "snapshot_not_found", + Self::UnsafePath => "unsafe_path", + Self::CorruptJournal => "corrupt_journal", + Self::InvalidJournalEvent => "invalid_journal_event", + Self::SensitiveJournalEvent => "sensitive_journal_event", + Self::InvalidTransitionState(_) => "invalid_transition_state", + Self::InvalidConfig(_) => "invalid_config", + Self::InvalidAutoResetState => "invalid_auto_reset_state", + Self::AutoResetAttemptMismatch => "auto_reset_attempt_mismatch", + Self::TransitionNotFound => "transition_not_found", + Self::InsufficientTelemetry => "insufficient_telemetry", + Self::Io(_) => "io_error", + Self::Json(_) => "encoding_error", + } + } +} diff --git a/runtime/codex-rs/codexmarathon-runtime/src/journal.rs b/runtime/codex-rs/codexmarathon-runtime/src/journal.rs new file mode 100644 index 00000000..ca153385 --- /dev/null +++ b/runtime/codex-rs/codexmarathon-runtime/src/journal.rs @@ -0,0 +1,320 @@ +//! Append-only, metadata-only transition journal. +//! +//! Journal records intentionally have no field capable of carrying an auth +//! snapshot, token, authorization header, or recovery prompt. Validation also +//! rejects common secret markers in the free-form reason field. + +use crate::errors::{DomainError, DomainResult}; +use crate::persistence::{ensure_private_dir, reject_unsafe_file, set_private_permissions_file}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::fs::{File, OpenOptions}; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +/// Stable journal event type names used by transition and recovery code. +pub mod event_types { + pub const TRANSITION_CREATED: &str = "TransitionCreated"; + pub const TRANSITION_PREPARED: &str = "TransitionPrepared"; + pub const AUTH_DEPLOYED: &str = "AuthDeployed"; + pub const COMMIT_SENT: &str = "CommitSent"; + pub const IDENTITY_CONFIRMED: &str = "IdentityConfirmed"; + pub const TRANSITION_COMMITTED: &str = "TransitionCommitted"; + pub const TRANSITION_UNCERTAIN: &str = "TransitionUncertain"; + pub const TRANSITION_RECONCILED: &str = "TransitionReconciled"; + pub const RUNTIME_CONNECTED: &str = "RuntimeConnected"; + pub const RUNTIME_DISCONNECTED: &str = "RuntimeDisconnected"; + pub const RECOVERY_PARKED: &str = "RecoveryParked"; + pub const RECOVERY_WAITING: &str = "RecoveryWaiting"; + pub const RECOVERY_BOUND: &str = "RecoveryBound"; + pub const RECOVERY_RELEASE_REQUESTED: &str = "RecoveryReleaseRequested"; + pub const RECOVERY_RELEASED: &str = "RecoveryReleased"; + pub const RECOVERY_STARTED: &str = "RecoveryStarted"; + pub const RECOVERY_COMPLETED: &str = "RecoveryCompleted"; + pub const RECOVERY_UNCERTAIN: &str = "RecoveryUncertain"; +} + +/// A scalar-only journal record. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct JournalEvent { + /// Event timestamp in UTC. + pub at: DateTime, + /// Stable event type name. + #[serde(rename = "type")] + pub event_type: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub transition_id: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub recovery_id: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub thread_id: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub turn_id: String, + /// Runtime auth generation observed for this event. + pub auth_generation: u64, + #[serde(default, skip_serializing_if = "is_zero")] + pub expected_generation: u64, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub runtime_id: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub account_id: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub from_account_id: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub target_account_id: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub outcome: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub reason: String, +} + +fn is_zero(value: &u64) -> bool { + *value == 0 +} + +impl JournalEvent { + /// Construct an event with the current timestamp and empty metadata. + pub fn new(event_type: impl Into) -> Self { + Self { + at: Utc::now(), + event_type: event_type.into(), + transition_id: String::new(), + recovery_id: String::new(), + thread_id: String::new(), + turn_id: String::new(), + auth_generation: 0, + expected_generation: 0, + runtime_id: String::new(), + account_id: String::new(), + from_account_id: String::new(), + target_account_id: String::new(), + outcome: String::new(), + reason: String::new(), + } + } + + /// Validate the metadata-only event contract. + pub fn validate(&self) -> DomainResult<()> { + if self.event_type.trim().is_empty() || self.event_type.len() > 256 { + return Err(DomainError::InvalidJournalEvent); + } + if self.at.timestamp() == 0 && self.at.timestamp_subsec_nanos() == 0 { + return Err(DomainError::InvalidJournalEvent); + } + if is_transition_event(&self.event_type) { + if self.transition_id.is_empty() || self.runtime_id.is_empty() { + return Err(DomainError::InvalidJournalEvent); + } + if matches!( + self.event_type.as_str(), + event_types::TRANSITION_COMMITTED | event_types::TRANSITION_RECONCILED + ) && self.auth_generation == 0 + { + return Err(DomainError::InvalidJournalEvent); + } + } + if is_recovery_event(&self.event_type) + && (self.recovery_id.is_empty() || self.runtime_id.is_empty()) + { + return Err(DomainError::InvalidJournalEvent); + } + for value in [ + &self.transition_id, + &self.recovery_id, + &self.thread_id, + &self.turn_id, + &self.runtime_id, + &self.account_id, + &self.from_account_id, + &self.target_account_id, + &self.outcome, + &self.reason, + ] { + if value.len() > 4096 + || value + .chars() + .any(|character| character == '\0' || character.is_control()) + { + return Err(DomainError::InvalidJournalEvent); + } + } + if looks_sensitive(&self.reason) { + return Err(DomainError::SensitiveJournalEvent); + } + Ok(()) + } +} + +fn is_transition_event(event_type: &str) -> bool { + matches!( + event_type, + event_types::TRANSITION_CREATED + | event_types::TRANSITION_PREPARED + | event_types::AUTH_DEPLOYED + | event_types::COMMIT_SENT + | event_types::IDENTITY_CONFIRMED + | event_types::TRANSITION_COMMITTED + | event_types::TRANSITION_UNCERTAIN + | event_types::TRANSITION_RECONCILED + ) +} + +fn is_recovery_event(event_type: &str) -> bool { + matches!( + event_type, + event_types::RECOVERY_PARKED + | event_types::RECOVERY_WAITING + | event_types::RECOVERY_BOUND + | event_types::RECOVERY_RELEASE_REQUESTED + | event_types::RECOVERY_RELEASED + | event_types::RECOVERY_STARTED + | event_types::RECOVERY_COMPLETED + | event_types::RECOVERY_UNCERTAIN + ) +} + +fn looks_sensitive(value: &str) -> bool { + let value = value.to_ascii_lowercase(); + [ + "access_token", + "id_token", + "refresh_token", + "authorization:", + "bearer ", + "api_key", + "openai_api_key", + "password=", + "secret=", + "auth.json", + "\"tokens\"", + ] + .iter() + .any(|marker| value.contains(marker)) +} + +/// Storage-neutral journal interface. +pub trait Journal: Send + Sync { + /// Validate and durably append an event. + fn append(&self, event: JournalEvent) -> DomainResult<()>; + /// Read and validate all events in order. + fn read_all(&self) -> DomainResult>; +} + +/// Process-safe JSONL journal with owner-only permissions. +pub struct FileJournal { + path: PathBuf, + gate: Mutex<()>, + clock: Arc DateTime + Send + Sync>, +} + +impl FileJournal { + /// Construct a journal without creating its file. + pub fn new(path: impl Into) -> Self { + Self::with_clock(path, Arc::new(Utc::now)) + } + + /// Construct a journal with an injectable clock. + pub fn with_clock( + path: impl Into, + clock: Arc DateTime + Send + Sync>, + ) -> Self { + Self { + path: path.into(), + gate: Mutex::new(()), + clock, + } + } + + /// Return the journal path without reading records. + pub fn path(&self) -> &Path { + &self.path + } +} + +impl Journal for FileJournal { + fn append(&self, mut event: JournalEvent) -> DomainResult<()> { + let _guard = self.gate.lock().map_err(|_| DomainError::CorruptJournal)?; + if event.at.timestamp() == 0 && event.at.timestamp_subsec_nanos() == 0 { + event.at = (self.clock)(); + } + event.at = event.at.with_timezone(&Utc); + event.validate()?; + reject_unsafe_file(&self.path)?; + let parent = self.path.parent().ok_or(DomainError::UnsafePath)?; + ensure_private_dir(parent)?; + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(&self.path)?; + set_private_permissions_file(&file)?; + serde_json::to_writer(&mut file, &event)?; + file.write_all(b"\n")?; + file.sync_all()?; + Ok(()) + } + + fn read_all(&self) -> DomainResult> { + let _guard = self.gate.lock().map_err(|_| DomainError::CorruptJournal)?; + reject_unsafe_file(&self.path)?; + let file = match File::open(&self.path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(error.into()), + }; + let mut records = Vec::new(); + let reader = BufReader::new(file); + for line in reader.lines() { + let line = line?; + if line.trim().is_empty() { + continue; + } + if line.len() > 1024 * 1024 { + return Err(DomainError::CorruptJournal); + } + let event: JournalEvent = + serde_json::from_str(&line).map_err(|_| DomainError::CorruptJournal)?; + event.validate().map_err(|_| DomainError::CorruptJournal)?; + records.push(event); + } + Ok(records) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn journal_round_trip_and_secret_guard() { + let directory = tempdir().expect("tempdir"); + let journal = FileJournal::new(directory.path().join("journal.jsonl")); + let mut event = JournalEvent::new(event_types::TRANSITION_CREATED); + event.transition_id = "transition-1".to_string(); + event.runtime_id = "runtime-1".to_string(); + event.reason = "threshold reached".to_string(); + journal.append(event.clone()).expect("append"); + assert_eq!(journal.read_all().expect("read"), vec![event]); + + let mut sensitive = JournalEvent::new("diagnostic"); + sensitive.reason = "Authorization: Bearer secret".to_string(); + assert!(matches!( + journal.append(sensitive), + Err(DomainError::SensitiveJournalEvent) + )); + } + + #[test] + fn committed_transition_requires_generation() { + let mut event = JournalEvent::new(event_types::TRANSITION_COMMITTED); + event.transition_id = "transition-1".to_string(); + event.runtime_id = "runtime-1".to_string(); + assert!(matches!( + event.validate(), + Err(DomainError::InvalidJournalEvent) + )); + event.auth_generation = 2; + assert!(event.validate().is_ok()); + } +} diff --git a/runtime/codex-rs/codexmarathon-runtime/src/legacy.rs b/runtime/codex-rs/codexmarathon-runtime/src/legacy.rs new file mode 100644 index 00000000..9dd84900 --- /dev/null +++ b/runtime/codex-rs/codexmarathon-runtime/src/legacy.rs @@ -0,0 +1,159 @@ +//! Read-only legacy auth-file inspection and explicit import scaffolding. +//! +//! Legacy support is intentionally one-way and opt-in: it reads an existing +//! Codex auth document, validates its opaque JSON envelope, and stores a copy +//! in the protected Marathon vault. It never replaces the active Codex auth +//! file and never invokes a second login implementation. + +use crate::accounts::{AccountRecord, AccountStore, validate_account_id, validate_alias}; +use crate::errors::{DomainError, DomainResult}; +use crate::persistence::reject_unsafe_file; +use crate::vault::{AuthSnapshot, SnapshotVault}; +use std::fs; +use std::path::Path; + +/// Result of an explicit legacy snapshot import. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LegacyImportResult { + /// Stable account key used for the imported snapshot. + pub account_id: String, + /// Display alias written to the non-secret registry. + pub alias: String, + /// Whether the account became the selected registry account. + pub activated: bool, + /// Whether an existing vault snapshot was replaced. + pub replaced: bool, +} + +/// Read and validate a legacy Codex auth document as opaque bytes. +pub fn read_legacy_auth(path: impl AsRef) -> DomainResult { + let path = path.as_ref(); + reject_unsafe_file(path)?; + let bytes = fs::read(path)?; + AuthSnapshot::from_unassigned_bytes(bytes) +} + +/// Import a legacy auth file into the protected vault and non-secret registry. +/// +/// `account_id` may be omitted only when the auth document contains an +/// embedded account identity. The operation never writes the source path. +pub fn import_legacy_auth( + path: impl AsRef, + account_id: Option<&str>, + alias: Option<&str>, + activate: bool, + overwrite: bool, + registry: &dyn AccountStore, + vault: &dyn SnapshotVault, +) -> DomainResult { + let snapshot = read_legacy_auth(path)?; + let account_id = account_id + .or(snapshot.embedded_account_id()) + .ok_or(DomainError::InvalidAccountId)?; + validate_account_id(account_id)?; + if let Some(alias) = alias { + validate_alias(alias)?; + } + + let had_snapshot = vault.contains(account_id)?; + let previous_record = match registry.get(account_id) { + Ok(record) => Some(record), + Err(DomainError::AccountNotFound) => None, + Err(error) => return Err(error), + }; + let had_record = previous_record.is_some(); + if !overwrite && (had_snapshot || had_record) { + return Err(DomainError::AccountExists); + } + + let previous_snapshot = if had_snapshot { + Some(vault.load(account_id)?) + } else { + None + }; + vault.save(account_id, &snapshot)?; + + let mut record = if let Some(previous_record) = &previous_record { + previous_record.clone() + } else { + AccountRecord::new(account_id, alias.unwrap_or(account_id))? + }; + if let Some(alias) = alias { + record.alias = alias.to_string(); + } + record.credential_ref = account_id.to_string(); + let registry_result = if had_record { + registry.upsert(record) + } else { + registry.register(record) + }; + if let Err(error) = registry_result { + rollback_snapshot(account_id, previous_snapshot.as_ref(), vault); + return Err(error); + } + + if activate { + if let Err(error) = registry.set_active(account_id) { + if let Some(previous_record) = &previous_record { + let _ = registry.upsert(previous_record.clone()); + } else { + let _ = registry.remove(account_id, true); + } + rollback_snapshot(account_id, previous_snapshot.as_ref(), vault); + return Err(error); + } + } + + Ok(LegacyImportResult { + account_id: account_id.to_string(), + alias: alias + .map(str::to_string) + .or_else(|| previous_record.map(|record| record.alias)) + .unwrap_or_else(|| account_id.to_string()), + activated: activate, + replaced: had_snapshot, + }) +} + +fn rollback_snapshot( + account_id: &str, + previous_snapshot: Option<&AuthSnapshot>, + vault: &dyn SnapshotVault, +) { + if let Some(previous_snapshot) = previous_snapshot { + let _ = vault.save(account_id, previous_snapshot); + } else { + let _ = vault.delete(account_id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::accounts::FileAccountRegistry; + use crate::vault::FileSnapshotVault; + use tempfile::tempdir; + + #[test] + fn import_reads_legacy_file_without_replacing_source() { + let directory = tempdir().expect("tempdir"); + let source = directory.path().join("auth.json"); + std::fs::write( + &source, + br#"{"tokens":{"account_id":"account-a","access_token":"secret"}}"#, + ) + .expect("write source"); + let registry = FileAccountRegistry::new(directory.path().join("accounts.json")); + let vault = FileSnapshotVault::new(directory.path().join("vault")); + let result = + import_legacy_auth(&source, None, Some("work"), true, false, ®istry, &vault) + .expect("import"); + assert_eq!(result.account_id, "account-a"); + assert_eq!( + registry.active().expect("active").map(|a| a.id), + Some("account-a".to_string()) + ); + assert!(source.exists()); + assert!(vault.contains("account-a").expect("contains")); + } +} diff --git a/runtime/codex-rs/codexmarathon-runtime/src/lib.rs b/runtime/codex-rs/codexmarathon-runtime/src/lib.rs index e444dd05..edc1ea89 100644 --- a/runtime/codex-rs/codexmarathon-runtime/src/lib.rs +++ b/runtime/codex-rs/codexmarathon-runtime/src/lib.rs @@ -17,7 +17,8 @@ use codexmarathon_runtime_adapter::RuntimeAdapter; use codexmarathon_runtime_adapter::protocol::{ AuthTransitionParams, NativeAuthSnapshotResult, NativeLoginParams, NativeLoginResult, NativeRefreshParams, NativeRefreshResult, RateLimitSnapshot, RecoveryLifecycleEvent, - RecoveryReleaseParams, RecoveryReleaseResult, RuntimeEvent, TransitionResult, + RecoveryReleaseParams, RecoveryReleaseResult, RuntimeEvent, + TransitionResult as AdapterTransitionResult, }; use codexmarathon_runtime_adapter::{ AdapterError, BackendError, BackendIdentity, BackendRateLimits, BackendReload, CodextBackend, @@ -25,11 +26,47 @@ use codexmarathon_runtime_adapter::{ use std::future::Future; use std::pin::Pin; +pub mod accounts; +pub mod auto_reset; +pub mod config; +pub mod errors; +pub mod journal; +pub mod legacy; +mod persistence; +pub mod policy; +pub mod telemetry; +pub mod transitions; +pub mod vault; + mod native; pub use codexmarathon_runtime_adapter::server::{RuntimeServer, ServerConfig}; pub use native::{CodexNativeRuntime, NativeAuthConfig}; +pub use accounts::{ + AccountRecord, AccountStore, CredentialHealth, FileAccountRegistry, REGISTRY_VERSION, + RegistryState, validate_account_id, validate_alias, +}; +pub use auto_reset::{ + AUTO_RESET_STATE_VERSION, AccountQuotaTelemetry, AutoResetBegin, AutoResetCoordinator, + AutoResetDecision, AutoResetPhase, AutoResetState, AutoResetStore, QuotaResetCapability, + QuotaResetExecution, QuotaResetExecutor, QuotaResetOutcome, QuotaResetRequest, +}; +pub use config::MarathonConfig; +pub use errors::{DomainError, DomainResult}; +pub use journal::{FileJournal, Journal, JournalEvent}; +pub use legacy::{LegacyImportResult, import_legacy_auth, read_legacy_auth}; +pub use policy::{ + PolicyConfig, PolicyDecision, PolicyDecisionType, PolicyInput, PolicyTrigger, evaluate, +}; +pub use telemetry::{ + AccountTelemetry, CandidateRank, Freshness, LimitTelemetry, Thresholds, UsageWindow, WindowKind, +}; +pub use transitions::{ + TransitionIntent, TransitionOutcome, TransitionPhase, TransitionResult, TransitionState, +}; +pub use vault::{AuthSnapshot, FileSnapshotVault, SnapshotMetadata, SnapshotVault}; + /// Future returned by the native Codex/TUI recovery command seam. pub type NativeRecoveryReleaseFuture = Pin> + Send>>; @@ -425,7 +462,7 @@ impl EmbeddedRuntime { pub fn prepare_transition( &mut self, params: AuthTransitionParams, - ) -> Result { + ) -> Result { self.adapter.prepare(params) } @@ -433,7 +470,7 @@ impl EmbeddedRuntime { pub fn commit_transition( &mut self, params: AuthTransitionParams, - ) -> Result { + ) -> Result { self.adapter.commit(params) } diff --git a/runtime/codex-rs/codexmarathon-runtime/src/persistence.rs b/runtime/codex-rs/codexmarathon-runtime/src/persistence.rs new file mode 100644 index 00000000..5f0fd96e --- /dev/null +++ b/runtime/codex-rs/codexmarathon-runtime/src/persistence.rs @@ -0,0 +1,99 @@ +//! Small filesystem helpers shared by the native domain stores. + +use crate::errors::{DomainError, DomainResult}; +use std::fs::{self, File, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Ensure that a persistence directory is private to its owner. +pub(crate) fn ensure_private_dir(path: &Path) -> DomainResult<()> { + fs::create_dir_all(path)?; + set_private_permissions(path, true)?; + Ok(()) +} + +/// Reject an existing symlink or directory at a file path. +pub(crate) fn reject_unsafe_file(path: &Path) -> DomainResult<()> { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.into()), + }; + if metadata.file_type().is_symlink() || metadata.is_dir() { + return Err(DomainError::UnsafePath); + } + Ok(()) +} + +/// Write a file through a same-directory temporary and rename it into place. +/// +/// The temporary filename is intentionally uninteresting and contains no +/// credential data. A process-local timestamp and PID are sufficient because +/// callers serialize writes for each store; a collision is retried by the +/// exclusive create flag. +pub(crate) fn atomic_write(path: &Path, contents: &[u8]) -> DomainResult<()> { + let parent = path.parent().ok_or(DomainError::UnsafePath)?; + ensure_private_dir(parent)?; + reject_unsafe_file(path)?; + + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| duration.as_nanos()); + let pid = std::process::id(); + let mut temporary = PathBuf::from(path); + let extension = format!("tmp-{pid}-{stamp}"); + temporary.set_extension(extension); + + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .open(&temporary)?; + set_private_permissions_file(&file)?; + if let Err(error) = file.write_all(contents).and_then(|_| file.sync_all()) { + drop(file); + let _ = fs::remove_file(&temporary); + return Err(error.into()); + } + drop(file); + + if let Err(error) = fs::rename(&temporary, path) { + let _ = fs::remove_file(&temporary); + return Err(error.into()); + } + let final_file = File::options().write(true).open(path)?; + set_private_permissions_file(&final_file)?; + final_file.sync_all()?; + Ok(()) +} + +/// Tighten a file or directory's Unix mode where mode bits are meaningful. +/// Windows ACLs are managed by the platform and are left untouched. +pub(crate) fn set_private_permissions(value: &Path, directory: bool) -> DomainResult<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = if directory { 0o700 } else { 0o600 }; + let metadata = fs::metadata(value)?; + let mut permissions = metadata.permissions(); + permissions.set_mode(mode); + fs::set_permissions(value, permissions)?; + } + #[cfg(not(unix))] + { + let _ = (value, directory); + } + Ok(()) +} + +/// Apply private permissions to an already-open file descriptor. +pub(crate) fn set_private_permissions_file(file: &File) -> DomainResult<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut permissions = file.metadata()?.permissions(); + permissions.set_mode(0o600); + file.set_permissions(permissions)?; + } + Ok(()) +} diff --git a/runtime/codex-rs/codexmarathon-runtime/src/policy.rs b/runtime/codex-rs/codexmarathon-runtime/src/policy.rs new file mode 100644 index 00000000..4c26dc97 --- /dev/null +++ b/runtime/codex-rs/codexmarathon-runtime/src/policy.rs @@ -0,0 +1,470 @@ +//! Pure, deterministic account-selection policy. +//! +//! Policy consumes normalized telemetry and returns an authorization hint. It +//! never reads credentials, writes the vault, reloads AuthManager, or starts a +//! transition. Those side effects belong to later orchestration stages. + +use crate::telemetry::{AccountTelemetry, CandidateRank, Thresholds}; +use chrono::{DateTime, Duration, Utc}; +use serde::{Deserialize, Serialize}; + +/// Why a policy evaluation was requested. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PolicyTrigger { + /// Explicit operator selection or initial account choice. + #[default] + Manual, + /// An account crossed a configured usage threshold. + ProactiveThreshold, + /// The active request received a hard usage-limit response. + UsageLimitExceeded, + /// A reset boundary was reached and telemetry is being revalidated. + ResetRevalidation, +} + +/// Stable policy result category. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PolicyDecisionType { + /// Keep using the current account. + Stay, + /// Ask the transition layer to select the returned account. + Transition, + /// Wait for an authoritative future quota reset. + WaitForReset, + /// Refresh or probe telemetry before selecting an account. + NoTelemetry, + /// Suppress a duplicate/in-flight request until the cooldown boundary. + Cooldown, +} + +/// Policy settings. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PolicyConfig { + /// Proactive primary/secondary usage thresholds. + pub thresholds: Thresholds, + /// Maximum age accepted for automatic selection. + pub freshness_ttl: Duration, + /// Minimum delay between automatic transitions. + pub cooldown: Duration, +} + +impl Default for PolicyConfig { + fn default() -> Self { + Self { + thresholds: Thresholds::default(), + freshness_ttl: Duration::minutes(5), + cooldown: Duration::minutes(1), + } + } +} + +impl PolicyConfig { + /// Normalize zero or invalid settings to conservative defaults. + pub fn normalized(self) -> Self { + let defaults = Self::default(); + Self { + thresholds: self.thresholds.normalized(), + freshness_ttl: if self.freshness_ttl <= Duration::zero() { + defaults.freshness_ttl + } else { + self.freshness_ttl + }, + cooldown: if self.cooldown <= Duration::zero() { + defaults.cooldown + } else { + self.cooldown + }, + } + } +} + +/// One pure policy evaluation request. +#[derive(Clone, Debug)] +pub struct PolicyInput { + /// Complete or partial account observations. + pub accounts: Vec, + /// Runtime's currently authenticated identity, if known. + pub active_account_id: Option, + /// Reason for this evaluation. + pub trigger: PolicyTrigger, + /// Optional caller override for proactive thresholds. + pub thresholds: Option, + /// Runtime/controller cooldown boundary, if one is already active. + pub cooldown_until: Option>, + /// Existing target intent. A policy tick must not create a duplicate. + pub pending_target_id: Option, + /// Evaluation time. + pub now: DateTime, +} + +/// Secret-free policy result. +#[derive(Clone, Debug, PartialEq)] +pub struct PolicyDecision { + /// Category of the decision. + pub kind: PolicyDecisionType, + /// Trigger that produced the decision. + pub trigger: PolicyTrigger, + /// Account currently in use, if known. + pub active_account_id: Option, + /// Selected account when `kind` is `Transition`. + pub account_id: Option, + /// Stable operator-safe explanation. + pub reason: String, + /// Earliest future reset when waiting for quota. + pub reset_at: Option>, + /// When a cooldown/no-telemetry caller should evaluate again. + pub retry_at: Option>, + /// Selected account's deterministic pressure score. + pub rank: Option, +} + +impl PolicyDecision { + /// Return whether the transition layer should consider an account switch. + pub fn is_transition(&self) -> bool { + self.kind == PolicyDecisionType::Transition + } + + /// Return whether policy asks the caller to wait or refresh data. + pub fn is_waiting(&self) -> bool { + matches!( + self.kind, + PolicyDecisionType::WaitForReset + | PolicyDecisionType::NoTelemetry + | PolicyDecisionType::Cooldown + ) + } +} + +/// Evaluate account telemetry without mutating state or performing I/O. +pub fn evaluate(input: PolicyInput, config: PolicyConfig) -> PolicyDecision { + let config = config.normalized(); + let now = if input.now.timestamp() == 0 && input.now.timestamp_subsec_nanos() == 0 { + Utc::now() + } else { + input.now + }; + let thresholds = input.thresholds.unwrap_or(config.thresholds).normalized(); + let active = input.active_account_id.clone(); + let trigger = input.trigger; + let mut accounts = deduplicate_accounts(input.accounts); + accounts.sort_by(|left, right| left.account_id.cmp(&right.account_id)); + let max_age = Some(config.freshness_ttl); + + let active_observation = active.as_deref().and_then(|account_id| { + accounts + .iter() + .find(|account| account.account_id == account_id) + }); + let active_eligible = active_observation + .is_some_and(|account| account.complete_fresh_capacity(now, max_age).is_ok()); + if trigger == PolicyTrigger::ProactiveThreshold + && active_observation.is_some() + && active_eligible + && !active_observation.is_some_and(|account| account.threshold_reached(thresholds)) + { + return decision( + PolicyDecisionType::Stay, + trigger, + active, + None, + "active account is below configured thresholds", + None, + None, + None, + ); + } + + if let Some(pending_target) = input + .pending_target_id + .as_deref() + .filter(|target| !target.is_empty()) + { + return decision( + PolicyDecisionType::Cooldown, + trigger, + active, + Some(pending_target.to_string()), + "a transition is already in flight", + None, + input.cooldown_until, + None, + ); + } + + let eligible: Vec<&AccountTelemetry> = accounts + .iter() + .filter(|account| account.complete_fresh_capacity(now, max_age).is_ok()) + .collect(); + let mut candidates: Vec<&AccountTelemetry> = eligible + .iter() + .copied() + .filter(|account| active.as_deref() != Some(account.account_id.as_str())) + .filter(|account| { + trigger != PolicyTrigger::ProactiveThreshold || !account.threshold_reached(thresholds) + }) + .collect(); + + if active.is_none() && trigger == PolicyTrigger::Manual { + candidates = eligible.clone(); + } + + if let Some(account) = choose_candidate(&mut candidates) { + let cooldown_until = input.cooldown_until.unwrap_or(DateTime::::MIN_UTC); + if trigger != PolicyTrigger::Manual && cooldown_until > now { + return decision( + PolicyDecisionType::Cooldown, + trigger, + active, + Some(account.account_id.clone()), + "switch cooldown is active", + None, + Some(cooldown_until), + Some(account.rank()), + ); + } + let reason = match trigger { + PolicyTrigger::UsageLimitExceeded => { + "UsageLimitExceeded requires a fresh replacement account" + } + PolicyTrigger::ProactiveThreshold => { + "active account crossed the configured usage threshold" + } + _ => "fresh account has lower quota pressure", + }; + return decision( + PolicyDecisionType::Transition, + trigger, + active, + Some(account.account_id.clone()), + reason, + None, + None, + Some(account.rank()), + ); + } + + if !eligible.is_empty() + && (trigger == PolicyTrigger::ProactiveThreshold + || (active_eligible && trigger != PolicyTrigger::UsageLimitExceeded)) + { + return decision( + PolicyDecisionType::Stay, + trigger, + active, + None, + "no lower-pressure replacement account is available", + None, + None, + None, + ); + } + + let reset_at = accounts + .iter() + .filter_map(|account| { + account.earliest_future_reset_with_age(now, Some(config.freshness_ttl)) + }) + .min(); + if reset_at.is_some() { + return decision( + PolicyDecisionType::WaitForReset, + trigger, + active, + None, + "all accounts are exhausted; wait for the earliest reset", + reset_at, + reset_at, + None, + ); + } + + decision( + PolicyDecisionType::NoTelemetry, + trigger, + active, + None, + "no fresh complete account telemetry is available", + None, + None, + None, + ) +} + +/// Compatibility spelling for callers that use an evaluation verb. +pub fn evaluate_policy(input: PolicyInput, config: PolicyConfig) -> PolicyDecision { + evaluate(input, config) +} + +fn decision( + kind: PolicyDecisionType, + trigger: PolicyTrigger, + active_account_id: Option, + account_id: Option, + reason: &str, + reset_at: Option>, + retry_at: Option>, + rank: Option, +) -> PolicyDecision { + PolicyDecision { + kind, + trigger, + active_account_id, + account_id, + reason: reason.to_string(), + reset_at, + retry_at, + rank, + } +} + +fn deduplicate_accounts(accounts: Vec) -> Vec { + let mut unique = Vec::with_capacity(accounts.len()); + for account in accounts { + if account.account_id.is_empty() + || unique + .iter() + .any(|seen: &AccountTelemetry| seen.account_id == account.account_id) + { + continue; + } + unique.push(account); + } + unique +} + +fn choose_candidate<'a>(accounts: &mut Vec<&'a AccountTelemetry>) -> Option<&'a AccountTelemetry> { + accounts.sort_by(|left, right| { + let left = left.rank(); + let right = right.rank(); + left.secondary_used_percent + .total_cmp(&right.secondary_used_percent) + .then_with(|| { + left.primary_used_percent + .total_cmp(&right.primary_used_percent) + }) + .then_with(|| left.max_used_percent.total_cmp(&right.max_used_percent)) + .then_with(|| left.total_used_percent.total_cmp(&right.total_used_percent)) + .then_with(|| left.account_id.cmp(&right.account_id)) + }); + accounts.first().copied() +} + +/// Stable, whitespace-normalized reason helper for journal/UI callers. +pub fn normalize_reason(reason: &str) -> String { + reason.trim().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::telemetry::{Freshness, LimitTelemetry, UsageWindow, WindowKind}; + + fn account(account_id: &str, primary: f64, secondary: f64) -> AccountTelemetry { + let now = DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") + .expect("time") + .with_timezone(&Utc); + AccountTelemetry { + account_id: account_id.to_string(), + limits: [( + "codex".to_string(), + LimitTelemetry { + limit_id: "codex".to_string(), + limit_name: String::new(), + plan_type: String::new(), + windows: vec![ + UsageWindow { + kind: WindowKind::Primary, + used_percent: primary, + window_duration_mins: Some(300), + resets_at: Some(now + Duration::hours(1)), + observed_at: now, + freshness: Freshness::Fresh, + }, + UsageWindow { + kind: WindowKind::Secondary, + used_percent: secondary, + window_duration_mins: Some(1_000), + resets_at: Some(now + Duration::hours(2)), + observed_at: now, + freshness: Freshness::Fresh, + }, + ], + }, + )] + .into_iter() + .collect(), + observed_at: now, + usable: true, + reset_capability: Default::default(), + source: "test".to_string(), + } + } + + #[test] + fn policy_selects_lowest_secondary_then_primary_pressure() { + let now = DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") + .expect("time") + .with_timezone(&Utc); + let result = evaluate( + PolicyInput { + accounts: vec![ + account("account-b", 10.0, 20.0), + account("account-a", 30.0, 20.0), + ], + active_account_id: Some("account-c".to_string()), + trigger: PolicyTrigger::UsageLimitExceeded, + thresholds: None, + cooldown_until: None, + pending_target_id: None, + now, + }, + PolicyConfig::default(), + ); + assert_eq!(result.kind, PolicyDecisionType::Transition); + assert_eq!(result.account_id.as_deref(), Some("account-b")); + } + + #[test] + fn proactive_trigger_stays_below_threshold() { + let now = DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") + .expect("time") + .with_timezone(&Utc); + let result = evaluate( + PolicyInput { + accounts: vec![account("account-a", 10.0, 20.0)], + active_account_id: Some("account-a".to_string()), + trigger: PolicyTrigger::ProactiveThreshold, + thresholds: None, + cooldown_until: None, + pending_target_id: None, + now, + }, + PolicyConfig::default(), + ); + assert_eq!(result.kind, PolicyDecisionType::Stay); + } + + #[test] + fn stale_accounts_do_not_authorize_a_switch() { + let now = DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") + .expect("time") + .with_timezone(&Utc); + let mut stale = account("account-a", 10.0, 20.0); + stale.observed_at = now - Duration::hours(1); + let result = evaluate( + PolicyInput { + accounts: vec![stale], + active_account_id: Some("account-b".to_string()), + trigger: PolicyTrigger::UsageLimitExceeded, + thresholds: None, + cooldown_until: None, + pending_target_id: None, + now, + }, + PolicyConfig::default(), + ); + assert_eq!(result.kind, PolicyDecisionType::NoTelemetry); + } +} diff --git a/runtime/codex-rs/codexmarathon-runtime/src/telemetry.rs b/runtime/codex-rs/codexmarathon-runtime/src/telemetry.rs new file mode 100644 index 00000000..df67059e --- /dev/null +++ b/runtime/codex-rs/codexmarathon-runtime/src/telemetry.rs @@ -0,0 +1,277 @@ +//! Provider-neutral quota telemetry used by the pure policy layer. + +use chrono::{DateTime, Duration, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +use crate::auto_reset::QuotaResetCapability; + +/// Conventional quota window kind. +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum WindowKind { + /// Short or primary provider window. + Primary, + /// Long or secondary provider window. + Secondary, +} + +/// Explicit freshness marker for an observation. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Freshness { + /// Observation may be used by policy if its age/reset checks also pass. + #[default] + Fresh, + /// Observation must be refreshed before it authorizes a transition. + Stale, +} + +/// One primary or secondary quota window. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct UsageWindow { + pub kind: WindowKind, + pub used_percent: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub window_duration_mins: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resets_at: Option>, + pub observed_at: DateTime, + #[serde(default)] + pub freshness: Freshness, +} + +impl UsageWindow { + /// Return whether this observation can be used at `now`. + pub fn is_fresh_at(&self, now: DateTime, max_age: Option) -> bool { + if self.freshness == Freshness::Stale + || self.resets_at.is_some_and(|reset| reset <= now) + || self.observed_at > now + { + return false; + } + max_age.is_none_or(|bound| bound <= Duration::zero() || now - self.observed_at < bound) + } + + /// Return whether this window carries exhausted quota. + pub fn is_exhausted(&self) -> bool { + self.used_percent >= 100.0 + } + + /// Return the future reset boundary, if present. + pub fn future_reset_at(&self, now: DateTime) -> Option> { + self.resets_at.filter(|reset| *reset > now) + } +} + +/// One provider metered bucket. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct LimitTelemetry { + pub limit_id: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub limit_name: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub plan_type: String, + pub windows: Vec, +} + +impl LimitTelemetry { + /// Return a cloned window list for deterministic policy evaluation. + pub fn window_list(&self) -> Vec { + self.windows.clone() + } +} + +/// Complete telemetry for one configured account. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct AccountTelemetry { + pub account_id: String, + #[serde(default)] + pub limits: BTreeMap, + pub observed_at: DateTime, + #[serde(default)] + pub usable: bool, + /// Provider-reported capability for an immediate quota reset action. + /// This is never inferred from a future reset timestamp. + #[serde(default)] + pub reset_capability: QuotaResetCapability, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub source: String, +} + +impl AccountTelemetry { + /// Return all windows in deterministic bucket order. + pub fn windows(&self) -> Vec<&UsageWindow> { + self.limits + .values() + .flat_map(|limit| limit.windows.iter()) + .collect() + } + + /// Refresh reset-boundary freshness without changing usage values. + pub fn refresh_staleness(&mut self, now: DateTime) { + for window in self + .limits + .values_mut() + .flat_map(|limit| limit.windows.iter_mut()) + { + if window.resets_at.is_some_and(|reset| reset <= now) { + window.freshness = Freshness::Stale; + } + } + } + + /// Return a strict eligibility result for automatic account selection. + pub fn complete_fresh_capacity( + &self, + now: DateTime, + max_age: Option, + ) -> Result<(), &'static str> { + if !self.usable { + return Err("account telemetry is unusable"); + } + if self.account_id.is_empty() { + return Err("account telemetry has no account id"); + } + if self.limits.is_empty() { + return Err("account telemetry has no rate-limit buckets"); + } + if max_age.is_some_and(|bound| { + bound > Duration::zero() && (self.observed_at > now || now - self.observed_at >= bound) + }) { + return Err("account telemetry is outside the freshness bound"); + } + for limit in self.limits.values() { + if limit.windows.is_empty() { + return Err("rate-limit bucket has no windows"); + } + let mut kinds = [false, false]; + for window in &limit.windows { + let slot = match window.kind { + WindowKind::Primary => &mut kinds[0], + WindowKind::Secondary => &mut kinds[1], + }; + if *slot { + return Err("rate-limit bucket has duplicate window kinds"); + } + *slot = true; + if !window.used_percent.is_finite() { + return Err("rate-limit usage is not finite"); + } + if !window.is_fresh_at(now, max_age) { + return Err("rate-limit window is stale"); + } + if window.is_exhausted() { + return Err("rate-limit window is exhausted"); + } + } + } + Ok(()) + } + + /// Return whether any window crosses its configured threshold. + pub fn threshold_reached(&self, thresholds: Thresholds) -> bool { + self.windows().into_iter().any(|window| match window.kind { + WindowKind::Primary => window.used_percent >= thresholds.primary_percent, + WindowKind::Secondary => window.used_percent >= thresholds.secondary_percent, + }) + } + + /// Find the earliest future reset boundary among exhausted windows. + pub fn earliest_future_reset(&self, now: DateTime) -> Option> { + self.windows() + .into_iter() + .filter(|window| window.is_exhausted()) + .filter_map(|window| window.future_reset_at(now)) + .min() + } + + /// Find the earliest reset whose window observation is still fresh under + /// the supplied age bound. Stale reset hints must never authorize a wait + /// or a subsequent transition. + pub fn earliest_future_reset_with_age( + &self, + now: DateTime, + max_age: Option, + ) -> Option> { + if !self.usable + || self.observed_at > now + || max_age + .is_some_and(|bound| bound > Duration::zero() && now - self.observed_at >= bound) + { + return None; + } + self.windows() + .into_iter() + .filter(|window| window.is_exhausted() && window.is_fresh_at(now, max_age)) + .filter_map(|window| window.future_reset_at(now)) + .min() + } + + /// Compute the deterministic pressure score used by account selection. + pub fn rank(&self) -> CandidateRank { + let mut rank = CandidateRank { + account_id: self.account_id.clone(), + ..CandidateRank::default() + }; + for window in self.windows() { + rank.max_used_percent = rank.max_used_percent.max(window.used_percent); + rank.total_used_percent += window.used_percent; + match window.kind { + WindowKind::Primary => { + rank.primary_used_percent = rank.primary_used_percent.max(window.used_percent) + } + WindowKind::Secondary => { + rank.secondary_used_percent = + rank.secondary_used_percent.max(window.used_percent) + } + } + } + rank + } +} + +/// Deterministic account pressure score. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct CandidateRank { + pub account_id: String, + pub secondary_used_percent: f64, + pub primary_used_percent: f64, + pub max_used_percent: f64, + pub total_used_percent: f64, +} + +/// Thresholds for proactive switching. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct Thresholds { + pub primary_percent: f64, + pub secondary_percent: f64, +} + +impl Default for Thresholds { + fn default() -> Self { + Self { + primary_percent: 90.0, + secondary_percent: 95.0, + } + } +} + +impl Thresholds { + /// Normalize zero/out-of-range values to safe policy defaults. + pub fn normalized(self) -> Self { + let defaults = Self::default(); + Self { + primary_percent: if self.primary_percent <= 0.0 { + defaults.primary_percent + } else { + self.primary_percent.min(100.0) + }, + secondary_percent: if self.secondary_percent <= 0.0 { + defaults.secondary_percent + } else { + self.secondary_percent.min(100.0) + }, + } + } +} diff --git a/runtime/codex-rs/codexmarathon-runtime/src/transitions.rs b/runtime/codex-rs/codexmarathon-runtime/src/transitions.rs new file mode 100644 index 00000000..c1acf243 --- /dev/null +++ b/runtime/codex-rs/codexmarathon-runtime/src/transitions.rs @@ -0,0 +1,353 @@ +//! Secret-free transition intent and lifecycle state. +//! +//! These types are durable coordination records. They do not deploy an auth +//! snapshot or call Codex. The later transition coordinator will use them to +//! correlate safe-boundary, reload, and identity acknowledgements across +//! process loss. + +use crate::errors::{DomainError, DomainResult}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// Outcome of a transition command or reconciliation attempt. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum TransitionOutcome { + /// Target identity and generation were verified. + Committed, + /// Runtime rejected the request before applying it. + Rejected, + /// The caller cannot prove whether the state-changing operation applied. + Uncertain, +} + +/// Controller-side transition lifecycle phase. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TransitionPhase { + /// No operation has been sent. + #[default] + Idle, + /// Runtime accepted preparation. + Prepared, + /// Waiting for the native runtime's idle/safe boundary. + WaitingForBoundary, + /// Target snapshot deployment is in progress. + Deploying, + /// Commit request was sent and may need reconciliation. + CommitSent, + /// State-changing acknowledgement was lost or contradictory. + Uncertain, + /// Target identity was verified. + Committed, + /// Runtime rejected the transition. + Rejected, +} + +impl TransitionPhase { + /// Return whether this phase is terminal. + pub const fn is_terminal(self) -> bool { + matches!(self, Self::Committed | Self::Rejected) + } +} + +/// Durable secret-free state for one account transition. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TransitionState { + pub transition_id: String, + pub runtime_id: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub current_account_id: String, + pub target_account_id: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub disk_account_id: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub adopted_account_id: String, + pub expected_generation: u64, + #[serde(default, skip_serializing_if = "is_zero")] + pub final_generation: u64, + pub phase: TransitionPhase, + pub prepare_sent: bool, + pub commit_sent: bool, + pub commit_attempts: u32, + pub active_turn_count: u32, + pub created_at: DateTime, + pub updated_at: DateTime, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub completed_at: Option>, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub reason: String, +} + +fn is_zero(value: &u64) -> bool { + *value == 0 +} + +impl TransitionState { + /// Create a new transition intent without performing any side effect. + pub fn new( + transition_id: impl Into, + runtime_id: impl Into, + current_account_id: impl Into, + target_account_id: impl Into, + expected_generation: u64, + now: DateTime, + ) -> DomainResult { + let state = Self { + transition_id: transition_id.into(), + runtime_id: runtime_id.into(), + current_account_id: current_account_id.into(), + target_account_id: target_account_id.into(), + disk_account_id: String::new(), + adopted_account_id: String::new(), + expected_generation, + final_generation: 0, + phase: TransitionPhase::Idle, + prepare_sent: false, + commit_sent: false, + commit_attempts: 0, + active_turn_count: 0, + created_at: now, + updated_at: now, + completed_at: None, + reason: String::new(), + }; + state.validate()?; + Ok(state) + } + + /// Validate cross-field invariants before persistence or replay. + pub fn validate(&self) -> DomainResult<()> { + if self.transition_id.trim().is_empty() + || self.runtime_id.trim().is_empty() + || self.target_account_id.trim().is_empty() + { + return Err(DomainError::InvalidTransitionState( + "missing transition identity", + )); + } + if self.expected_generation == 0 { + return Err(DomainError::InvalidTransitionState( + "expected_generation must be greater than zero", + )); + } + if self.commit_sent && !self.prepare_sent { + return Err(DomainError::InvalidTransitionState( + "commit cannot be sent before prepare", + )); + } + if self.phase == TransitionPhase::Committed { + if !self.commit_sent { + return Err(DomainError::InvalidTransitionState( + "committed transition lacks commit acknowledgement", + )); + } + if self.final_generation == 0 || self.adopted_account_id.is_empty() { + return Err(DomainError::InvalidTransitionState( + "committed transition lacks verified identity", + )); + } + if self.completed_at.is_none() { + return Err(DomainError::InvalidTransitionState( + "committed transition lacks completion time", + )); + } + } + if self.phase == TransitionPhase::Rejected && self.completed_at.is_none() { + return Err(DomainError::InvalidTransitionState( + "rejected transition lacks completion time", + )); + } + if self.updated_at < self.created_at { + return Err(DomainError::InvalidTransitionState( + "updated_at precedes created_at", + )); + } + if self.reason.len() > 4096 + || self + .reason + .chars() + .any(|character| character == '\0' || character.is_control()) + { + return Err(DomainError::InvalidTransitionState("invalid reason")); + } + Ok(()) + } + + /// Mark runtime preparation as accepted. + pub fn mark_prepared(&mut self, now: DateTime) -> DomainResult<()> { + self.prepare_sent = true; + self.phase = TransitionPhase::Prepared; + self.updated_at = now; + self.validate() + } + + /// Mark that the runtime is waiting for an idle/safe boundary. + pub fn mark_waiting_for_boundary(&mut self, now: DateTime) -> DomainResult<()> { + if !self.prepare_sent { + return Err(DomainError::InvalidTransitionState( + "boundary wait requires prepare", + )); + } + self.phase = TransitionPhase::WaitingForBoundary; + self.updated_at = now; + self.validate() + } + + /// Mark target snapshot deployment as started. + pub fn mark_deploying(&mut self, now: DateTime) -> DomainResult<()> { + if !self.prepare_sent { + return Err(DomainError::InvalidTransitionState( + "deployment requires prepare", + )); + } + self.phase = TransitionPhase::Deploying; + self.updated_at = now; + self.validate() + } + + /// Record one commit attempt. + pub fn mark_commit_sent(&mut self, now: DateTime) -> DomainResult<()> { + if !self.prepare_sent { + return Err(DomainError::InvalidTransitionState( + "commit requires prepare", + )); + } + self.commit_sent = true; + self.commit_attempts = self.commit_attempts.saturating_add(1); + self.phase = TransitionPhase::CommitSent; + self.updated_at = now; + self.validate() + } + + /// Record an uncertain state-changing acknowledgement. + pub fn mark_uncertain( + &mut self, + reason: impl Into, + now: DateTime, + ) -> DomainResult<()> { + self.reason = reason.into(); + self.phase = TransitionPhase::Uncertain; + self.updated_at = now; + self.validate() + } + + /// Record a verified committed identity and generation. + pub fn mark_committed( + &mut self, + adopted_account_id: impl Into, + final_generation: u64, + now: DateTime, + ) -> DomainResult<()> { + self.adopted_account_id = adopted_account_id.into(); + self.final_generation = final_generation; + self.phase = TransitionPhase::Committed; + self.completed_at = Some(now); + self.updated_at = now; + self.validate() + } + + /// Record a terminal rejection without exposing provider details. + pub fn mark_rejected( + &mut self, + reason: impl Into, + now: DateTime, + ) -> DomainResult<()> { + self.reason = reason.into(); + self.phase = TransitionPhase::Rejected; + self.completed_at = Some(now); + self.updated_at = now; + self.validate() + } +} + +/// Secret-free result returned by transition orchestration. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TransitionResult { + pub transition_id: String, + pub target_account_id: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub adopted_account_id: String, + pub expected_generation: u64, + pub final_generation: u64, + pub outcome: TransitionOutcome, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub completed_at: Option>, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub runtime_id: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub disk_account_id: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub reason: String, +} + +/// Read-only intent passed from policy into a future transition coordinator. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct TransitionIntent { + pub transition_id: String, + pub target_account_id: String, + pub expected_generation: u64, +} + +impl TransitionIntent { + /// Validate the minimum correlation fields. + pub fn validate(&self) -> DomainResult<()> { + if self.transition_id.trim().is_empty() || self.target_account_id.trim().is_empty() { + return Err(DomainError::InvalidTransitionState( + "missing transition intent", + )); + } + if self.expected_generation == 0 { + return Err(DomainError::InvalidTransitionState( + "expected_generation must be greater than zero", + )); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Duration; + + fn time() -> DateTime { + DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") + .expect("time") + .with_timezone(&Utc) + } + + #[test] + fn lifecycle_preserves_secret_free_transition_state() { + let start = time(); + let mut state = + TransitionState::new("tx-1", "runtime-1", "a", "b", 2, start).expect("state"); + state + .mark_prepared(start + Duration::seconds(1)) + .expect("prepare"); + state + .mark_waiting_for_boundary(start + Duration::seconds(2)) + .expect("boundary"); + state + .mark_deploying(start + Duration::seconds(3)) + .expect("deploy"); + state + .mark_commit_sent(start + Duration::seconds(4)) + .expect("commit"); + state + .mark_committed("b", 3, start + Duration::seconds(5)) + .expect("complete"); + assert_eq!(state.phase, TransitionPhase::Committed); + assert!(state.validate().is_ok()); + } + + #[test] + fn commit_before_prepare_is_rejected() { + let mut state = + TransitionState::new("tx-1", "runtime-1", "a", "b", 2, time()).expect("state"); + assert!(matches!( + state.mark_commit_sent(time()), + Err(DomainError::InvalidTransitionState(_)) + )); + } +} diff --git a/runtime/codex-rs/codexmarathon-runtime/src/vault.rs b/runtime/codex-rs/codexmarathon-runtime/src/vault.rs new file mode 100644 index 00000000..ebf58cb7 --- /dev/null +++ b/runtime/codex-rs/codexmarathon-runtime/src/vault.rs @@ -0,0 +1,294 @@ +//! Opaque authentication snapshots and their protected vault. +//! +//! The native domain validates only the JSON envelope and optional account +//! identity. Provider-specific token fields remain uninterpreted bytes. The +//! wrapper intentionally has a redacted `Debug` implementation and is not +//! `Serialize`, which prevents accidental journal, tracing, or RPC leakage. + +use crate::accounts::validate_account_id; +use crate::errors::{DomainError, DomainResult}; +use crate::persistence::{atomic_write, ensure_private_dir, reject_unsafe_file}; +use serde_json::Value; +use std::fmt; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +/// An opaque auth document returned by the native Codex authentication path. +#[derive(Clone, Eq, PartialEq)] +pub struct AuthSnapshot { + embedded_account_id: Option, + bytes: Vec, +} + +impl fmt::Debug for AuthSnapshot { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthSnapshot") + .field("embedded_account_id", &self.embedded_account_id) + .field("bytes", &"") + .finish() + } +} + +impl AuthSnapshot { + /// Validate an opaque object and associate it with a target account. + /// + /// `account_id` may be `None` for a legacy auth document that does not + /// expose an identity. A vault save still requires a concrete key. + pub fn from_bytes(account_id: Option<&str>, bytes: impl AsRef<[u8]>) -> DomainResult { + let bytes = bytes.as_ref(); + let value: Value = + serde_json::from_slice(bytes).map_err(|_| DomainError::InvalidSnapshot)?; + let object = value.as_object().ok_or(DomainError::InvalidSnapshot)?; + let embedded_account_id = extract_account_id(object)?; + if let Some(account_id) = account_id { + validate_account_id(account_id)?; + if embedded_account_id + .as_deref() + .is_some_and(|value| value != account_id) + { + return Err(DomainError::SnapshotAccountMismatch); + } + } + Ok(Self { + embedded_account_id, + bytes: bytes.to_vec(), + }) + } + + /// Validate a legacy document without assigning a vault key. + pub fn from_unassigned_bytes(bytes: impl AsRef<[u8]>) -> DomainResult { + Self::from_bytes(None, bytes) + } + + /// Convenience constructor for a concrete account vault entry. + pub fn for_account(account_id: &str, bytes: impl AsRef<[u8]>) -> DomainResult { + Self::from_bytes(Some(account_id), bytes) + } + + /// Return the identity present in the opaque document, if the provider + /// format supplied one. + pub fn embedded_account_id(&self) -> Option<&str> { + self.embedded_account_id.as_deref() + } + + /// Borrow the opaque bytes for the native AuthManager boundary. + pub fn bytes(&self) -> &[u8] { + &self.bytes + } + + /// Consume the wrapper and return the opaque bytes to the native boundary. + pub fn into_bytes(self) -> Vec { + self.bytes + } + + /// Return a redacted metadata view suitable for diagnostics. + pub fn metadata(&self) -> SnapshotMetadata { + SnapshotMetadata { + embedded_account_id: self.embedded_account_id.clone(), + byte_len: self.bytes.len(), + } + } +} + +fn extract_account_id(object: &serde_json::Map) -> DomainResult> { + let top_level = extract_string_field(object.get("account_id"))?; + let nested = object + .get("tokens") + .filter(|tokens| !tokens.is_null()) + .map(|tokens| { + let tokens = tokens.as_object().ok_or(DomainError::InvalidSnapshot)?; + extract_string_field(tokens.get("account_id")) + }) + .transpose()? + .flatten(); + if top_level.is_some() && nested.is_some() && top_level != nested { + return Err(DomainError::SnapshotAccountMismatch); + } + let identity = top_level.or(nested); + if let Some(identity) = &identity { + validate_account_id(identity)?; + } + Ok(identity) +} + +fn extract_string_field(value: Option<&Value>) -> DomainResult> { + match value { + None | Some(Value::Null) => Ok(None), + Some(Value::String(value)) if !value.is_empty() => Ok(Some(value.clone())), + Some(Value::String(_)) => Err(DomainError::InvalidSnapshot), + Some(_) => Err(DomainError::InvalidSnapshot), + } +} + +/// Secret-free metadata describing an opaque snapshot. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SnapshotMetadata { + /// Provider identity if the opaque document exposed one. + pub embedded_account_id: Option, + /// Byte length of the document, which is safe for diagnostics. + pub byte_len: usize, +} + +/// Storage-neutral protected snapshot vault. +pub trait SnapshotVault: Send + Sync { + /// Atomically save or replace a snapshot under an account key. + fn save(&self, account_id: &str, snapshot: &AuthSnapshot) -> DomainResult<()>; + /// Read and validate one snapshot. + fn load(&self, account_id: &str) -> DomainResult; + /// Remove one snapshot. + fn delete(&self, account_id: &str) -> DomainResult<()>; + /// List keys in deterministic order. + fn list(&self) -> DomainResult>; + /// Test whether a snapshot exists without reading its bytes. + fn contains(&self, account_id: &str) -> DomainResult; +} + +/// Owner-only, file-backed snapshot vault. +pub struct FileSnapshotVault { + root: PathBuf, + gate: Mutex<()>, +} + +impl FileSnapshotVault { + /// Construct a vault without creating its directory. + pub fn new(root: impl Into) -> Self { + Self { + root: root.into(), + gate: Mutex::new(()), + } + } + + /// Return the vault directory without exposing snapshot contents. + pub fn root(&self) -> &Path { + &self.root + } + + fn path_for(&self, account_id: &str) -> PathBuf { + self.root.join(format!("{account_id}.json")) + } +} + +impl SnapshotVault for FileSnapshotVault { + fn save(&self, account_id: &str, snapshot: &AuthSnapshot) -> DomainResult<()> { + validate_account_id(account_id)?; + let checked = AuthSnapshot::for_account(account_id, snapshot.bytes())?; + let _guard = self.gate.lock().map_err(|_| DomainError::UnsafePath)?; + ensure_private_dir(&self.root)?; + atomic_write(&self.path_for(account_id), checked.bytes()) + } + + fn load(&self, account_id: &str) -> DomainResult { + validate_account_id(account_id)?; + let _guard = self.gate.lock().map_err(|_| DomainError::UnsafePath)?; + let path = self.path_for(account_id); + reject_unsafe_file(&path)?; + let bytes = match fs::read(&path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Err(DomainError::SnapshotNotFound); + } + Err(error) => return Err(error.into()), + }; + AuthSnapshot::for_account(account_id, bytes) + } + + fn delete(&self, account_id: &str) -> DomainResult<()> { + validate_account_id(account_id)?; + let _guard = self.gate.lock().map_err(|_| DomainError::UnsafePath)?; + let path = self.path_for(account_id); + reject_unsafe_file(&path)?; + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + Err(DomainError::SnapshotNotFound) + } + Err(error) => Err(error.into()), + } + } + + fn list(&self) -> DomainResult> { + let _guard = self.gate.lock().map_err(|_| DomainError::UnsafePath)?; + let entries = match fs::read_dir(&self.root) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(error.into()), + }; + let mut account_ids = Vec::new(); + for entry in entries { + let entry = entry?; + let file_type = entry.file_type()?; + let name = entry.file_name(); + let name = name.to_string_lossy(); + if file_type.is_symlink() || !file_type.is_file() || !name.ends_with(".json") { + continue; + } + let account_id = name.trim_end_matches(".json"); + if validate_account_id(account_id).is_ok() { + account_ids.push(account_id.to_string()); + } + } + account_ids.sort(); + Ok(account_ids) + } + + fn contains(&self, account_id: &str) -> DomainResult { + validate_account_id(account_id)?; + let _guard = self.gate.lock().map_err(|_| DomainError::UnsafePath)?; + let path = self.path_for(account_id); + reject_unsafe_file(&path)?; + match fs::metadata(path) { + Ok(metadata) => Ok(metadata.is_file()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error.into()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + const AUTH_JSON: &[u8] = br#"{"account_id":"account-a","tokens":{"access_token":"bearer-secret","refresh_token":"refresh-secret"},"provider_specific":{"keep":true}}"#; + + #[test] + fn snapshot_debug_and_metadata_do_not_expose_tokens() { + let snapshot = AuthSnapshot::for_account("account-a", AUTH_JSON).expect("snapshot"); + let debug = format!("{snapshot:?}"); + assert!(!debug.contains("bearer-secret")); + assert_eq!(snapshot.metadata().byte_len, AUTH_JSON.len()); + assert_eq!(snapshot.embedded_account_id(), Some("account-a")); + } + + #[test] + fn vault_round_trip_is_opaque_and_rejects_mismatch() { + let directory = tempdir().expect("tempdir"); + let vault = FileSnapshotVault::new(directory.path().join("vault")); + let snapshot = AuthSnapshot::for_account("account-a", AUTH_JSON).expect("snapshot"); + vault.save("account-a", &snapshot).expect("save"); + assert_eq!(vault.list().expect("list"), vec!["account-a"]); + let loaded = vault.load("account-a").expect("load"); + assert_eq!(loaded.bytes(), AUTH_JSON); + assert!(matches!( + vault.save("account-b", &snapshot,), + Err(DomainError::SnapshotAccountMismatch) + )); + } + + #[test] + fn legacy_document_without_identity_can_be_read_but_not_misaddressed() { + let snapshot = + AuthSnapshot::from_unassigned_bytes(br#"{"tokens":{"access_token":"secret"}}"#) + .expect("legacy snapshot"); + assert!(snapshot.embedded_account_id().is_none()); + let directory = tempdir().expect("tempdir"); + let vault = FileSnapshotVault::new(directory.path().join("vault")); + vault.save("account-a", &snapshot).expect("save"); + assert_eq!( + vault.load("account-a").expect("load").bytes(), + snapshot.bytes() + ); + } +} diff --git a/runtime/codex-rs/login/Cargo.toml b/runtime/codex-rs/login/Cargo.toml index 2971dec6..c06ab193 100644 --- a/runtime/codex-rs/login/Cargo.toml +++ b/runtime/codex-rs/login/Cargo.toml @@ -42,6 +42,9 @@ url = { workspace = true } urlencoding = { workspace = true } webbrowser = { workspace = true } +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.52", features = ["Win32_Storage_FileSystem"] } + [dev-dependencies] anyhow = { workspace = true } core_test_support = { workspace = true } diff --git a/runtime/codex-rs/login/src/auth/auth_tests.rs b/runtime/codex-rs/login/src/auth/auth_tests.rs index 9927a604..c3801962 100644 --- a/runtime/codex-rs/login/src/auth/auth_tests.rs +++ b/runtime/codex-rs/login/src/auth/auth_tests.rs @@ -2,6 +2,7 @@ use super::*; use crate::auth::storage::FileAuthStorage; use crate::auth::storage::get_auth_file; use crate::token_data::IdTokenInfo; +use crate::token_data::TokenData; use codex_protocol::account::PlanType as AccountPlanType; use codex_protocol::auth::AuthMode; use codex_protocol::auth::KnownPlan as InternalKnownPlan; @@ -1827,6 +1828,191 @@ fn fake_jwt_for_auth_file_params(params: &AuthFileParams) -> std::io::Result AuthDotJson { + let id_token = crate::token_data::parse_chatgpt_jwt_claims( + &fake_jwt_for_auth_file_params(&AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: Some("pro".to_string()), + chatgpt_account_id: Some(account_id.to_string()), + }) + .expect("transition test JWT should be valid"), + ) + .expect("transition test JWT claims should parse"); + AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(TokenData { + id_token, + access_token: access_token.to_string(), + refresh_token: format!("refresh-{account_id}"), + account_id: Some(account_id.to_string()), + }), + last_refresh: Some(Utc::now()), + agent_identity: None, + personal_access_token: None, + bedrock_api_key: None, + bedrock_access_keys: None, + } +} + +#[test] +fn transition_snapshot_rejects_unsupported_auth_modes_without_debug_secrets() { + let api_key = AuthDotJson { + auth_mode: Some(AuthMode::ApiKey), + openai_api_key: Some("api-key-secret".to_string()), + tokens: None, + last_refresh: None, + agent_identity: None, + personal_access_token: None, + bedrock_api_key: None, + bedrock_access_keys: None, + }; + + let error = AuthTransitionSnapshot::from_auth_dot_json(api_key) + .expect_err("API key auth cannot be transferred by Marathon"); + assert_eq!(error.kind(), std::io::ErrorKind::Unsupported); + assert!(!error.to_string().contains("api-key-secret")); + + let mut conflicting = transition_auth("account-a", "access-token-secret"); + conflicting + .tokens + .as_mut() + .expect("transition test tokens") + .id_token + .chatgpt_account_id = Some("different-account".to_string()); + let error = AuthTransitionSnapshot::from_auth_dot_json(conflicting) + .expect_err("conflicting provider identities must be rejected"); + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); + + let snapshot = AuthTransitionSnapshot::from_auth_dot_json(transition_auth( + "account-a", + "access-token-secret", + )) + .expect("managed snapshot"); + let debug = format!("{snapshot:?}"); + assert!(!debug.contains("access-token-secret")); + assert_eq!(snapshot.account_id(), "account-a"); +} + +#[tokio::test] +async fn auth_manager_transition_round_trip_updates_storage_then_cache() -> anyhow::Result<()> { + let codex_home = tempdir()?; + let storage = FileAuthStorage::new(codex_home.path().to_path_buf()); + let source = transition_auth("account-a", "access-a"); + storage.save(&source)?; + let current_auth = CodexAuth::from_auth_dot_json( + codex_home.path(), + source.clone(), + AuthCredentialsStoreMode::File, + None, + AuthKeyringBackendKind::Direct, + None, + &crate::test_support::transport_default_auth_route_config(), + ) + .await?; + let manager = + AuthManager::from_auth_for_testing_with_home(current_auth, codex_home.path().to_path_buf()); + + let captured = manager.snapshot_for_transition("account-a").await?; + assert_eq!(captured.account_id(), "account-a"); + let target = + AuthTransitionSnapshot::from_auth_dot_json(transition_auth("account-b", "access-b"))?; + let status = manager + .install_snapshot_for_transition(target, "account-a") + .await?; + assert_eq!(status, AuthReloadStatus::Reloaded { changed: true }); + assert_eq!( + manager.auth_cached().and_then(|auth| auth.get_account_id()), + Some("account-b".to_string()) + ); + assert_eq!( + storage + .load()? + .and_then(|auth| auth.tokens) + .and_then(|tokens| tokens.account_id), + Some("account-b".to_string()) + ); + Ok(()) +} + +#[tokio::test] +async fn auth_manager_transition_rejects_source_mismatch_and_preserves_state() -> anyhow::Result<()> +{ + let codex_home = tempdir()?; + let storage = FileAuthStorage::new(codex_home.path().to_path_buf()); + let source = transition_auth("account-a", "access-a"); + storage.save(&source)?; + let current_auth = CodexAuth::from_auth_dot_json( + codex_home.path(), + source.clone(), + AuthCredentialsStoreMode::File, + None, + AuthKeyringBackendKind::Direct, + None, + &crate::test_support::transport_default_auth_route_config(), + ) + .await?; + let manager = + AuthManager::from_auth_for_testing_with_home(current_auth, codex_home.path().to_path_buf()); + let target = + AuthTransitionSnapshot::from_auth_dot_json(transition_auth("account-b", "access-b"))?; + + let error = manager + .install_snapshot_for_transition(target, "account-b") + .await + .expect_err("source identity must be checked before persistence"); + assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied); + assert!(!error.to_string().contains("access-a")); + assert_eq!( + manager.auth_cached().and_then(|auth| auth.get_account_id()), + Some("account-a".to_string()) + ); + assert_eq!(storage.load()?, Some(source)); + Ok(()) +} + +#[tokio::test] +async fn auth_manager_transition_write_failure_preserves_file_and_cached_auth() -> anyhow::Result<()> +{ + let codex_home = tempdir()?; + let storage = FileAuthStorage::new(codex_home.path().to_path_buf()); + let source = transition_auth("account-a", "access-a"); + storage.save(&source)?; + let current_auth = CodexAuth::from_auth_dot_json( + codex_home.path(), + source.clone(), + AuthCredentialsStoreMode::File, + None, + AuthKeyringBackendKind::Direct, + None, + &crate::test_support::transport_default_auth_route_config(), + ) + .await?; + let manager = + AuthManager::from_auth_for_testing_with_home(current_auth, codex_home.path().to_path_buf()); + let target = + AuthTransitionSnapshot::from_auth_dot_json(transition_auth("account-b", "access-b"))?; + + let original_permissions = std::fs::metadata(codex_home.path())?.permissions(); + let mut read_only_permissions = original_permissions.clone(); + read_only_permissions.set_readonly(true); + std::fs::set_permissions(codex_home.path(), read_only_permissions)?; + let result = manager + .install_snapshot_for_transition(target, "account-a") + .await; + std::fs::set_permissions(codex_home.path(), original_permissions)?; + + let error = result.expect_err("read-only auth home must reject the write"); + assert_eq!(error.kind(), std::io::ErrorKind::Other); + assert!(!error.to_string().contains("access-b")); + assert_eq!( + manager.auth_cached().and_then(|auth| auth.get_account_id()), + Some("account-a".to_string()) + ); + assert_eq!(storage.load()?, Some(source)); + Ok(()) +} + async fn build_config( codex_home: &Path, forced_login_method: Option, diff --git a/runtime/codex-rs/login/src/auth/manager.rs b/runtime/codex-rs/login/src/auth/manager.rs index 788ee04b..906a6ef7 100644 --- a/runtime/codex-rs/login/src/auth/manager.rs +++ b/runtime/codex-rs/login/src/auth/manager.rs @@ -6,6 +6,7 @@ use serde::Serialize; use serial_test::serial; use std::env; use std::fmt::Debug; +use std::fmt::Formatter; use std::future::Future; use std::path::Path; use std::path::PathBuf; @@ -1841,6 +1842,115 @@ pub enum AuthReloadStatus { Failed, } +/// A validated, managed ChatGPT auth payload held for an account transition. +/// +/// The wrapper deliberately does not implement serialization and redacts its +/// debug representation. Callers can only obtain the payload explicitly at +/// the native AuthManager boundary, which keeps transition identifiers and +/// diagnostics free of credential material by default. +#[derive(Clone, PartialEq)] +pub struct AuthTransitionSnapshot { + auth_dot_json: AuthDotJson, + account_id: String, +} + +impl Debug for AuthTransitionSnapshot { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("AuthTransitionSnapshot") + .field("account_id", &self.account_id) + .field("auth_mode", &self.auth_dot_json.resolved_mode()) + .finish() + } +} + +impl AuthTransitionSnapshot { + /// Validate and wrap a stored auth payload for a managed transition. + /// + /// Only Codex-managed ChatGPT OAuth snapshots are transferable. External + /// token modes, API keys, headers, Agent Identity, PAT, and Bedrock auth + /// are rejected before any transition can persist them. + pub fn from_auth_dot_json(auth_dot_json: AuthDotJson) -> std::io::Result { + let account_id = managed_transition_account_id(&auth_dot_json)?; + Ok(Self { + auth_dot_json, + account_id, + }) + } + + /// Return the non-secret provider account identity. + pub fn account_id(&self) -> &str { + &self.account_id + } + + /// Return the validated auth mode. This is always managed ChatGPT OAuth. + pub fn auth_mode(&self) -> AuthMode { + self.auth_dot_json.resolved_mode() + } + + /// Borrow the opaque native payload at an explicit integration boundary. + pub fn as_auth_dot_json(&self) -> &AuthDotJson { + &self.auth_dot_json + } + + /// Consume the wrapper at an explicit native AuthManager boundary. + pub fn into_auth_dot_json(self) -> AuthDotJson { + self.auth_dot_json + } +} + +fn transition_account_id(value: Option) -> std::io::Result { + let account_id = value + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "managed auth snapshot has no account identity", + ) + })?; + if account_id.chars().any(|character| character.is_control()) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "managed auth snapshot has an invalid account identity", + )); + } + Ok(account_id) +} + +fn managed_transition_account_id(auth_dot_json: &AuthDotJson) -> std::io::Result { + if auth_dot_json.resolved_mode() != AuthMode::Chatgpt { + return Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + format!( + "auth transitions do not support {} authentication", + auth_dot_json.resolved_mode() + ), + )); + } + let tokens = auth_dot_json.tokens.as_ref().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "managed ChatGPT auth snapshot has no token data", + ) + })?; + if let (Some(account_id), Some(claimed_account_id)) = ( + tokens.account_id.as_deref(), + tokens.id_token.chatgpt_account_id.as_deref(), + ) && account_id != claimed_account_id + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "managed auth snapshot has conflicting provider identities", + )); + } + transition_account_id( + tokens + .account_id + .clone() + .or_else(|| tokens.id_token.chatgpt_account_id.clone()), + ) +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum UnauthorizedRecoveryMode { Managed, @@ -2356,6 +2466,199 @@ impl AuthManager { ) } + fn ensure_transition_storage(&self) -> std::io::Result<()> { + if self.auth_credentials_store_mode == AuthCredentialsStoreMode::Ephemeral { + return Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "auth transitions require persistent managed auth storage", + )); + } + if self.has_external_auth() { + return Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "auth transitions do not support externally managed auth", + )); + } + Ok(()) + } + + fn validate_transition_policy(&self, auth: &CodexAuth) -> std::io::Result<()> { + let allowed_login_methods = self.allowed_login_methods(); + validate_auth_restrictions( + Some(&allowed_login_methods), + self.effective_chatgpt_workspaces().as_deref(), + auth, + ) + .map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "auth snapshot violates the configured authentication policy", + ) + }) + } + + fn current_transition_source(&self) -> std::io::Result<(CodexAuth, String)> { + let auth = self.auth_cached().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::NotFound, + "no managed auth is active for transition", + ) + })?; + if !matches!(auth, CodexAuth::Chatgpt(_)) { + return Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + format!( + "auth transitions do not support {} authentication", + auth.api_auth_mode() + ), + )); + } + self.validate_transition_policy(&auth)?; + let account_id = transition_account_id(auth.get_account_id())?; + Ok((auth, account_id)) + } + + async fn auth_from_transition_snapshot( + &self, + snapshot: &AuthTransitionSnapshot, + ) -> std::io::Result { + let auth = CodexAuth::from_auth_dot_json( + &self.codex_home, + snapshot.as_auth_dot_json().clone(), + self.auth_credentials_store_mode, + self.chatgpt_base_url.as_deref(), + self.keyring_backend_kind, + self.agent_identity_authapi_base_url.as_deref(), + &self.auth_route_config, + ) + .await + .map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "auth snapshot could not be loaded by the native provider", + ) + })?; + if !matches!(auth, CodexAuth::Chatgpt(_)) { + return Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + format!( + "auth transitions do not support {} authentication", + auth.api_auth_mode() + ), + )); + } + self.validate_transition_policy(&auth)?; + Ok(auth) + } + + /// Capture the currently active managed ChatGPT auth from the configured + /// native storage backend after proving that it belongs to the expected + /// account. The refresh semaphore guards this read against concurrent + /// native refresh operations. + pub async fn snapshot_for_transition( + &self, + expected_account_id: &str, + ) -> std::io::Result { + let _refresh_guard = self + .refresh_lock + .acquire() + .await + .map_err(|_| std::io::Error::other("auth transition guard is unavailable"))?; + self.ensure_transition_storage()?; + let (_current_auth, current_account_id) = self.current_transition_source()?; + if current_account_id != expected_account_id { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "active auth identity does not match the expected transition source", + )); + } + + let stored = self.auth_dot_json_from_storage()?.ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::NotFound, + "managed auth is not persisted", + ) + })?; + let snapshot = AuthTransitionSnapshot::from_auth_dot_json(stored)?; + if snapshot.account_id() != expected_account_id { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "persisted auth identity does not match the expected transition source", + )); + } + // Validate the provider-specific payload and configured policy before + // handing credential material to the transition coordinator. + self.auth_from_transition_snapshot(&snapshot).await?; + Ok(snapshot) + } + + /// Persist a validated managed ChatGPT snapshot for the next account only + /// after proving that the expected source account is still active. The + /// cache is changed strictly after the configured storage backend reports + /// a successful durable write. + pub async fn install_snapshot_for_transition( + &self, + snapshot: AuthTransitionSnapshot, + expected_source_id: &str, + ) -> std::io::Result { + let _refresh_guard = self + .refresh_lock + .acquire() + .await + .map_err(|_| std::io::Error::other("auth transition guard is unavailable"))?; + self.ensure_transition_storage()?; + let (_current_auth, current_account_id) = self.current_transition_source()?; + if current_account_id != expected_source_id { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "active auth identity does not match the expected transition source", + )); + } + + let stored = self.auth_dot_json_from_storage()?.ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::NotFound, + "managed auth is not persisted", + ) + })?; + let stored_snapshot = AuthTransitionSnapshot::from_auth_dot_json(stored)?; + if stored_snapshot.account_id() != expected_source_id { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "persisted auth identity does not match the expected transition source", + )); + } + + let candidate = self.auth_from_transition_snapshot(&snapshot).await?; + let storage = create_auth_storage( + self.codex_home.clone(), + self.auth_credentials_store_mode, + self.keyring_backend_kind, + ); + storage + .save(snapshot.as_auth_dot_json()) + .map_err(|_| std::io::Error::other("managed auth snapshot could not be persisted"))?; + + let changed = self.set_cached_auth_after_transition(candidate)?; + Ok(AuthReloadStatus::Reloaded { changed }) + } + + fn set_cached_auth_after_transition(&self, new_auth: CodexAuth) -> std::io::Result { + let mut guard = self + .inner + .write() + .map_err(|_| std::io::Error::other("auth cache lock is poisoned"))?; + let changed = !Self::auths_equal_for_refresh(guard.auth.as_ref(), Some(&new_auth)); + if changed { + guard.permanent_refresh_failure = None; + } + guard.auth = Some(new_auth); + if changed { + self.auth_change_tx.send_modify(|revision| *revision += 1); + } + Ok(changed) + } + /// Subscribes to cached auth changes that can affect request recovery. pub fn auth_change_receiver(&self) -> watch::Receiver { self.auth_change_tx.subscribe() diff --git a/runtime/codex-rs/login/src/auth/storage.rs b/runtime/codex-rs/login/src/auth/storage.rs index 8a9d75f4..3f617973 100644 --- a/runtime/codex-rs/login/src/auth/storage.rs +++ b/runtime/codex-rs/login/src/auth/storage.rs @@ -16,6 +16,8 @@ use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::Mutex; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; use tracing::warn; use super::BedrockAccessKeysAuth; @@ -170,6 +172,124 @@ pub(super) trait AuthStorageBackend: Debug + Send + Sync { fn delete(&self) -> std::io::Result; } +static NEXT_AUTH_TEMP_FILE_ID: AtomicU64 = AtomicU64::new(1); + +/// Replace a persistent auth file without ever exposing a partially-written +/// JSON document to a concurrent reader. +/// +/// The temporary is created in the destination directory, flushed to the +/// filesystem, and then replaced with a single rename operation. On Unix the +/// rename is atomic and replaces an existing destination. Windows uses +/// `MoveFileExW` with replacement and write-through flags. A directory sync is +/// attempted after the replacement on platforms that support syncing a +/// directory entry. +fn atomic_write_auth_file(path: &Path, contents: &[u8]) -> std::io::Result<()> { + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + let file_name = path.file_name().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "auth storage path has no file name", + ) + })?; + let temp_name = format!( + ".{}.tmp-{}-{}", + file_name.to_string_lossy(), + std::process::id(), + NEXT_AUTH_TEMP_FILE_ID.fetch_add(1, Ordering::Relaxed) + ); + let temp_path = parent.join(temp_name); + + let write_result = (|| { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + options.mode(0o600); + } + let mut file = options.open(&temp_path)?; + file.write_all(contents)?; + file.sync_all()?; + drop(file); + replace_auth_file(&temp_path, path)?; + sync_auth_directory(parent) + })(); + + if write_result.is_err() { + // The destination remains untouched when writing or replacing fails. + // Cleanup is best effort because the original error is more useful to + // the caller and the temporary contains credential material. + let _ = std::fs::remove_file(&temp_path); + } + write_result +} + +#[cfg(unix)] +fn replace_auth_file(temp_path: &Path, destination: &Path) -> std::io::Result<()> { + std::fs::rename(temp_path, destination) +} + +#[cfg(windows)] +fn replace_auth_file(temp_path: &Path, destination: &Path) -> std::io::Result<()> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{ + MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW, + }; + + let temp_path: Vec = temp_path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let destination: Vec = destination + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + // SAFETY: both vectors are NUL-terminated UTF-16 paths that remain alive + // for the duration of the OS call. + let replaced = unsafe { + MoveFileExW( + temp_path.as_ptr(), + destination.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + }; + if replaced == 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) + } +} + +#[cfg(not(any(unix, windows)))] +fn replace_auth_file(temp_path: &Path, destination: &Path) -> std::io::Result<()> { + std::fs::rename(temp_path, destination) +} + +#[cfg(unix)] +fn sync_auth_directory(path: &Path) -> std::io::Result<()> { + let directory = File::open(path)?; + match directory.sync_all() { + Ok(()) => Ok(()), + // Some Unix filesystems do not permit syncing a directory. The file + // itself has already been synced, so preserve the successful replace. + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::Unsupported | std::io::ErrorKind::InvalidInput + ) => + { + Ok(()) + } + Err(error) => Err(error), + } +} + +#[cfg(not(unix))] +fn sync_auth_directory(_path: &Path) -> std::io::Result<()> { + Ok(()) +} + #[derive(Clone, Debug)] pub(super) struct FileAuthStorage { codex_home: PathBuf, @@ -210,16 +330,7 @@ impl AuthStorageBackend for FileAuthStorage { std::fs::create_dir_all(parent)?; } let json_data = serde_json::to_string_pretty(auth_dot_json)?; - let mut options = OpenOptions::new(); - options.truncate(true).write(true).create(true); - #[cfg(unix)] - { - options.mode(0o600); - } - let mut file = options.open(auth_file)?; - file.write_all(json_data.as_bytes())?; - file.flush()?; - Ok(()) + atomic_write_auth_file(&auth_file, json_data.as_bytes()) } fn delete(&self) -> std::io::Result { diff --git a/runtime/codex-rs/login/src/auth/storage_tests.rs b/runtime/codex-rs/login/src/auth/storage_tests.rs index 0c693438..dda16483 100644 --- a/runtime/codex-rs/login/src/auth/storage_tests.rs +++ b/runtime/codex-rs/login/src/auth/storage_tests.rs @@ -65,6 +65,58 @@ async fn file_storage_save_persists_auth_dot_json() -> anyhow::Result<()> { Ok(()) } +#[test] +fn file_storage_replacement_is_durable_and_leaves_no_partial_temp_file() -> anyhow::Result<()> { + let codex_home = tempdir()?; + let storage = FileAuthStorage::new(codex_home.path().to_path_buf()); + let first = AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(TokenData { + id_token: id_token_with_prefix("atomic-first"), + access_token: "first-access".to_string(), + refresh_token: "first-refresh".to_string(), + account_id: Some("account-a".to_string()), + }), + last_refresh: Some(Utc::now()), + agent_identity: None, + personal_access_token: None, + bedrock_api_key: None, + bedrock_access_keys: None, + }; + let second = AuthDotJson { + tokens: Some(TokenData { + id_token: id_token_with_prefix("atomic-second"), + access_token: "second-access".to_string(), + refresh_token: "second-refresh".to_string(), + account_id: Some("account-b".to_string()), + }), + ..first.clone() + }; + + storage.save(&first)?; + storage.save(&second)?; + + assert_eq!(storage.load()?, Some(second)); + let temporary_files = std::fs::read_dir(codex_home.path())? + .map(|entry| entry.map(|entry| entry.file_name())) + .collect::, _>>()?; + assert!( + temporary_files + .iter() + .all(|name| { !name.to_string_lossy().starts_with(".auth.json.tmp-") }) + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(get_auth_file(codex_home.path()))? + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600); + } + Ok(()) +} + #[tokio::test] async fn file_storage_round_trips_agent_identity_auth() -> anyhow::Result<()> { let codex_home = tempdir()?; diff --git a/runtime/codex-rs/login/src/lib.rs b/runtime/codex-rs/login/src/lib.rs index d51bc26a..5988226b 100644 --- a/runtime/codex-rs/login/src/lib.rs +++ b/runtime/codex-rs/login/src/lib.rs @@ -35,6 +35,7 @@ pub use auth::AuthManager; pub use auth::AuthManagerConfig; pub use auth::AuthManagerInitializationError; pub use auth::AuthReloadStatus; +pub use auth::AuthTransitionSnapshot; pub use auth::CLIENT_ID; pub use auth::CLIENT_ID_OVERRIDE_ENV_VAR; pub use auth::CODEX_ACCESS_TOKEN_ENV_VAR; diff --git a/runtime/codex-rs/tui/src/app/app_server_events.rs b/runtime/codex-rs/tui/src/app/app_server_events.rs index d4db3152..dd969423 100644 --- a/runtime/codex-rs/tui/src/app/app_server_events.rs +++ b/runtime/codex-rs/tui/src/app/app_server_events.rs @@ -275,6 +275,9 @@ impl App { .is_some_and(AuthMode::has_chatgpt_account), has_codex_backend_auth, ); + // Marathon login snapshots the new identity only after this + // notification, which is emitted after AuthManager reload. + self.chat_widget.on_marathon_account_updated(); return; } ServerNotification::ExternalAgentConfigImportCompleted(notification) => { diff --git a/runtime/codex-rs/tui/src/app/event_dispatch.rs b/runtime/codex-rs/tui/src/app/event_dispatch.rs index 88317066..0a8a07c8 100644 --- a/runtime/codex-rs/tui/src/app/event_dispatch.rs +++ b/runtime/codex-rs/tui/src/app/event_dispatch.rs @@ -8,8 +8,9 @@ use super::rate_limit_refresh::RateLimitRefreshOutcome; use super::resize_reflow::trailing_run_start; use super::session_lifecycle::ThreadAttachPresentation; use super::*; -use crate::app_event::RecapTrigger; use crate::app_event::CodexMarathonRecoveryLifecycle; +use crate::app_event::MarathonLoginMode; +use crate::app_event::RecapTrigger; use crate::app_event::ThreadTitleDestination; use crate::app_server_session::ForkGoalContinuation; use crate::app_server_session::UnsupportedLegacyPermissionProfile; @@ -1285,6 +1286,159 @@ impl App { self.pending_codexmarathon_recovery_events.push_back(event); self.flush_codexmarathon_recovery_events(app_server).await; } + AppEvent::MarathonStatusRequest => { + let request_id = app_server.next_request_id(); + let request_handle = app_server.request_handle(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let result = request_handle + .request_typed::( + ClientRequest::MarathonStatus { + request_id, + params: None, + }, + ) + .await + .map_err(|error| error.to_string()); + app_event_tx.send(AppEvent::MarathonStatusResult { result }); + }); + } + AppEvent::MarathonEnabledSetRequest { enabled } => { + let request_id = app_server.next_request_id(); + let request_handle = app_server.request_handle(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let result = request_handle + .request_typed::( + ClientRequest::MarathonEnabledSet { + request_id, + params: codex_app_server_protocol::MarathonEnabledSetParams { + enabled, + }, + }, + ) + .await + .map_err(|error| error.to_string()); + app_event_tx.send(AppEvent::MarathonEnabledSetResult { enabled, result }); + }); + } + AppEvent::MarathonAutoResetSetRequest { enabled } => { + let request_id = app_server.next_request_id(); + let request_handle = app_server.request_handle(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let result = request_handle + .request_typed::( + ClientRequest::MarathonAutoResetSet { + request_id, + params: codex_app_server_protocol::MarathonAutoResetSetParams { + enabled, + }, + }, + ) + .await + .map_err(|error| error.to_string()); + app_event_tx.send(AppEvent::MarathonAutoResetSetResult { enabled, result }); + }); + } + AppEvent::MarathonSwitchRequest { target } => { + let request_id = app_server.next_request_id(); + let request_handle = app_server.request_handle(); + let app_event_tx = self.app_event_tx.clone(); + let request_target = target.clone(); + tokio::spawn(async move { + let result = request_handle + .request_typed::( + ClientRequest::MarathonSwitch { + request_id, + params: codex_app_server_protocol::MarathonSwitchParams { + target: request_target, + }, + }, + ) + .await + .map_err(|error| error.to_string()); + app_event_tx.send(AppEvent::MarathonSwitchResult { target, result }); + }); + } + AppEvent::MarathonImportRequest { alias } => { + let request_id = app_server.next_request_id(); + let request_handle = app_server.request_handle(); + let app_event_tx = self.app_event_tx.clone(); + let request_alias = alias.clone(); + tokio::spawn(async move { + let result = request_handle + .request_typed::( + ClientRequest::MarathonImport { + request_id, + params: codex_app_server_protocol::MarathonImportParams { + alias: request_alias, + }, + }, + ) + .await + .map_err(|error| error.to_string()); + app_event_tx.send(AppEvent::MarathonImportResult { alias, result }); + }); + } + AppEvent::MarathonLoginRequest { alias, mode } => { + let request_id = app_server.next_request_id(); + let request_handle = app_server.request_handle(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let params = match mode { + MarathonLoginMode::Browser => { + codex_app_server_protocol::LoginAccountParams::Chatgpt { + codex_streamlined_login: false, + use_hosted_login_success_page: false, + app_brand: None, + } + } + MarathonLoginMode::DeviceCode => { + codex_app_server_protocol::LoginAccountParams::ChatgptDeviceCode + } + }; + let result = request_handle + .request_typed::( + ClientRequest::LoginAccount { + request_id, + params, + }, + ) + .await + .map_err(|error| error.to_string()); + if let Ok(codex_app_server_protocol::LoginAccountResponse::Chatgpt { + auth_url, + .. + }) = &result + { + if let Err(error) = webbrowser::open(auth_url) { + tracing::debug!(%error, "could not open Marathon login URL in browser"); + } + } + app_event_tx.send(AppEvent::MarathonLoginResult { alias, result }); + }); + } + AppEvent::MarathonStatusResult { result } => { + self.chat_widget.on_marathon_status_result(result); + } + AppEvent::MarathonEnabledSetResult { enabled, result } => { + self.chat_widget + .on_marathon_enabled_set_result(enabled, result); + } + AppEvent::MarathonAutoResetSetResult { enabled, result } => { + self.chat_widget + .on_marathon_auto_reset_set_result(enabled, result); + } + AppEvent::MarathonSwitchResult { target, result } => { + self.chat_widget.on_marathon_switch_result(target, result); + } + AppEvent::MarathonImportResult { alias, result } => { + self.chat_widget.on_marathon_import_result(alias, result); + } + AppEvent::MarathonLoginResult { alias, result } => { + self.chat_widget.on_marathon_login_result(alias, result); + } AppEvent::TaskSearchResult { thread_id, query, diff --git a/runtime/codex-rs/tui/src/app_event.rs b/runtime/codex-rs/tui/src/app_event.rs index 9e183663..2f4a13b9 100644 --- a/runtime/codex-rs/tui/src/app_event.rs +++ b/runtime/codex-rs/tui/src/app_event.rs @@ -19,6 +19,12 @@ use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditResponse; use codex_app_server_protocol::DynamicToolCallResponse; use codex_app_server_protocol::GetAccountRateLimitsResponse; use codex_app_server_protocol::GetAccountTokenUsageResponse; +use codex_app_server_protocol::LoginAccountResponse; +use codex_app_server_protocol::MarathonAutoResetSetResponse; +use codex_app_server_protocol::MarathonEnabledSetResponse; +use codex_app_server_protocol::MarathonImportResponse; +use codex_app_server_protocol::MarathonStatusResponse; +use codex_app_server_protocol::MarathonSwitchResponse; use codex_app_server_protocol::MarketplaceAddResponse; use codex_app_server_protocol::MarketplaceRemoveResponse; use codex_app_server_protocol::MarketplaceUpgradeResponse; @@ -206,6 +212,17 @@ pub(crate) enum CodexMarathonRecoveryLifecycle { }, } +/// Selects how a native Marathon account login is completed. +/// +/// Browser login is convenient on a local machine. Device-code login keeps +/// the Codex process headless so the URL and one-time code can be completed +/// from another machine, such as an AWS instance. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum MarathonLoginMode { + Browser, + DeviceCode, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum KeymapEditIntent { ReplaceAll, @@ -529,13 +546,80 @@ pub(crate) enum AppEvent { /// `CODEX_HOME/auth.json` changed on disk; reload auth from storage. AuthFileChanged, /// Retry a deferred auth reload after a previous attempt failed. - AuthFileChangedRetry { attempt: u8 }, + AuthFileChangedRetry { + attempt: u8, + }, /// Forward one native recovery lifecycle observation to app-server. CodexMarathonRecoveryLifecycle { event: CodexMarathonRecoveryLifecycle, }, + /// Request native Marathon status through the in-process app-server. + MarathonStatusRequest, + + /// Set the native Marathon enabled state through the in-process app-server. + MarathonEnabledSetRequest { + enabled: bool, + }, + + /// Set whether Marathon may consume a provider-supported immediate quota + /// reset action after all eligible accounts reach zero weekly quota. + MarathonAutoResetSetRequest { + enabled: bool, + }, + + /// Switch the native Codex auth identity at an idle boundary. + MarathonSwitchRequest { + target: String, + }, + + /// Capture the currently authenticated native identity under an alias. + MarathonImportRequest { + alias: String, + }, + + /// Start the existing native Codex login flow for a future Marathon account. + MarathonLoginRequest { + alias: String, + mode: MarathonLoginMode, + }, + + /// Result of reading native Marathon status. + MarathonStatusResult { + result: Result, + }, + + /// Result of changing native Marathon's enabled state. + MarathonEnabledSetResult { + enabled: bool, + result: Result, + }, + + /// Result of changing native Marathon automatic reset-action enablement. + MarathonAutoResetSetResult { + enabled: bool, + result: Result, + }, + + /// Result of requesting a native account transition. + MarathonSwitchResult { + target: String, + result: Result, + }, + + /// Result of importing the current native account into Marathon. + MarathonImportResult { + alias: String, + result: Result, + }, + + /// Result of starting the native Codex login flow. + MarathonLoginResult { + alias: String, + result: Result, + }, + /// Background Git-status poller for the status header produced a new summary. StatusHeaderGitStatusUpdated { cwd: std::path::PathBuf, diff --git a/runtime/codex-rs/tui/src/bottom_pane/status_line_setup.rs b/runtime/codex-rs/tui/src/bottom_pane/status_line_setup.rs index a5f728ed..dd6620c0 100644 --- a/runtime/codex-rs/tui/src/bottom_pane/status_line_setup.rs +++ b/runtime/codex-rs/tui/src/bottom_pane/status_line_setup.rs @@ -150,6 +150,21 @@ pub(crate) enum StatusLineItem { /// Current workspace notification headline. WorkspaceHeadline, + /// Current signed-in account identity. + Account, + + /// Whether the native CodexMarathon service is enabled. + #[strum(to_string = "marathon", serialize = "marathon-status")] + Marathon, + + /// Number of accounts managed by the native CodexMarathon service. + #[strum( + to_string = "marathon-accounts", + serialize = "managed-accounts", + serialize = "managed-account-count" + )] + ManagedAccounts, + /// Latest checklist task progress from `update_plan` (if available). TaskProgress, } @@ -208,6 +223,9 @@ impl StatusLineItem { StatusLineItem::WorkspaceHeadline => { "Workspace notification headline (Enterprise workspaces only; omitted when unavailable)" } + StatusLineItem::Account => "Current signed-in account (omitted when unavailable)", + StatusLineItem::Marathon => "Native CodexMarathon state (enabled or disabled)", + StatusLineItem::ManagedAccounts => "Number of accounts managed by native CodexMarathon", StatusLineItem::TaskProgress => { "Latest task progress from update_plan (omitted until available)" } @@ -244,6 +262,9 @@ impl StatusLineItem { StatusLineItem::RawOutput => StatusSurfacePreviewItem::RawOutput, StatusLineItem::ThreadTitle => StatusSurfacePreviewItem::ThreadTitle, StatusLineItem::WorkspaceHeadline => StatusSurfacePreviewItem::WorkspaceHeadline, + StatusLineItem::Account => StatusSurfacePreviewItem::Account, + StatusLineItem::Marathon => StatusSurfacePreviewItem::Marathon, + StatusLineItem::ManagedAccounts => StatusSurfacePreviewItem::ManagedAccounts, StatusLineItem::TaskProgress => StatusSurfacePreviewItem::TaskProgress, } } @@ -508,6 +529,32 @@ mod tests { ); } + #[test] + fn account_is_selectable_id() { + assert_eq!(StatusLineItem::Account.to_string(), "account"); + assert_eq!( + "account".parse::(), + Ok(StatusLineItem::Account) + ); + } + + #[test] + fn marathon_items_are_selectable_ids() { + assert_eq!(StatusLineItem::Marathon.to_string(), "marathon"); + assert_eq!( + "marathon-status".parse::(), + Ok(StatusLineItem::Marathon) + ); + assert_eq!( + "managed-accounts".parse::(), + Ok(StatusLineItem::ManagedAccounts) + ); + assert_eq!( + "marathon-accounts".parse::(), + Ok(StatusLineItem::ManagedAccounts) + ); + } + #[test] fn run_state_is_canonical_and_accepts_status_legacy_id() { assert_eq!(StatusLineItem::Status.to_string(), "run-state"); diff --git a/runtime/codex-rs/tui/src/bottom_pane/status_line_style.rs b/runtime/codex-rs/tui/src/bottom_pane/status_line_style.rs index 4783f552..6b214340 100644 --- a/runtime/codex-rs/tui/src/bottom_pane/status_line_style.rs +++ b/runtime/codex-rs/tui/src/bottom_pane/status_line_style.rs @@ -54,6 +54,9 @@ impl StatusLineAccent { StatusLineItem::Permissions => Self::Mode, StatusLineItem::ApprovalMode => Self::Mode, StatusLineItem::ThreadTitle | StatusLineItem::WorkspaceHeadline => Self::Thread, + StatusLineItem::Account => Self::Metadata, + StatusLineItem::Marathon => Self::Mode, + StatusLineItem::ManagedAccounts => Self::Metadata, StatusLineItem::TaskProgress => Self::Progress, } } diff --git a/runtime/codex-rs/tui/src/bottom_pane/status_surface_preview.rs b/runtime/codex-rs/tui/src/bottom_pane/status_surface_preview.rs index c0ce0f6c..6eda6f2c 100644 --- a/runtime/codex-rs/tui/src/bottom_pane/status_surface_preview.rs +++ b/runtime/codex-rs/tui/src/bottom_pane/status_surface_preview.rs @@ -34,6 +34,9 @@ pub(crate) enum StatusSurfacePreviewItem { FastMode, RawOutput, WorkspaceHeadline, + Account, + Marathon, + ManagedAccounts, Model, ModelWithReasoning, Reasoning, @@ -70,6 +73,9 @@ impl StatusSurfacePreviewItem { StatusSurfacePreviewItem::FastMode => "Fast on", StatusSurfacePreviewItem::RawOutput => "raw output", StatusSurfacePreviewItem::WorkspaceHeadline => "Workspace headline", + StatusSurfacePreviewItem::Account => "user@example.com(Pro)", + StatusSurfacePreviewItem::Marathon => "Marathon enabled", + StatusSurfacePreviewItem::ManagedAccounts => "Accounts 2", StatusSurfacePreviewItem::Model => "gpt-5.2-codex", StatusSurfacePreviewItem::ModelWithReasoning => "gpt-5.2-codex medium", StatusSurfacePreviewItem::Reasoning => "medium", @@ -106,6 +112,9 @@ impl StatusSurfacePreviewItem { Self::FastMode, Self::RawOutput, Self::WorkspaceHeadline, + Self::Account, + Self::Marathon, + Self::ManagedAccounts, Self::Model, Self::ModelWithReasoning, Self::Reasoning, diff --git a/runtime/codex-rs/tui/src/chatwidget.rs b/runtime/codex-rs/tui/src/chatwidget.rs index 85d4d2ba..6ef3141a 100644 --- a/runtime/codex-rs/tui/src/chatwidget.rs +++ b/runtime/codex-rs/tui/src/chatwidget.rs @@ -587,6 +587,11 @@ pub(crate) struct ChatWidget { session_header: SessionHeader, initial_user_message: Option, status_account_display: Option, + /// Last secret-free status received from the optional CodexMarathon controller. + marathon_controller_status: Option, + /// Login started from `/marathon login`; importing waits for both native + /// login completion and the following AuthManager reload notification. + pending_marathon_login: Option, runtime_model_provider_base_url: Option, pub(crate) remote_connection: Option, token_info: Option, @@ -839,6 +844,13 @@ pub(crate) struct ChatWidget { marathon_recovery_controller_enabled: bool, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct PendingMarathonLogin { + login_id: String, + alias: String, + completed: bool, +} + #[cfg_attr(not(test), allow(dead_code))] enum CodexOpTarget { Direct(UnboundedSender), @@ -1362,12 +1374,7 @@ impl ChatWidget { self.request_redraw(); } - fn on_committed_user_message( - &mut self, - items: &[UserInput], - turn_id: &str, - from_replay: bool, - ) { + fn on_committed_user_message(&mut self, items: &[UserInput], turn_id: &str, from_replay: bool) { let display = Self::user_message_display_from_inputs(items); if from_replay { if self.review.is_review_mode { @@ -1405,12 +1412,12 @@ impl ChatWidget { self.on_user_message_display(display); } } else if !self.review.is_review_mode { - let is_local_echo = self - .pending_local_user_message_echo - .as_ref() - .is_some_and(|pending| { - pending.turn_id.as_deref() == Some(turn_id) && pending.display == display - }); + let is_local_echo = + self.pending_local_user_message_echo + .as_ref() + .is_some_and(|pending| { + pending.turn_id.as_deref() == Some(turn_id) && pending.display == display + }); if is_local_echo { self.pending_local_user_message_echo = None; } else { diff --git a/runtime/codex-rs/tui/src/chatwidget/constructor.rs b/runtime/codex-rs/tui/src/chatwidget/constructor.rs index b47664c4..9e4a43ac 100644 --- a/runtime/codex-rs/tui/src/chatwidget/constructor.rs +++ b/runtime/codex-rs/tui/src/chatwidget/constructor.rs @@ -125,6 +125,8 @@ impl ChatWidget { session_header: SessionHeader::new(header_model), initial_user_message, status_account_display, + marathon_controller_status: None, + pending_marathon_login: None, runtime_model_provider_base_url, remote_connection: None, token_info: None, diff --git a/runtime/codex-rs/tui/src/chatwidget/input_flow.rs b/runtime/codex-rs/tui/src/chatwidget/input_flow.rs index 4a192c44..7808344b 100644 --- a/runtime/codex-rs/tui/src/chatwidget/input_flow.rs +++ b/runtime/codex-rs/tui/src/chatwidget/input_flow.rs @@ -197,13 +197,14 @@ impl ChatWidget { self.active_usage_limit_resume_thread_id = recovery_thread_id.clone(); self.active_usage_limit_resume_turn_id = recovery_turn_id.clone(); self.active_usage_limit_resume_dispatch_turn_id = None; - self.app_event_tx.send(AppEvent::CodexMarathonRecoveryLifecycle { - event: crate::app_event::CodexMarathonRecoveryLifecycle::Started { - recovery_id, - thread_id: recovery_thread_id, - turn_id: recovery_turn_id, - }, - }); + self.app_event_tx + .send(AppEvent::CodexMarathonRecoveryLifecycle { + event: crate::app_event::CodexMarathonRecoveryLifecycle::Started { + recovery_id, + thread_id: recovery_thread_id, + turn_id: recovery_turn_id, + }, + }); } } else { // A transient submission rejection must not lose Codext's diff --git a/runtime/codex-rs/tui/src/chatwidget/input_submission.rs b/runtime/codex-rs/tui/src/chatwidget/input_submission.rs index 2fcd327e..31380749 100644 --- a/runtime/codex-rs/tui/src/chatwidget/input_submission.rs +++ b/runtime/codex-rs/tui/src/chatwidget/input_submission.rs @@ -395,10 +395,8 @@ impl ChatWidget { let render_before_submit = render_in_history && matches!(&self.codex_op_target, CodexOpTarget::AppEvent); if render_before_submit { - let display = user_message_display_for_history( - submitted_message.clone(), - &history_record, - ); + let display = + user_message_display_for_history(submitted_message.clone(), &history_record); self.pending_local_user_message_echo = Some(PendingLocalUserMessageEcho { display: display.clone(), turn_id: None, diff --git a/runtime/codex-rs/tui/src/chatwidget/protocol.rs b/runtime/codex-rs/tui/src/chatwidget/protocol.rs index 356a19a0..e732913d 100644 --- a/runtime/codex-rs/tui/src/chatwidget/protocol.rs +++ b/runtime/codex-rs/tui/src/chatwidget/protocol.rs @@ -239,6 +239,9 @@ impl ChatWidget { self.on_shutdown_complete(); } } + ServerNotification::AccountLoginCompleted(notification) => { + self.on_marathon_login_completed(notification); + } ServerNotification::ServerRequestResolved(_) | ServerNotification::AccountUpdated(_) | ServerNotification::AccountRateLimitsUpdated(_) @@ -281,7 +284,6 @@ impl ChatWidget { | ServerNotification::ThreadRealtimeTranscriptDone(_) | ServerNotification::WindowsWorldWritableWarning(_) | ServerNotification::WindowsSandboxSetupCompleted(_) - | ServerNotification::AccountLoginCompleted(_) | ServerNotification::ProjectChanged(_) | ServerNotification::ThreadProjectUpdated(_) => {} ServerNotification::ContextCompacted(_) => {} @@ -420,15 +422,16 @@ impl ChatWidget { .clone() .or_else(|| Some(notification.thread_id.clone())); let turn_id = self.active_usage_limit_resume_turn_id.clone(); - self.app_event_tx.send(AppEvent::CodexMarathonRecoveryLifecycle { - event: crate::app_event::CodexMarathonRecoveryLifecycle::Completed { - recovery_id, - thread_id, - turn_id, - outcome: outcome.to_string(), - error_code: None, - }, - }); + self.app_event_tx + .send(AppEvent::CodexMarathonRecoveryLifecycle { + event: crate::app_event::CodexMarathonRecoveryLifecycle::Completed { + recovery_id, + thread_id, + turn_id, + outcome: outcome.to_string(), + error_code: None, + }, + }); self.active_usage_limit_resume_recovery_id = None; self.active_usage_limit_resume_thread_id = None; self.active_usage_limit_resume_turn_id = None; diff --git a/runtime/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/runtime/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 89a08b73..68089ae6 100644 --- a/runtime/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/runtime/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -6,6 +6,7 @@ //! slash-command recall follows the same submitted-input rule as ordinary text. use super::*; +use crate::app_event::MarathonLoginMode; use crate::app_event::ThreadGoalSetMode; use crate::bottom_pane::prompt_args::parse_slash_name; use crate::bottom_pane::slash_commands::BuiltinCommandFlags; @@ -475,6 +476,10 @@ impl ChatWidget { ); } } + SlashCommand::Marathon => { + self.show_marathon_help(); + self.request_marathon_status(); + } SlashCommand::Cd => { self.dispatch_command_with_args(SlashCommand::Cd, "~".to_string(), Vec::new()); } @@ -734,6 +739,83 @@ impl ChatWidget { } } } + SlashCommand::Marathon => { + let (subcommand, rest) = trimmed + .split_once(char::is_whitespace) + .unwrap_or((trimmed, "")); + match subcommand.to_ascii_lowercase().as_str() { + "" => { + self.show_marathon_help(); + self.request_marathon_status(); + } + "status" if rest.trim().is_empty() => self.request_marathon_status(), + "on" | "enable" if rest.trim().is_empty() => { + self.request_marathon_enabled_set(true) + } + "off" | "disable" if rest.trim().is_empty() => { + self.request_marathon_enabled_set(false) + } + "auto-reset" | "autoreset" => match rest.trim().to_ascii_lowercase().as_str() { + "on" | "enable" => self.request_marathon_auto_reset_set(true), + "off" | "disable" => self.request_marathon_auto_reset_set(false), + "status" | "" => self.request_marathon_status(), + _ => self.add_error_message( + "Usage: /marathon auto-reset [on|off|status]".to_string(), + ), + }, + "import" => { + let alias = rest.trim(); + if alias.is_empty() { + self.add_error_message("Usage: /marathon import ".to_string()); + } else { + self.request_marathon_import(alias.to_string()); + } + } + "login" => { + let mut parts = rest.split_whitespace(); + let alias = parts.next().unwrap_or_default(); + let mode = parts.next().map(str::to_ascii_lowercase); + let extra = parts.next(); + if alias.is_empty() { + self.add_error_message( + "Usage: /marathon login [browser|device-code]".to_string(), + ); + } else if extra.is_some() { + self.add_error_message( + "Usage: /marathon login [browser|device-code]".to_string(), + ); + } else { + match mode.as_deref() { + None => self.request_marathon_login(alias.to_string()), + Some("browser" | "link") => self.request_marathon_login_with_mode( + alias.to_string(), + MarathonLoginMode::Browser, + ), + Some("device-code" | "device" | "auth-code") => self + .request_marathon_login_with_mode( + alias.to_string(), + MarathonLoginMode::DeviceCode, + ), + Some(_) => self.add_error_message( + "Usage: /marathon login [browser|device-code]" + .to_string(), + ), + } + } + } + "switch" => { + let target = rest.trim(); + if target.is_empty() { + self.add_error_message( + "Usage: /marathon switch ".to_string(), + ); + } else { + self.request_marathon_switch(target.to_string()); + } + } + _ => self.show_marathon_help(), + } + } SlashCommand::Ide => { self.handle_ide_command_args(trimmed); } @@ -1120,6 +1202,422 @@ impl ChatWidget { } } + fn request_marathon_status(&mut self) { + self.app_event_tx.send(AppEvent::MarathonStatusRequest); + } + + fn show_marathon_help(&mut self) { + let current = match self.marathon_controller_status.as_ref() { + Some(status) => { + let state = if status.enabled { + "enabled" + } else { + "disabled" + }; + let active = status + .active_account_id + .as_deref() + .or(status.current_account_id.as_deref()) + .unwrap_or("none"); + format!( + "Current: {state} · active account: {active} · accounts: {}", + status.accounts.len() + ) + } + None => "Current: checking native Marathon status…".to_string(), + }; + self.add_info_message( + format!( + "Native Marathon is built into Codex.\n{current}\n\nCommands:\n /marathon on | off\n /marathon status\n /marathon auto-reset on | off | status\n /marathon login (choose browser link or device code)\n /marathon import \n /marathon switch \n\nAutomatic reset uses a provider-supported reset action only after every eligible managed account reports zero weekly quota. It never guesses a reset time. For a headless server, choose device code and finish sign-in from another machine. No separate codexmarathon command is required." + ), + Some("Use ↑ to recall this help".to_string()), + ); + } + + fn request_marathon_enabled_set(&mut self, enabled: bool) { + self.app_event_tx + .send(AppEvent::MarathonEnabledSetRequest { enabled }); + } + + fn request_marathon_auto_reset_set(&mut self, enabled: bool) { + self.add_info_message( + format!( + "Native Marathon automatic provider quota reset {}…", + if enabled { "enabled" } else { "disabled" } + ), + /*hint*/ None, + ); + self.app_event_tx + .send(AppEvent::MarathonAutoResetSetRequest { enabled }); + } + + fn request_marathon_import(&mut self, alias: String) { + self.add_info_message( + format!("CodexMarathon: saving the current account as {alias}…"), + /*hint*/ None, + ); + self.app_event_tx + .send(AppEvent::MarathonImportRequest { alias }); + } + + fn request_marathon_login(&mut self, alias: String) { + let browser_alias = alias.clone(); + let device_alias = alias.clone(); + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some(format!("Login Marathon account `{alias}`")), + subtitle: Some( + "Choose how to complete ChatGPT sign-in. Device code works on headless servers." + .to_string(), + ), + footer_hint: Some(standard_popup_hint_line()), + initial_selected_idx: Some(0), + items: vec![ + SelectionItem { + name: "Browser link".to_string(), + description: Some( + "Open the sign-in link in a browser on this machine".to_string(), + ), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::MarathonLoginRequest { + alias: browser_alias.clone(), + mode: MarathonLoginMode::Browser, + }); + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Device / auth code".to_string(), + description: Some( + "Show a URL and one-time code for another machine to complete".to_string(), + ), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::MarathonLoginRequest { + alias: device_alias.clone(), + mode: MarathonLoginMode::DeviceCode, + }); + })], + dismiss_on_select: true, + ..Default::default() + }, + ], + ..Default::default() + }); + } + + fn request_marathon_login_with_mode(&mut self, alias: String, mode: MarathonLoginMode) { + self.add_info_message( + format!("Native Marathon login starting for `{alias}`…"), + /*hint*/ None, + ); + self.app_event_tx + .send(AppEvent::MarathonLoginRequest { alias, mode }); + } + + fn request_marathon_switch(&mut self, target: String) { + self.add_info_message( + format!("CodexMarathon: requesting switch to {target}…"), + /*hint*/ None, + ); + self.app_event_tx + .send(AppEvent::MarathonSwitchRequest { target }); + } + + pub(crate) fn on_marathon_status_result( + &mut self, + result: Result, + ) { + match result { + Ok(status) => { + self.marathon_controller_status = Some(status.clone()); + self.refresh_status_line(); + self.add_marathon_controller_status(status); + } + Err(error) => { + self.marathon_controller_status = None; + self.refresh_status_line(); + self.add_error_message(format!("CodexMarathon native service unavailable: {error}")) + } + } + } + + pub(crate) fn on_marathon_enabled_set_result( + &mut self, + enabled: bool, + result: Result, + ) { + match result { + Ok(response) => { + if let Some(status) = self.marathon_controller_status.as_mut() { + status.enabled = response.enabled; + } + self.refresh_status_line(); + self.add_info_message( + format!( + "CodexMarathon: {}.", + if response.enabled { + "enabled" + } else { + "disabled" + } + ), + /*hint*/ None, + ); + } + Err(error) => self.add_error_message(format!( + "CodexMarathon: could not {} service: {error}", + if enabled { "enable" } else { "disable" } + )), + } + } + + pub(crate) fn on_marathon_auto_reset_set_result( + &mut self, + enabled: bool, + result: Result, + ) { + match result { + Ok(response) => { + if let Some(status) = self.marathon_controller_status.as_mut() { + status.auto_reset_enabled = response.enabled; + status.auto_reset_phase = response.phase.clone(); + status.auto_reset_last_error = None; + } + self.refresh_status_line(); + self.add_info_message( + format!( + "Native Marathon automatic provider quota reset is {} (phase: {}).", + if response.enabled { + "enabled" + } else { + "disabled" + }, + response.phase + ), + /*hint*/ None, + ); + } + Err(error) => self.add_error_message(format!( + "Native Marathon automatic quota reset could not be {}: {error}", + if enabled { "enabled" } else { "disabled" } + )), + } + } + + pub(crate) fn on_marathon_switch_result( + &mut self, + target: String, + result: Result, + ) { + match result { + Ok(result) + if result.outcome + == codex_app_server_protocol::MarathonSwitchOutcome::Committed => + { + let account = result.account_id.unwrap_or(target.clone()); + self.add_info_message( + format!("CodexMarathon: switched to {account}."), + /*hint*/ None, + ); + } + Ok(result) => { + let outcome = match result.outcome { + codex_app_server_protocol::MarathonSwitchOutcome::Rejected => "was rejected", + codex_app_server_protocol::MarathonSwitchOutcome::Deferred => { + "is waiting for active turns to finish" + } + codex_app_server_protocol::MarathonSwitchOutcome::Committed => "completed", + }; + let reason = result + .reason + .map(|reason| format!(": {reason}")) + .unwrap_or_default(); + self.add_error_message(format!( + "CodexMarathon: switch to {target} {outcome}{reason}." + )); + } + Err(error) => { + self.add_error_message(format!("CodexMarathon: switch to {target} failed: {error}")) + } + } + } + + pub(crate) fn on_marathon_import_result( + &mut self, + alias: String, + result: Result, + ) { + match result { + Ok(result) => { + self.add_info_message( + format!( + "CodexMarathon: imported {alias} ({}) as the active account.", + result.account_id + ), + /*hint*/ None, + ); + self.request_marathon_status(); + } + Err(error) => { + self.add_error_message(format!("CodexMarathon: import of {alias} failed: {error}")) + } + } + } + + pub(crate) fn on_marathon_login_result( + &mut self, + alias: String, + result: Result, + ) { + match result { + Ok(codex_app_server_protocol::LoginAccountResponse::Chatgpt { login_id, auth_url }) => { + self.pending_marathon_login = Some(PendingMarathonLogin { + login_id, + alias: alias.clone(), + completed: false, + }); + self.add_info_message( + format!( + "Native Marathon login for `{alias}`: finish sign-in at this browser link. The account will be saved automatically when login completes.\n{auth_url}" + ), + /*hint*/ None, + ); + } + Ok(codex_app_server_protocol::LoginAccountResponse::ChatgptDeviceCode { + login_id, + verification_url, + user_code, + }) => { + self.pending_marathon_login = Some(PendingMarathonLogin { + login_id, + alias: alias.clone(), + completed: false, + }); + self.add_info_message( + format!( + "Native Marathon login for `{alias}`: open {verification_url} on another machine and enter one-time code {user_code}. The account will be saved automatically when login completes." + ), + /*hint*/ None, + ); + } + Ok(_) => self.add_info_message( + format!("Native Marathon login completed for `{alias}`; saving the account…"), + /*hint*/ None, + ), + Err(error) => { + self.add_error_message(format!("CodexMarathon: login for {alias} failed: {error}")) + } + } + } + + pub(crate) fn on_marathon_login_completed( + &mut self, + notification: codex_app_server_protocol::AccountLoginCompletedNotification, + ) { + let Some(pending) = self.pending_marathon_login.as_mut() else { + return; + }; + if notification.login_id.as_deref() != Some(pending.login_id.as_str()) { + return; + } + if notification.success { + pending.completed = true; + } else { + let alias = pending.alias.clone(); + self.pending_marathon_login = None; + self.add_error_message(format!( + "Native Marathon login for `{alias}` failed{}.", + notification + .error + .map(|error| format!(": {error}")) + .unwrap_or_default() + )); + } + } + + /// The app-server emits AccountUpdated after AuthManager reloads the new + /// credentials. Only then is it safe to snapshot the identity under the + /// requested Marathon alias. + pub(crate) fn on_marathon_account_updated(&mut self) { + let Some(pending) = self.pending_marathon_login.as_ref() else { + return; + }; + if !pending.completed { + return; + } + let alias = pending.alias.clone(); + self.pending_marathon_login = None; + self.add_info_message( + format!("Native Marathon login completed for `{alias}`; saving account…"), + /*hint*/ None, + ); + self.app_event_tx + .send(AppEvent::MarathonImportRequest { alias }); + } + + fn add_marathon_controller_status(&mut self, status: crate::marathon_control::MarathonStatus) { + let service_state = if status.enabled { + "enabled" + } else { + "disabled" + }; + let active = status + .active_account_id + .as_deref() + .or(status.current_account_id.as_deref()) + .unwrap_or("none") + .to_string(); + let mut lines = vec![ + Line::from(format!("• CodexMarathon: {service_state}")), + Line::from(format!(" Active account: {active}")), + Line::from(format!( + " Automatic provider quota reset: {} (phase: {})", + if status.auto_reset_enabled { + "enabled" + } else { + "disabled" + }, + status.auto_reset_phase + )), + ]; + if let Some(error) = status.auto_reset_last_error.as_deref() { + lines.push(Line::from(format!(" Last reset result: {error}"))); + } + if status.accounts.is_empty() { + lines.push(Line::from(" Accounts: none configured")); + } else { + lines.push(Line::from(" Accounts:")); + for account in status.accounts { + let name = if account.alias.is_empty() { + account.account_id.clone() + } else if account.account_id.is_empty() { + account.alias.clone() + } else { + format!("{} ({})", account.alias, account.account_id) + }; + let state = if account.active { "active" } else { "inactive" }; + let health = if account.credential_health.is_empty() { + "health unknown" + } else { + account.credential_health.as_str() + }; + lines.push(Line::from(format!( + " - {name}: {state}, {health}{}", + if account.credential_present { + "" + } else { + ", credential missing" + } + ))); + } + } + lines.push(Line::from(format!( + " Active turns: {}, auth generation {}", + status.active_turn_count, status.auth_generation + ))); + self.add_plain_history_lines(lines); + } + fn ensure_usage_command_available(&mut self) -> bool { if self.has_codex_backend_auth { return true; @@ -1135,6 +1633,7 @@ impl ChatWidget { match cmd { SlashCommand::Ide | SlashCommand::Status + | SlashCommand::Marathon | SlashCommand::Pwd | SlashCommand::Usage | SlashCommand::DebugConfig diff --git a/runtime/codex-rs/tui/src/chatwidget/status_header.rs b/runtime/codex-rs/tui/src/chatwidget/status_header.rs index 4feccadb..d474346c 100644 --- a/runtime/codex-rs/tui/src/chatwidget/status_header.rs +++ b/runtime/codex-rs/tui/src/chatwidget/status_header.rs @@ -1,14 +1,12 @@ use std::path::PathBuf; use std::time::Duration; -use codex_protocol::account::PlanType; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; use ratatui::widgets::Widget; use unicode_width::UnicodeWidthStr; -use crate::status::StatusAccountDisplay; use crate::ui_consts::LIVE_PREFIX_COLS; use super::*; @@ -115,7 +113,7 @@ impl StatusHeader { directory: crate::status::format_directory_display(widget.status_line_cwd(), None), git: widget.status_header_git_status.clone(), rate_limit, - account: account_label( + account: crate::status::format_account_label( widget.status_account_display(), widget.current_plan_type(), ), @@ -142,7 +140,9 @@ impl StatusHeader { push(vec!["\u{ee9c} ".cyan(), Span::from(model.clone()).cyan()]); } if !self.directory.is_empty() { - let available = width.saturating_sub(UnicodeWidthStr::width("\u{f07c} ")).max(8); + let available = width + .saturating_sub(UnicodeWidthStr::width("\u{f07c} ")) + .max(8); let directory = crate::text_formatting::center_truncate_path(&self.directory, available); push(vec!["\u{f07c} ".yellow(), Span::from(directory).yellow()]); @@ -164,7 +164,10 @@ impl StatusHeader { push(segment); } if let Some(rate_limit) = &self.rate_limit { - push(vec!["\u{f464} ".cyan(), Span::from(rate_limit.clone()).cyan()]); + push(vec![ + "\u{f464} ".cyan(), + Span::from(rate_limit.clone()).cyan(), + ]); } if let Some(account) = &self.account { push(vec![Span::from(account.clone()).cyan()]); @@ -183,25 +186,6 @@ impl Renderable for StatusHeader { } } -/// Renders the account segment: `user@example.com(Pro)` for ChatGPT accounts. -fn account_label( - account: Option<&StatusAccountDisplay>, - plan_type: Option, -) -> Option { - match account { - Some(StatusAccountDisplay::ChatGpt { email, plan }) => match (email, plan) { - (Some(email), Some(plan)) => Some(format!("{email}({plan})")), - (Some(email), None) => Some(format!( - "{email}({})", - plan_type.map(crate::status::plan_type_display_name)? - )), - _ => None, - }, - Some(StatusAccountDisplay::ApiKey) => Some("API key".to_string()), - None => None, - } -} - fn compact_reset_time(resets_at: &str) -> &str { resets_at .split_once(' ') diff --git a/runtime/codex-rs/tui/src/chatwidget/status_surfaces.rs b/runtime/codex-rs/tui/src/chatwidget/status_surfaces.rs index f9e7edb3..4f4503a7 100644 --- a/runtime/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/runtime/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -9,6 +9,7 @@ use crate::branch_summary; use crate::chatwidget::limit_label_for_window; use crate::chatwidget::rate_limits::get_limits_duration; use crate::legacy_core::config::Config; +use crate::status::format_account_label; use crate::status::format_credit_micros; use crate::status::format_estimated_usd_micros; use crate::status::format_tokens_compact; @@ -785,10 +786,30 @@ impl ChatWidget { }, ), StatusLineItem::WorkspaceHeadline => self.status_line_workspace_headline.clone(), + StatusLineItem::Account => { + format_account_label(self.status_account_display(), self.current_plan_type()) + } + StatusLineItem::Marathon => Some(self.marathon_status_line_value()), + StatusLineItem::ManagedAccounts => Some(self.marathon_accounts_line_value()), StatusLineItem::TaskProgress => self.terminal_title_task_progress(), } } + fn marathon_status_line_value(&self) -> String { + match self.marathon_controller_status.as_ref() { + Some(status) if status.enabled => "Marathon enabled".to_string(), + Some(_) => "Marathon disabled".to_string(), + None => "Marathon disabled".to_string(), + } + } + + fn marathon_accounts_line_value(&self) -> String { + match self.marathon_controller_status.as_ref() { + Some(status) => format!("Accounts {}", status.accounts.len()), + None => "Accounts disabled".to_string(), + } + } + fn status_line_pull_request_url(&self) -> Option { self.status_line_git_summary .as_ref() @@ -829,6 +850,9 @@ impl ChatWidget { StatusSurfacePreviewItem::FastMode => StatusLineItem::FastMode, StatusSurfacePreviewItem::RawOutput => StatusLineItem::RawOutput, StatusSurfacePreviewItem::WorkspaceHeadline => StatusLineItem::WorkspaceHeadline, + StatusSurfacePreviewItem::Account => StatusLineItem::Account, + StatusSurfacePreviewItem::Marathon => StatusLineItem::Marathon, + StatusSurfacePreviewItem::ManagedAccounts => StatusLineItem::ManagedAccounts, StatusSurfacePreviewItem::Model => StatusLineItem::ModelName, StatusSurfacePreviewItem::ModelWithReasoning => StatusLineItem::ModelWithReasoning, StatusSurfacePreviewItem::Reasoning => StatusLineItem::Reasoning, diff --git a/runtime/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/runtime/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 6b728989..99d50eb8 100644 --- a/runtime/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/runtime/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -3544,6 +3544,30 @@ async fn status_line_workspace_headline_renders_cached_value() { ); } +#[tokio::test] +async fn status_line_account_renders_current_account() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.config.tui_status_line = Some(vec!["account".to_string()]); + chat.update_account_state( + Some(StatusAccountDisplay::ChatGpt { + email: Some("status@example.com".to_string()), + plan: Some("Team".to_string()), + }), + Some(PlanType::Business), + /*has_chatgpt_account*/ true, + /*has_codex_backend_auth*/ true, + ); + + assert_eq!( + status_line_text(&chat), + Some("status@example.com(Team)".to_string()) + ); + assert_eq!( + chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::Account), + Some("status@example.com(Team)".to_string()) + ); +} + #[tokio::test] async fn status_line_workspace_headline_omits_when_unavailable() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/runtime/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs b/runtime/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs index 25f3e83a..634ead7b 100644 --- a/runtime/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs +++ b/runtime/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs @@ -31,6 +31,140 @@ async fn status_command_renders_immediately_and_refreshes_rate_limits_for_chatgp pretty_assertions::assert_eq!(request_id, 0); } +#[tokio::test] +async fn marathon_status_displays_current_account_state() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.dispatch_command_with_args(SlashCommand::Marathon, "status".to_string(), Vec::new()); + + assert_matches!(rx.try_recv(), Ok(AppEvent::MarathonStatusRequest)); +} + +#[tokio::test] +async fn marathon_account_commands_use_native_app_server_events() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.dispatch_command_with_args(SlashCommand::Marathon, "on".to_string(), Vec::new()); + assert_matches!( + rx.try_recv(), + Ok(AppEvent::MarathonEnabledSetRequest { enabled: true }) + ); + + chat.dispatch_command_with_args( + SlashCommand::Marathon, + "import work".to_string(), + Vec::new(), + ); + assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_))); + assert_matches!( + rx.try_recv(), + Ok(AppEvent::MarathonImportRequest { alias }) if alias == "work" + ); + + chat.dispatch_command_with_args( + SlashCommand::Marathon, + "login personal device-code".to_string(), + Vec::new(), + ); + assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_))); + assert_matches!( + rx.try_recv(), + Ok(AppEvent::MarathonLoginRequest { alias, mode }) + if alias == "personal" + && mode == crate::app_event::MarathonLoginMode::DeviceCode + ); +} + +#[tokio::test] +async fn bare_marathon_command_shows_native_help_and_refreshes_status() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.dispatch_command(SlashCommand::Marathon); + + let help = match rx.try_recv() { + Ok(AppEvent::InsertHistoryCell(cell)) => { + lines_to_single_string(&cell.display_lines(/*width*/ 120)) + } + other => panic!("expected Marathon help output, got {other:?}"), + }; + assert!(help.contains("built into Codex")); + assert!(help.contains("device code")); + assert!(help.contains("No separate codexmarathon command")); + assert_matches!(rx.try_recv(), Ok(AppEvent::MarathonStatusRequest)); +} + +#[tokio::test] +async fn marathon_switch_result_reports_success_and_failure() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.on_marathon_switch_result( + "work".to_string(), + Ok(crate::marathon_control::MarathonSwitchResult { + account_id: Some("acct-work".to_string()), + outcome: codex_app_server_protocol::MarathonSwitchOutcome::Committed, + auth_generation: 1, + active_turn_count: 0, + reason: None, + }), + ); + let success = match rx.try_recv() { + Ok(AppEvent::InsertHistoryCell(cell)) => { + lines_to_single_string(&cell.display_lines(/*width*/ 100)) + } + other => panic!("expected switch success output, got {other:?}"), + }; + assert!(success.contains("switched to acct-work")); + + chat.on_marathon_switch_result( + "missing".to_string(), + Err("account was not found".to_string()), + ); + let failure = match rx.try_recv() { + Ok(AppEvent::InsertHistoryCell(cell)) => { + lines_to_single_string(&cell.display_lines(/*width*/ 100)) + } + other => panic!("expected switch failure output, got {other:?}"), + }; + assert!(failure.contains("switch to missing failed")); + assert!(failure.contains("account was not found")); +} + +#[tokio::test] +async fn marathon_controller_status_updates_configured_status_line_items() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.config.tui_status_line = + Some(vec!["marathon".to_string(), "managed-accounts".to_string()]); + + chat.on_marathon_status_result(Ok(crate::marathon_control::MarathonStatus { + enabled: true, + active_account_id: Some("acct-work".to_string()), + current_account_id: Some("acct-work".to_string()), + auth_generation: 1, + active_turn_count: 0, + accounts: vec![ + codex_app_server_protocol::MarathonAccount { + account_id: "acct-a".to_string(), + alias: "a".to_string(), + active: true, + credential_present: true, + credential_health: "healthy".to_string(), + }, + codex_app_server_protocol::MarathonAccount { + account_id: "acct-b".to_string(), + alias: "b".to_string(), + active: false, + credential_present: true, + credential_health: "healthy".to_string(), + }, + ], + })); + + assert_eq!( + chat.status_line_text(), + Some("Marathon enabled · Accounts 2".to_string()) + ); + assert!(matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_)))); +} + #[tokio::test] async fn status_command_refresh_updates_cached_limits_for_future_status_outputs() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/runtime/codex-rs/tui/src/chatwidget/turn_runtime.rs b/runtime/codex-rs/tui/src/chatwidget/turn_runtime.rs index ab92ca76..1fdd4ef1 100644 --- a/runtime/codex-rs/tui/src/chatwidget/turn_runtime.rs +++ b/runtime/codex-rs/tui/src/chatwidget/turn_runtime.rs @@ -391,9 +391,8 @@ impl ChatWidget { return; }; self.server_overloaded_resume_attempts = next_attempt; - self.server_overloaded_resume_generation = self - .server_overloaded_resume_generation - .wrapping_add(1); + self.server_overloaded_resume_generation = + self.server_overloaded_resume_generation.wrapping_add(1); let generation = self.server_overloaded_resume_generation; self.pending_server_overloaded_resume_turn = Some(UserMessage::from(DEFAULT_SERVER_OVERLOADED_RESUME_PROMPT)); @@ -479,15 +478,16 @@ impl ChatWidget { if self.marathon_recovery_controller_enabled && let Some(recovery_id) = recovery_id { - self.app_event_tx.send(AppEvent::CodexMarathonRecoveryLifecycle { - event: crate::app_event::CodexMarathonRecoveryLifecycle::Parked { - recovery_id, - thread_id, - turn_id, - source_account_id: None, - reason: "usage_limit_exceeded".to_string(), - }, - }); + self.app_event_tx + .send(AppEvent::CodexMarathonRecoveryLifecycle { + event: crate::app_event::CodexMarathonRecoveryLifecycle::Parked { + recovery_id, + thread_id, + turn_id, + source_account_id: None, + reason: "usage_limit_exceeded".to_string(), + }, + }); } } } diff --git a/runtime/codex-rs/tui/src/lib.rs b/runtime/codex-rs/tui/src/lib.rs index 5fdfd187..b14d8c93 100644 --- a/runtime/codex-rs/tui/src/lib.rs +++ b/runtime/codex-rs/tui/src/lib.rs @@ -156,6 +156,7 @@ mod markdown; mod markdown_render; mod markdown_stream; mod markdown_text_merge; +mod marathon_control; mod mention_codec; mod model_catalog; mod model_migration; diff --git a/runtime/codex-rs/tui/src/marathon_control.rs b/runtime/codex-rs/tui/src/marathon_control.rs new file mode 100644 index 00000000..6168de2e --- /dev/null +++ b/runtime/codex-rs/tui/src/marathon_control.rs @@ -0,0 +1,11 @@ +//! Typed native Marathon values used by the TUI. +//! +//! Marathon requests are sent through the existing in-process app-server +//! request handle. This module intentionally contains no socket client and +//! does not read the legacy controller's socket environment setting; the +//! compatibility controller remains separate from the native TUI path. + +pub(crate) type MarathonStatus = codex_app_server_protocol::MarathonStatusResponse; +pub(crate) type MarathonEnabledSetResult = codex_app_server_protocol::MarathonEnabledSetResponse; +pub(crate) type MarathonSwitchResult = codex_app_server_protocol::MarathonSwitchResponse; +pub(crate) type MarathonImportResult = codex_app_server_protocol::MarathonImportResponse; diff --git a/runtime/codex-rs/tui/src/slash_command.rs b/runtime/codex-rs/tui/src/slash_command.rs index aab133ff..d41c9eb8 100644 --- a/runtime/codex-rs/tui/src/slash_command.rs +++ b/runtime/codex-rs/tui/src/slash_command.rs @@ -50,6 +50,7 @@ pub enum SlashCommand { Diff, Mention, Status, + Marathon, Cd, #[strum(to_string = "pwd", serialize = "cwd")] Pwd, @@ -110,6 +111,7 @@ impl SlashCommand { SlashCommand::Import => "import setup, this project, and recent chats from Claude Code", SlashCommand::Hooks => "view and manage lifecycle hooks", SlashCommand::Status => "show current session configuration and token usage", + SlashCommand::Marathon => "manage native CodexMarathon accounts and switching", SlashCommand::Cd => "change the current working directory", SlashCommand::Pwd => "show the current working directory", SlashCommand::Usage => "view account usage or use a usage limit reset", @@ -178,6 +180,7 @@ impl SlashCommand { | SlashCommand::Cd | SlashCommand::Pwd | SlashCommand::Usage + | SlashCommand::Marathon | SlashCommand::Pets | SlashCommand::Side | SlashCommand::Btw @@ -197,6 +200,7 @@ impl SlashCommand { | SlashCommand::Diff | SlashCommand::Mention | SlashCommand::Status + | SlashCommand::Marathon | SlashCommand::Pwd | SlashCommand::Usage | SlashCommand::Ide @@ -240,6 +244,7 @@ impl SlashCommand { | SlashCommand::Skills | SlashCommand::Hooks | SlashCommand::Status + | SlashCommand::Marathon | SlashCommand::Pwd | SlashCommand::Usage | SlashCommand::DebugConfig @@ -297,6 +302,20 @@ mod tests { assert_eq!(SlashCommand::Stop.command(), "stop"); } + #[test] + fn marathon_command_is_registered_and_parses() { + assert_eq!(SlashCommand::Marathon.command(), "marathon"); + assert_eq!( + SlashCommand::from_str("marathon"), + Ok(SlashCommand::Marathon) + ); + assert!( + built_in_slash_commands() + .iter() + .any(|(name, command)| *name == "marathon" && *command == SlashCommand::Marathon) + ); + } + #[test] fn clean_alias_parses_to_stop_command() { assert_eq!(SlashCommand::from_str("clean"), Ok(SlashCommand::Stop)); @@ -318,6 +337,9 @@ mod tests { assert!(SlashCommand::Raw.available_in_side_conversation()); assert!(SlashCommand::Raw.supports_inline_args()); assert!(SlashCommand::App.available_during_task()); + assert!(SlashCommand::Marathon.supports_inline_args()); + assert!(SlashCommand::Marathon.available_in_side_conversation()); + assert!(SlashCommand::Marathon.available_during_task()); } #[test] diff --git a/runtime/codex-rs/tui/src/status/account.rs b/runtime/codex-rs/tui/src/status/account.rs index 7d814a27..d36f9287 100644 --- a/runtime/codex-rs/tui/src/status/account.rs +++ b/runtime/codex-rs/tui/src/status/account.rs @@ -1,3 +1,5 @@ +use codex_protocol::account::PlanType; + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum StatusAccountDisplay { ChatGpt { @@ -6,3 +8,22 @@ pub(crate) enum StatusAccountDisplay { }, ApiKey, } + +/// Formats the account identity shown in the TUI status surfaces. +pub(crate) fn format_account_label( + account: Option<&StatusAccountDisplay>, + plan_type: Option, +) -> Option { + match account { + Some(StatusAccountDisplay::ChatGpt { email, plan }) => match (email, plan) { + (Some(email), Some(plan)) => Some(format!("{email}({plan})")), + (Some(email), None) => Some(format!( + "{email}({})", + plan_type.map(super::plan_type_display_name)? + )), + _ => None, + }, + Some(StatusAccountDisplay::ApiKey) => Some("API key".to_string()), + None => None, + } +} diff --git a/runtime/codex-rs/tui/src/status/mod.rs b/runtime/codex-rs/tui/src/status/mod.rs index 43959526..6c413f1c 100644 --- a/runtime/codex-rs/tui/src/status/mod.rs +++ b/runtime/codex-rs/tui/src/status/mod.rs @@ -14,6 +14,7 @@ mod rate_limits; pub(crate) mod remote_connection; mod thread_usage; +pub(crate) use account::format_account_label; pub(crate) use account::StatusAccountDisplay; pub(crate) use card::StatusHistoryHandle; #[cfg(test)]