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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions docs/CODEX_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,10 +282,31 @@ only after it actually returns usage, and is dropped again as soon as it stops
working or the token changes - so a temporary failure can never pin polling to a
dead endpoint.

### Usage percentages and plan labels

onWatch scopes usage requests to the selected profile with Codex's
`ChatGPT-Account-Id` header. It does not send alternative account headers.

A running default profile stays tied to the account it started with. Switching
the active Codex login does not make that profile adopt the other account's
tokens or overwrite its credentials. Save the new account as a named profile,
or restart onWatch to use the new default login.

Codex's `pro`, `prolite`, and `promax` plan values display as **Pro (More)**,
**Pro**, and **Pro (Max)**. The server supplies the same label to every
dashboard account view.

For Pro (More), the usage API and CLI `/status` can report different percentages
for the same reset cycle. onWatch preserves the usage API percentage and shows
a source note on the dashboard. There is no verified conversion rule for this
difference; matching reset times alone does not establish the allowance used
by each source.

### Token security

- Keep `.env` out of version control
- onWatch only sends the token to Codex usage endpoints
- onWatch sends the token to Codex usage endpoints and, when enabled, the
quota-starter endpoint
- Usage history stays local in SQLite

---
Expand Down Expand Up @@ -357,9 +378,9 @@ Environment overrides (the dashboard toggles take precedence at runtime):

- `CODEX_AUTO_START_5H=true` / `CODEX_AUTO_START_7D=true` - default-on without the UI.
- `CODEX_STARTER_MODEL` - override the model used for the starter request (default
`gpt-5.5`). ChatGPT-account Codex access supports only a small set of models
(currently `gpt-5.5`, `gpt-5.4`, `gpt-5.4-mini`); set this if the default is
rejected.
`gpt-6-luna`). Model availability depends on your ChatGPT plan and workspace
settings; set this to a model available to your account if the default is
rejected. See [Codex models](https://learn.chatgpt.com/docs/models).

> Beta: the Codex Responses request shape can change upstream. If starter pings
> fail, check the logs and try a different `CODEX_STARTER_MODEL`.
Expand Down
3 changes: 2 additions & 1 deletion internal/agent/codex_agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,10 @@ func isUnstartedCodexWindow(quotaName string, resetsAt *time.Time, now time.Time
}

// SetCodexAccountID sets the Codex account_id used for the ChatGPT-Account-ID
// header on auto quota-starter pings.
// header on usage requests and auto quota-starter pings.
func (a *CodexAgent) SetCodexAccountID(id string) {
a.codexAccountID = id
a.client.SetAccountID(id)
}

// SetAutoStartCheck wires a callback that reports, fresh per poll, whether the
Expand Down
28 changes: 21 additions & 7 deletions internal/agent/codex_agent_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -474,13 +474,26 @@ func (m *CodexAgentManager) startAgentForProfile(profile CodexProfile) error {
// auth contamination between profiles (see issue #55).
profilePath := filepath.Join(m.profilesDir, profile.Name+".json")
isDefaultProfile := profile.Name == "default"
defaultCredentials := func() *api.CodexCredentials {
current := api.DetectCodexCredentials(m.logger)
if current == nil {
return nil
}
// Defaults without identity metadata can still rotate tokens, but
// newly present, missing, or different IDs require a new agent.
if strings.TrimSpace(current.AccountID) != strings.TrimSpace(profile.AccountID) ||
strings.TrimSpace(current.UserID) != strings.TrimSpace(profile.UserID) {
return nil
}
return current
}

agent.SetTokenRefresh(func() string {
if isDefaultProfile {
if systemCreds := api.DetectCodexCredentials(m.logger); systemCreds != nil {
if systemCreds := defaultCredentials(); systemCreds != nil {
return systemCreds.AccessToken
}
return profile.Tokens.AccessToken
return ""
}

// Named profiles: prefer profile file, fall back to global auth.json
Expand Down Expand Up @@ -515,7 +528,7 @@ func (m *CodexAgentManager) startAgentForProfile(profile CodexProfile) error {

agent.SetCredentialsRefresh(func() *api.CodexCredentials {
if isDefaultProfile {
return api.DetectCodexCredentials(m.logger)
return defaultCredentials()
}

// Named profiles: prefer profile file, fall back to global auth.json
Expand Down Expand Up @@ -546,11 +559,12 @@ func (m *CodexAgentManager) startAgentForProfile(profile CodexProfile) error {
// Write back to whichever file the credentials came from, in its
// native format. OpenCode-sourced tokens must stay in OpenCode
// format (one-time-use refresh tokens must not be lost).
source := api.CredentialSourceCodex
if cur := api.DetectCodexCredentials(m.logger); cur != nil {
source = cur.Source
// A login switch must not replace another identity's credentials.
cur := defaultCredentials()
if cur == nil {
return fmt.Errorf("active Codex credentials do not match tracked default profile")
}
return api.WriteCredentialsBySource(source, accessToken, refreshToken, idToken, expiresIn)
return api.WriteCredentialsBySource(cur.Source, accessToken, refreshToken, idToken, expiresIn)
}

// Named profiles: save refreshed tokens to the profile file only
Expand Down
183 changes: 183 additions & 0 deletions internal/agent/codex_agent_manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@ import (
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"

"github.com/onllm-dev/onwatch/v2/internal/api"
"github.com/onllm-dev/onwatch/v2/internal/notify"
"github.com/onllm-dev/onwatch/v2/internal/store"
"github.com/onllm-dev/onwatch/v2/internal/testutil/testhome"
Expand Down Expand Up @@ -176,6 +179,63 @@ func TestCodexAgentManager_LoadAndStartProfiles(t *testing.T) {
}
}

func TestCodexAgentManager_ProfileUsageRequests_KeepAccountIDsIsolated(t *testing.T) {
fx := newCodexManagerFixture(t)
type usageRequest struct {
authorization string
accountID string
}
requests := make(chan usageRequest, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests <- usageRequest{
authorization: r.Header.Get("Authorization"),
accountID: r.Header.Get("ChatGPT-Account-Id"),
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"plan_type":"pro","rate_limit":{"primary_window":{"used_percent":25,"reset_at":1766000000,"limit_window_seconds":18000}}}`)
}))
t.Cleanup(server.Close)

profiles := []CodexProfile{
{Name: "work", AccountID: "acct-work", SavedAt: time.Now().UTC()},
{Name: "personal", AccountID: "acct-personal", SavedAt: time.Now().UTC()},
}
for i := range profiles {
profiles[i].Tokens.AccessToken = profiles[i].Name + "-token"
fx.writeProfile(t, profiles[i])
}
if err := fx.manager.loadAndStartProfiles(); err != nil {
t.Fatalf("loadAndStartProfiles: %v", err)
}
for _, profile := range profiles {
instance := fx.instance(profile.Name)
if instance == nil {
t.Fatalf("missing agent for profile %q", profile.Name)
}
// Background polling is disabled by the fixture. Redirect each real
// manager-created client before exercising its usage request.
api.WithCodexBaseURL(server.URL)(instance.Agent.client)
}

// Return to the first profile after the second to detect shared account
// metadata as well as missing account headers.
for _, profile := range []CodexProfile{profiles[0], profiles[1], profiles[0]} {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
_, err := fx.instance(profile.Name).Agent.client.FetchUsage(ctx)
cancel()
if err != nil {
t.Fatalf("FetchUsage(%s): %v", profile.Name, err)
}
request := <-requests
if request.accountID != profile.AccountID {
t.Errorf("profile %s account header = %q, want %q", profile.Name, request.accountID, profile.AccountID)
}
if request.authorization != "Bearer "+profile.Tokens.AccessToken {
t.Errorf("profile %s authorization did not use its own token", profile.Name)
}
}
}

func TestCodexAgentManager_LoadAndStartProfile_DerivesNameAndSkipsDuplicate(t *testing.T) {
fx := newCodexManagerFixture(t)

Expand Down Expand Up @@ -293,6 +353,129 @@ func TestCodexAgentManager_StartDefaultAgent(t *testing.T) {
}
}

func TestCodexAgentManager_DefaultProfileRejectsChangedIdentity(t *testing.T) {
for _, test := range []struct {
name string
trackedAccountID string
trackedUserID string
accountID string
userID string
}{
{name: "different account", trackedAccountID: "acct-default", trackedUserID: "user-default", accountID: "acct-other", userID: "user-other"},
{name: "different workspace user", trackedAccountID: "acct-default", trackedUserID: "user-default", accountID: "acct-default", userID: "user-other"},
{name: "metadata-free default with known foreign account", accountID: "acct-other", userID: "user-other"},
{name: "missing account with different user", trackedUserID: "user-default", userID: "user-other"},
} {
t.Run(test.name, func(t *testing.T) {
fx := newCodexManagerFixture(t)
authDir := filepath.Join(testHomeDir(t), ".codex")
if err := os.MkdirAll(authDir, 0o700); err != nil {
t.Fatalf("mkdir .codex: %v", err)
}
authPath := filepath.Join(authDir, "auth.json")
originalIDToken := makeCodexIDToken(t, time.Now().Add(24*time.Hour), test.trackedAccountID, test.trackedUserID)
originalAuth := `{"tokens":{"access_token":"original-token","refresh_token":"original-refresh","id_token":"` + originalIDToken + `","account_id":"` + test.trackedAccountID + `"}}`
if err := os.WriteFile(authPath, []byte(originalAuth), 0o600); err != nil {
t.Fatalf("write original auth.json: %v", err)
}
if err := fx.manager.startDefaultAgent(); err != nil {
t.Fatalf("startDefaultAgent: %v", err)
}
instance := fx.instance("default")
if instance == nil {
t.Fatal("missing default agent")
}

rotatedAuth := strings.ReplaceAll(originalAuth, "original-token", "rotated-token")
if err := os.WriteFile(authPath, []byte(rotatedAuth), 0o600); err != nil {
t.Fatalf("write rotated auth.json: %v", err)
}
if token := instance.Agent.tokenRefresh(); token != "rotated-token" {
t.Error("default agent rejected its own rotated access token")
} else {
instance.Agent.client.SetToken(token)
}

foreignIDToken := makeCodexIDToken(t, time.Now().Add(48*time.Hour), test.accountID, test.userID)
foreignAuth := `{"tokens":{"access_token":"foreign-token","refresh_token":"foreign-refresh","id_token":"` + foreignIDToken + `","account_id":"` + test.accountID + `"}}`
if err := os.WriteFile(authPath, []byte(foreignAuth), 0o600); err != nil {
t.Fatalf("write switched auth.json: %v", err)
}
if instance.Agent.tokenRefresh() != "" {
t.Error("foreign credentials should leave the current access token unchanged")
}
if instance.Agent.credsRefresh() != nil {
t.Error("default agent accepted another identity's refresh credentials")
}
if err := instance.Agent.tokenSave("refreshed-token", "refreshed-refresh", originalIDToken, 604800); err == nil {
t.Error("default agent saved refreshed credentials over another identity")
}
data, err := os.ReadFile(authPath)
if err != nil {
t.Fatalf("read auth.json: %v", err)
}
if string(data) != foreignAuth {
t.Error("another identity's auth.json was modified")
}
})
}
}

func TestCodexAgentManager_DefaultProfileAcceptsOwnTokenRotation(t *testing.T) {
for _, test := range []struct {
name string
accountID string
userID string
}{
{name: "known identity", accountID: "acct-default", userID: "user-default"},
{name: "no identity metadata"},
{name: "missing account with known user", userID: "user-default"},
} {
t.Run(test.name, func(t *testing.T) {
fx := newCodexManagerFixture(t)
authDir := filepath.Join(testHomeDir(t), ".codex")
if err := os.MkdirAll(authDir, 0o700); err != nil {
t.Fatalf("mkdir .codex: %v", err)
}
authPath := filepath.Join(authDir, "auth.json")
idToken := makeCodexIDToken(t, time.Now().Add(24*time.Hour), test.accountID, test.userID)
originalAuth := `{"tokens":{"access_token":"original-token","refresh_token":"original-refresh","id_token":"` + idToken + `","account_id":"` + test.accountID + `"}}`
if err := os.WriteFile(authPath, []byte(originalAuth), 0o600); err != nil {
t.Fatalf("write original auth.json: %v", err)
}
if err := fx.manager.startDefaultAgent(); err != nil {
t.Fatalf("startDefaultAgent: %v", err)
}
instance := fx.instance("default")
if instance == nil {
t.Fatal("missing default agent")
}

rotatedAuth := `{"tokens":{"access_token":"rotated-token","refresh_token":"rotated-refresh","id_token":"` + idToken + `","account_id":"` + test.accountID + `"}}`
if err := os.WriteFile(authPath, []byte(rotatedAuth), 0o600); err != nil {
t.Fatalf("write rotated auth.json: %v", err)
}
if instance.Agent.tokenRefresh() != "rotated-token" {
t.Error("default agent rejected its own rotated access token")
}
creds := instance.Agent.credsRefresh()
if creds == nil || creds.AccessToken != "rotated-token" || creds.RefreshToken != "rotated-refresh" {
t.Error("default agent rejected its own rotated refresh credentials")
}
if err := instance.Agent.tokenSave("refreshed-token", "refreshed-refresh", idToken, 604800); err != nil {
t.Fatalf("tokenSave: %v", err)
}
updated := api.DetectCodexCredentials(fx.logger)
if updated == nil || updated.AccessToken != "refreshed-token" || updated.RefreshToken != "refreshed-refresh" {
t.Error("default agent did not persist its own refreshed credentials")
}
if updated == nil || updated.AccountID != test.accountID || updated.UserID != test.userID {
t.Error("saving refreshed credentials changed the default profile identity")
}
})
}
}

func TestCodexAgentManager_ErrorAndFallbackPaths(t *testing.T) {
fx := newCodexManagerFixture(t)

Expand Down
4 changes: 2 additions & 2 deletions internal/api/codex_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,8 +214,8 @@ func (c *CodexClient) doUsageRequest(ctx context.Context, usageURL string) (*htt
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "onwatch/1.0")
if accountID := c.getAccountID(); accountID != "" {
req.Header.Set("X-Account-Id", accountID)
req.Header.Set("ChatClaude-Account-Id", accountID)
// Codex CLI's usage reader scopes account rate limits with this header.
req.Header.Set("ChatGPT-Account-Id", accountID)
}

resp, err := c.httpClient.Do(req)
Expand Down
Loading
Loading