From 40c92d8796e838be16e8fe1538112db9d971f3dc Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 21 Sep 2026 15:11:41 -0400 Subject: [PATCH 01/18] codexauth: sign in to a ChatGPT plan the way the Codex CLI does, and carry its turns Codex credentials had no profile store, no browser exchange and no road onto the wire. internal/codexauth now holds the Codex CLI-compatible S256 sign-in on the fixed loopback callback (1455, then 1457), the owner-only token file with every token registered for redaction, the account's own model listing with a vendored fallback, single-flight refresh that re-reads the file first, and a translating RoundTripper that turns the provider client's chat-completions request into a Responses-API turn on chatgpt.com and its stream back into the chunks the client already decodes, encrypted reasoning riding reasoning_details. A spent plan window comes back as a 402 with one plain sentence, which is the status every service's payment refusal already carries. provider.Config.HTTPClient is no longer a test-only seam, and the funnel law names the adapter as the provider funnel's own wire, not a second call path. Co-Authored-By: Claude Fable 5.1 --- internal/codexauth/doc.go | 4 + internal/codexauth/flow.go | 290 ++++++++++ internal/codexauth/flow_test.go | 198 +++++++ internal/codexauth/models.go | 151 ++++++ internal/codexauth/quota.go | 5 + internal/codexauth/tokens.go | 164 ++++++ internal/codexauth/transport.go | 644 +++++++++++++++++++++++ internal/codexauth/transport_test.go | 251 +++++++++ internal/provider/client.go | 3 +- internal/provider/codex_identity_test.go | 19 + internal/provider/funnel_law_test.go | 8 +- internal/provider/reasoning_test.go | 4 +- 12 files changed, 1736 insertions(+), 5 deletions(-) create mode 100644 internal/codexauth/doc.go create mode 100644 internal/codexauth/flow.go create mode 100644 internal/codexauth/flow_test.go create mode 100644 internal/codexauth/models.go create mode 100644 internal/codexauth/quota.go create mode 100644 internal/codexauth/tokens.go create mode 100644 internal/codexauth/transport.go create mode 100644 internal/codexauth/transport_test.go create mode 100644 internal/provider/codex_identity_test.go diff --git a/internal/codexauth/doc.go b/internal/codexauth/doc.go new file mode 100644 index 000000000..35e125fbe --- /dev/null +++ b/internal/codexauth/doc.go @@ -0,0 +1,4 @@ +// Package codexauth signs a codeaf profile in to a ChatGPT plan in the same +// way the Codex CLI does, and adapts that plan's Responses API for codeaf's +// provider client. OpenAI's terms for a ChatGPT plan apply to work run on it. +package codexauth diff --git a/internal/codexauth/flow.go b/internal/codexauth/flow.go new file mode 100644 index 000000000..b2bac9c55 --- /dev/null +++ b/internal/codexauth/flow.go @@ -0,0 +1,290 @@ +package codexauth + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/guard" +) + +const ( + authorizePath = "/oauth/authorize" + tokenPath = "/oauth/token" + callbackPath = "/auth/callback" + callbackScope = "openid profile email offline_access" + maxExchangeBody = 1 << 20 +) + +// Options holds every replaceable edge of a Codex browser sign-in and its +// later backend calls. The zero value is the shipped service. +type Options struct { + Issuer string + Backend string + HTTPClient *http.Client + Random io.Reader + Listen func(network, address string) (net.Listener, error) + Now func() time.Time + SessionID string +} + +func (o Options) issuer() string { + if value := strings.TrimRight(strings.TrimSpace(o.Issuer), "/"); value != "" { + return value + } + return Issuer() +} + +func (o Options) backend() string { + if value := strings.TrimRight(strings.TrimSpace(o.Backend), "/"); value != "" { + return value + } + return Backend() +} + +func (o Options) client() *http.Client { + if o.HTTPClient != nil { + return o.HTTPClient + } + return &http.Client{Timeout: 30 * time.Second} +} + +func (o Options) now() time.Time { + if o.Now != nil { + return o.Now() + } + return time.Now() +} + +// Flow is one browser connection whose fixed loopback listener is already +// standing when Begin returns. +type Flow struct { + url string + server *http.Server + listener net.Listener + client *http.Client + tokenURL string + redirectURI string + verifier string + state string + now func() time.Time + + ctx context.Context + cancel context.CancelFunc + done chan struct{} + once sync.Once + claim sync.Once + mu sync.Mutex + tokens Tokens + err error +} + +// Begin starts the exact S256 loopback flow registered for the Codex CLI, +// trying its primary port and then its one fallback. +func Begin(ctx context.Context, options Options) (*Flow, error) { + issuer := options.issuer() + parsedIssuer, err := url.ParseRequestURI(issuer) + if err != nil || parsedIssuer.Scheme == "" || parsedIssuer.Host == "" { + return nil, errors.New("connect Codex: the sign-in address is invalid") + } + random := options.Random + if random == nil { + random = rand.Reader + } + proof := make([]byte, 32) + stateBytes := make([]byte, 32) + if _, err := io.ReadFull(random, proof); err != nil { + return nil, fmt.Errorf("connect Codex: make proof key: %w", err) + } + if _, err := io.ReadFull(random, stateBytes); err != nil { + return nil, fmt.Errorf("connect Codex: make state: %w", err) + } + listen := options.Listen + if listen == nil { + listen = net.Listen + } + var listener net.Listener + port := 0 + for _, candidate := range []int{1455, 1457} { + listener, err = listen("tcp", fmt.Sprintf("127.0.0.1:%d", candidate)) + if err == nil { + port = candidate + break + } + } + if listener == nil { + return nil, errors.New("connect Codex: both browser return ports are busy · finish or cancel the other sign-in and try again") + } + redirectURI := fmt.Sprintf("http://localhost:%d%s", port, callbackPath) + verifier := base64.RawURLEncoding.EncodeToString(proof) + challenge := sha256.Sum256([]byte(verifier)) + state := base64.RawURLEncoding.EncodeToString(stateBytes) + authorize, _ := url.Parse(issuer + authorizePath) + query := url.Values{} + query.Set("response_type", "code") + query.Set("client_id", ClientID) + query.Set("redirect_uri", redirectURI) + query.Set("scope", callbackScope) + query.Set("code_challenge", base64.RawURLEncoding.EncodeToString(challenge[:])) + query.Set("code_challenge_method", "S256") + query.Set("state", state) + query.Set("id_token_add_organizations", "true") + query.Set("codex_cli_simplified_flow", "true") + query.Set("originator", Originator) + authorize.RawQuery = query.Encode() + runContext, cancel := context.WithCancel(context.WithoutCancel(ctx)) + flow := &Flow{ + url: authorize.String(), listener: listener, client: options.client(), + tokenURL: issuer + tokenPath, redirectURI: redirectURI, verifier: verifier, + state: state, now: options.now, ctx: runContext, cancel: cancel, + done: make(chan struct{}), + } + mux := http.NewServeMux() + mux.HandleFunc(callbackPath, flow.callback) + flow.server = &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 5 * time.Second} + guard.Go("codexauth/callback", func() { + serveErr := flow.server.Serve(listener) + if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) && !errors.Is(serveErr, net.ErrClosed) { + flow.finish(Tokens{}, fmt.Errorf("connect Codex: browser return: %w", serveErr)) + } + }) + return flow, nil +} + +// URL is the one sign-in address the person opens. +func (f *Flow) URL() string { return f.url } + +// Wait returns the token set after the browser has returned once. +func (f *Flow) Wait(ctx context.Context) (Tokens, error) { + select { + case <-f.done: + case <-ctx.Done(): + f.finish(Tokens{}, fmt.Errorf("connect Codex: %w", ctx.Err())) + <-f.done + } + f.mu.Lock() + defer f.mu.Unlock() + return f.tokens, f.err +} + +// Cancel releases the fixed loopback listener. It is safe after Wait. +func (f *Flow) Cancel() { + f.finish(Tokens{}, errors.New("connect Codex: cancelled")) + f.cancel() + _ = f.listener.Close() +} + +func (f *Flow) callback(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "text/html; charset=utf-8") + writer.Header().Set("Cache-Control", "no-store") + claimed := false + f.claim.Do(func() { claimed = true }) + if !claimed { + writer.WriteHeader(http.StatusConflict) + _, _ = io.WriteString(writer, failurePage) + return + } + if request.URL.Query().Get("state") != f.state { + writer.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(writer, failurePage) + f.finish(Tokens{}, errors.New("connect Codex: the browser returned for a different sign-in")) + return + } + if strings.TrimSpace(request.URL.Query().Get("error")) != "" { + writer.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(writer, failurePage) + f.finish(Tokens{}, errors.New("connect Codex: sign-in was not completed")) + return + } + code := strings.TrimSpace(request.URL.Query().Get("code")) + if code == "" { + writer.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(writer, failurePage) + f.finish(Tokens{}, errors.New("connect Codex: the browser returned without a code")) + return + } + tokens, err := f.exchangeCode(code) + if err != nil { + writer.WriteHeader(http.StatusBadGateway) + _, _ = io.WriteString(writer, failurePage) + f.finish(Tokens{}, err) + return + } + _, _ = io.WriteString(writer, successPage) + f.finish(tokens, nil) +} + +func (f *Flow) exchangeCode(code string) (Tokens, error) { + form := url.Values{ + "grant_type": {"authorization_code"}, + "client_id": {ClientID}, + "code": {code}, + "redirect_uri": {f.redirectURI}, + "code_verifier": {f.verifier}, + } + request, err := http.NewRequestWithContext(f.ctx, http.MethodPost, f.tokenURL, strings.NewReader(form.Encode())) + if err != nil { + return Tokens{}, fmt.Errorf("connect Codex: prepare exchange: %w", err) + } + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.Header.Set("Accept", "application/json") + response, err := f.client.Do(request) + if err != nil { + return Tokens{}, fmt.Errorf("connect Codex: exchange the browser code: %w", err) + } + defer response.Body.Close() + raw, err := io.ReadAll(io.LimitReader(response.Body, maxExchangeBody)) + if err != nil { + return Tokens{}, fmt.Errorf("connect Codex: read the exchange: %w", err) + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return Tokens{}, errors.New("connect Codex: OpenAI refused the exchange") + } + var answer struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + IDToken string `json:"id_token"` + } + if json.Unmarshal(raw, &answer) != nil || strings.TrimSpace(answer.AccessToken) == "" || strings.TrimSpace(answer.RefreshToken) == "" || strings.TrimSpace(answer.IDToken) == "" { + return Tokens{}, errors.New("connect Codex: OpenAI returned no usable sign-in") + } + idClaims, err := claimsFrom(answer.IDToken) + if err != nil { + return Tokens{}, errors.New("connect Codex: OpenAI returned unreadable account details") + } + accessClaims, _ := claimsFrom(answer.AccessToken) + expiresAt := time.Unix(accessClaims.Exp, 0) + if accessClaims.Exp == 0 { + expiresAt = time.Time{} + } + tokens := Tokens{ + AccessToken: answer.AccessToken, RefreshToken: answer.RefreshToken, IDToken: answer.IDToken, + AccountID: idClaims.Auth.AccountID, Email: idClaims.Email, Plan: idClaims.Auth.Plan, + ExpiresAt: expiresAt, LastRefresh: f.now().UTC(), + } + register(tokens) + return tokens, nil +} + +func (f *Flow) finish(tokens Tokens, err error) { + f.once.Do(func() { + f.mu.Lock() + f.tokens, f.err = tokens, err + f.mu.Unlock() + close(f.done) + }) +} + +const successPage = `Codex connected

Codex connected.

You can close this tab and return to codeaf.

` +const failurePage = `Codex did not connect

Codex did not connect.

Return to codeaf and try again.

` diff --git a/internal/codexauth/flow_test.go b/internal/codexauth/flow_test.go new file mode 100644 index 000000000..e07545d55 --- /dev/null +++ b/internal/codexauth/flow_test.go @@ -0,0 +1,198 @@ +package codexauth + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/trace" +) + +func jwt(t *testing.T, claims any) string { + t.Helper() + raw, err := json.Marshal(claims) + if err != nil { + t.Fatal(err) + } + return "header." + base64.RawURLEncoding.EncodeToString(raw) + ".signature" +} + +func testListener(_ string, _ string) (net.Listener, error) { + return net.Listen("tcp", "127.0.0.1:0") +} + +func callbackAddress(flow *Flow, query url.Values) string { + return "http://" + flow.listener.Addr().String() + callbackPath + "?" + query.Encode() +} + +func TestC2AuthorizeAddressCarriesOnlyTheCodexCLIContract(t *testing.T) { + // C2: the authorization address has the exact fixed fields, fresh S256 proof and state. + flow, err := Begin(context.Background(), Options{Issuer: "https://issuer.example", Random: strings.NewReader(strings.Repeat("a", 32) + strings.Repeat("b", 32)), Listen: testListener}) + if err != nil { + t.Fatal(err) + } + defer flow.Cancel() + parsed, _ := url.Parse(flow.URL()) + if parsed.Scheme+"://"+parsed.Host+parsed.Path != "https://issuer.example/oauth/authorize" { + t.Fatalf("authorize address = %q", flow.URL()) + } + want := map[string]string{ + "response_type": "code", "client_id": ClientID, + "redirect_uri": "http://localhost:1455/auth/callback", "scope": callbackScope, + "code_challenge_method": "S256", "state": base64.RawURLEncoding.EncodeToString([]byte(strings.Repeat("b", 32))), + "id_token_add_organizations": "true", "codex_cli_simplified_flow": "true", "originator": Originator, + } + challenge := sha256Text(base64.RawURLEncoding.EncodeToString([]byte(strings.Repeat("a", 32)))) + want["code_challenge"] = challenge + if len(parsed.Query()) != len(want) { + t.Fatalf("query fields = %v", parsed.Query()) + } + for key, value := range want { + if got := parsed.Query().Get(key); got != value { + t.Errorf("%s = %q, want %q", key, got, value) + } + } +} + +func sha256Text(value string) string { + sum := sha256.Sum256([]byte(value)) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} + +func TestC3CallbackRejectsWrongStateAndASecondReturn(t *testing.T) { + // C3: only the first callback carrying this flow's state may spend the code. + flow, err := Begin(context.Background(), Options{Issuer: "https://issuer.example", Random: strings.NewReader(strings.Repeat("x", 64)), Listen: testListener}) + if err != nil { + t.Fatal(err) + } + defer flow.Cancel() + response, err := http.Get(callbackAddress(flow, url.Values{"state": {"wrong"}, "code": {"one"}})) + if err != nil { + t.Fatal(err) + } + _ = response.Body.Close() + if response.StatusCode != http.StatusBadRequest { + t.Fatalf("wrong state status = %d", response.StatusCode) + } + response, err = http.Get(callbackAddress(flow, url.Values{"state": {flow.state}, "code": {"two"}})) + if err != nil { + t.Fatal(err) + } + _ = response.Body.Close() + if response.StatusCode != http.StatusConflict { + t.Fatalf("second callback status = %d", response.StatusCode) + } + if _, err := flow.Wait(context.Background()); err == nil || !strings.Contains(err.Error(), "different sign-in") { + t.Fatalf("wait error = %v", err) + } +} + +func TestC3CallbackRefusalAndMissingCodeArePlainFailures(t *testing.T) { + // C3: an issuer error or a callback without a code connects nothing. + for _, query := range []url.Values{{"error": {"denied"}}, {}} { + flow, err := Begin(context.Background(), Options{Issuer: "https://issuer.example", Random: strings.NewReader(strings.Repeat("q", 64)), Listen: testListener}) + if err != nil { + t.Fatal(err) + } + query.Set("state", flow.state) + response, err := http.Get(callbackAddress(flow, query)) + if err != nil { + t.Fatal(err) + } + _ = response.Body.Close() + if _, err := flow.Wait(context.Background()); err == nil || !strings.HasPrefix(err.Error(), "connect Codex: ") { + t.Fatalf("plain callback error = %v", err) + } + flow.Cancel() + } +} + +func TestC4CodeExchangeKeepsTokensAndReadsAccountClaims(t *testing.T) { + // C4: the authorization code form is exact and no API-key exchange occurs. + now := time.Unix(1_800_000_000, 0) + access := jwt(t, map[string]any{"exp": now.Add(time.Hour).Unix()}) + idToken := jwt(t, map[string]any{"email": "person@example.com", "https://api.openai.com/auth": map[string]any{"chatgpt_account_id": "acct-1", "chatgpt_plan_type": "pro"}}) + var form url.Values + issuer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != tokenPath { + t.Fatalf("unexpected exchange path %q", request.URL.Path) + } + _ = request.ParseForm() + form = request.Form + _ = json.NewEncoder(writer).Encode(map[string]string{"access_token": access, "refresh_token": "refresh-secret", "id_token": idToken}) + })) + defer issuer.Close() + flow, err := Begin(context.Background(), Options{Issuer: issuer.URL, HTTPClient: issuer.Client(), Random: strings.NewReader(strings.Repeat("z", 64)), Listen: testListener, Now: func() time.Time { return now }}) + if err != nil { + t.Fatal(err) + } + defer flow.Cancel() + response, err := http.Get(callbackAddress(flow, url.Values{"state": {flow.state}, "code": {"the-code"}})) + if err != nil { + t.Fatal(err) + } + _, _ = io.Copy(io.Discard, response.Body) + _ = response.Body.Close() + tokens, err := flow.Wait(context.Background()) + if err != nil { + t.Fatal(err) + } + if tokens.AccessToken != access || tokens.RefreshToken != "refresh-secret" || tokens.IDToken != idToken || tokens.AccountID != "acct-1" || tokens.Email != "person@example.com" || tokens.Plan != "pro" { + t.Fatalf("tokens = %+v", tokens) + } + want := map[string]string{"grant_type": "authorization_code", "client_id": ClientID, "code": "the-code", "redirect_uri": "http://localhost:1455/auth/callback", "code_verifier": flow.verifier} + if len(form) != len(want) { + t.Fatalf("exchange fields = %v", form) + } + for key, value := range want { + if form.Get(key) != value { + t.Errorf("%s = %q, want %q", key, form.Get(key), value) + } + } + second, err := http.Get(callbackAddress(flow, url.Values{"state": {flow.state}, "code": {"again"}})) + if err != nil { + t.Fatal(err) + } + _ = second.Body.Close() + if second.StatusCode != http.StatusConflict { + t.Fatalf("second successful callback status = %d", second.StatusCode) + } +} + +func TestC5BothRegisteredPortsBusySaysHowToRecover(t *testing.T) { + // C5: two occupied callback ports produce the fact and the next action. + _, err := Begin(context.Background(), Options{Random: strings.NewReader(strings.Repeat("r", 64)), Listen: func(string, string) (net.Listener, error) { return nil, os.ErrExist }}) + if err == nil || !strings.Contains(err.Error(), "both browser return ports are busy") || !strings.Contains(err.Error(), "finish or cancel") { + t.Fatalf("busy-port error = %v", err) + } +} + +func TestC6TokenFileIsOwnerOnlyAndEveryTokenIsScrubbedOnLoad(t *testing.T) { + // C6: codex.json is 0600 and loading it registers all three credentials. + dir := t.TempDir() + tokens := Tokens{AccessToken: "access-secret-value", RefreshToken: "refresh-secret-value", IDToken: "identity-secret-value"} + if err := Save(dir, tokens); err != nil { + t.Fatal(err) + } + mode, _ := os.Stat(Path(dir)) + if mode.Mode().Perm() != 0o600 { + t.Fatalf("token mode = %o", mode.Mode().Perm()) + } + if _, err := Load(dir); err != nil { + t.Fatal(err) + } + clean := string(trace.Scrub([]byte(tokens.AccessToken + " " + tokens.RefreshToken + " " + tokens.IDToken))) + if strings.Contains(clean, "secret-value") { + t.Fatalf("tokens survived scrub: %q", clean) + } +} diff --git a/internal/codexauth/models.go b/internal/codexauth/models.go new file mode 100644 index 000000000..99d064d93 --- /dev/null +++ b/internal/codexauth/models.go @@ -0,0 +1,151 @@ +package codexauth + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +// Model is one model the signed-in account may choose, with the reasoning +// levels the request translator is allowed to send for it. +type Model struct { + ID string `json:"id"` + ReasoningLevels []string `json:"reasoning_levels,omitempty"` +} + +// FallbackModels is the last observed public list used when a fresh account +// listing cannot be reached during connection. +var FallbackModels = []Model{ + {ID: "gpt-5.5"}, + {ID: "gpt-5.6-sol"}, + {ID: "gpt-5.6-terra"}, + {ID: "gpt-5.6-luna"}, +} + +// List asks the account's own backend which models are visible and remembers +// the reasoning levels needed by later turns. +func List(ctx context.Context, profileDir string, options Options) ([]Model, error) { + if _, bounded := ctx.Deadline(); !bounded { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, 10*time.Second) + defer cancel() + } + endpoint := options.backend() + "/models?client_version=" + clientVersion + request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + response, err := ClientWithOptions(profileDir, options).Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4<<10)) + return nil, fmt.Errorf("codex model list answered %s", response.Status) + } + var answer struct { + Models []struct { + Slug string `json:"slug"` + Visibility string `json:"visibility"` + Levels []struct { + Effort string `json:"effort"` + } `json:"supported_reasoning_levels"` + } `json:"models"` + } + if err := json.NewDecoder(io.LimitReader(response.Body, 4<<20)).Decode(&answer); err != nil { + return nil, err + } + models := make([]Model, 0, len(answer.Models)) + for _, row := range answer.Models { + if row.Visibility != "list" || strings.TrimSpace(row.Slug) == "" { + continue + } + model := Model{ID: strings.TrimSpace(row.Slug)} + for _, level := range row.Levels { + if effort := strings.TrimSpace(level.Effort); effort != "" { + model.ReasoningLevels = append(model.ReasoningLevels, effort) + } + } + models = append(models, model) + } + if len(models) == 0 { + return nil, fmt.Errorf("codex model list carried no visible models") + } + if err := saveModels(profileDir, models); err != nil { + return nil, err + } + return models, nil +} + +func saveModels(profileDir string, models []Model) error { + raw, err := json.Marshal(models) + if err != nil { + return err + } + path := ModelsPath(profileDir) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + return os.WriteFile(path, raw, 0o600) +} + +func loadModels(profileDir string) []Model { + raw, err := os.ReadFile(ModelsPath(profileDir)) + if err != nil { + return nil + } + var models []Model + if json.Unmarshal(raw, &models) != nil { + return nil + } + return models +} + +func clampEffort(profileDir, model, effort string) string { + effort = strings.TrimSpace(effort) + if effort == "" { + return "" + } + var supported []string + for _, row := range loadModels(profileDir) { + if strings.EqualFold(strings.TrimSpace(row.ID), strings.TrimSpace(model)) { + supported = row.ReasoningLevels + break + } + } + if len(supported) == 0 { + return effort + } + for _, level := range supported { + if strings.EqualFold(level, effort) { + return level + } + } + order := map[string]int{"none": 0, "minimal": 1, "low": 2, "medium": 3, "high": 4, "xhigh": 5, "max": 6} + want, known := order[strings.ToLower(effort)] + if !known { + return supported[0] + } + best, distance := supported[0], 100 + for _, level := range supported { + position, ok := order[strings.ToLower(level)] + if !ok { + continue + } + delta := position - want + if delta < 0 { + delta = -delta + } + if delta < distance { + best, distance = level, delta + } + } + return best +} diff --git a/internal/codexauth/quota.go b/internal/codexauth/quota.go new file mode 100644 index 000000000..598f0f9fa --- /dev/null +++ b/internal/codexauth/quota.go @@ -0,0 +1,5 @@ +package codexauth + +// QuotaWords is the person-facing outcome for a ChatGPT plan window that has +// been spent. The plan owns its reset, so there is no billing action to offer. +const QuotaWords = "codex reached your chatgpt plan's usage limit · it resets on its own" diff --git a/internal/codexauth/tokens.go b/internal/codexauth/tokens.go new file mode 100644 index 000000000..b78589b82 --- /dev/null +++ b/internal/codexauth/tokens.go @@ -0,0 +1,164 @@ +package codexauth + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/env" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/trace" +) + +const ( + DefaultIssuer = "https://auth.openai.com" + DefaultBackend = "https://chatgpt.com/backend-api/codex" + ClientID = "app_EMoamEEZ73f0CkXaXp7hrann" + Sentinel = "chatgpt" + Originator = "codex_cli_rs" + clientVersion = "0.144.1" +) + +// ErrSignInExpired is the one actionable sentence returned when the issuer no +// longer accepts a profile's rotating refresh token. +var ErrSignInExpired = errors.New("codex sign-in has expired · /connect or codeaf connect codex signs in again") + +// Tokens is the complete durable answer from one browser sign-in. +type Tokens struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + IDToken string `json:"id_token"` + AccountID string `json:"account_id"` + Email string `json:"email"` + Plan string `json:"plan"` + ExpiresAt time.Time `json:"expires_at"` + LastRefresh time.Time `json:"last_refresh"` +} + +// Issuer returns the configured issuer without changing the fixed callback. +func Issuer() string { + if value := strings.TrimRight(strings.TrimSpace(env.Value("CODEAF_CODEX_ISSUER")), "/"); value != "" { + return value + } + return DefaultIssuer +} + +// Backend returns the configured ChatGPT backend used by both listing and turns. +func Backend() string { + if value := strings.TrimRight(strings.TrimSpace(env.Value("CODEAF_CODEX_BACKEND")), "/"); value != "" { + return value + } + return DefaultBackend +} + +// Path names the owner-readable token file in a profile. +func Path(profileDir string) string { return profilePath(profileDir, "codex.json") } + +// ModelsPath names the non-secret listing cached beside the tokens. +func ModelsPath(profileDir string) string { return profilePath(profileDir, "codex-models.json") } + +func profilePath(profileDir, name string) string { + if profileDir = strings.TrimSpace(profileDir); profileDir != "" { + return filepath.Join(profileDir, name) + } + return home.Join(name) +} + +// Load reads the current token set and immediately registers every credential +// with the record scrubber. +func Load(profileDir string) (Tokens, error) { + var tokens Tokens + raw, err := os.ReadFile(Path(profileDir)) + if err != nil { + return tokens, err + } + if err := json.Unmarshal(raw, &tokens); err != nil { + return Tokens{}, fmt.Errorf("read codex sign-in: %w", err) + } + register(tokens) + return tokens, nil +} + +// Save replaces the token file with an owner-only file. +func Save(profileDir string, tokens Tokens) error { + register(tokens) + raw, err := json.Marshal(tokens) + if err != nil { + return fmt.Errorf("write codex sign-in: %w", err) + } + path := Path(profileDir) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("write codex sign-in: %w", err) + } + temporary, err := os.CreateTemp(filepath.Dir(path), ".codex-*.json") + if err != nil { + return fmt.Errorf("write codex sign-in: %w", err) + } + temporaryPath := temporary.Name() + defer os.Remove(temporaryPath) + if err := temporary.Chmod(0o600); err == nil { + _, err = temporary.Write(raw) + } + if closeErr := temporary.Close(); err == nil { + err = closeErr + } + if err == nil { + err = os.Rename(temporaryPath, path) + } + if err != nil { + return fmt.Errorf("write codex sign-in: %w", err) + } + return os.Chmod(path, 0o600) +} + +// Remove forgets the browser sign-in and its model listing. +func Remove(profileDir string) error { + for _, path := range []string{Path(profileDir), ModelsPath(profileDir)} { + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + } + return nil +} + +// Connected reports whether a usable sign-in currently exists. +func Connected(profileDir string) bool { + tokens, err := Load(profileDir) + return err == nil && strings.TrimSpace(tokens.AccessToken) != "" && strings.TrimSpace(tokens.RefreshToken) != "" +} + +func register(tokens Tokens) { + trace.Secret(tokens.AccessToken) + trace.Secret(tokens.RefreshToken) + trace.Secret(tokens.IDToken) +} + +type tokenClaims struct { + Email string `json:"email"` + Exp int64 `json:"exp"` + Auth struct { + AccountID string `json:"chatgpt_account_id"` + Plan string `json:"chatgpt_plan_type"` + } `json:"https://api.openai.com/auth"` +} + +func claimsFrom(token string) (tokenClaims, error) { + var claims tokenClaims + parts := strings.Split(token, ".") + if len(parts) < 2 { + return claims, errors.New("token has no claims") + } + raw, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return claims, err + } + if err := json.Unmarshal(raw, &claims); err != nil { + return claims, err + } + return claims, nil +} diff --git a/internal/codexauth/transport.go b/internal/codexauth/transport.go new file mode 100644 index 000000000..aae2c0ec6 --- /dev/null +++ b/internal/codexauth/transport.go @@ -0,0 +1,644 @@ +package codexauth + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/google/uuid" + + "github.com/Agent-Field/codeaf/internal/guard" + "github.com/Agent-Field/codeaf/internal/provider" +) + +var refreshLocks struct { + sync.Mutex + byPath map[string]*sync.Mutex +} + +// Client returns the refreshing and translating client for one profile. +func Client(profileDir string) *http.Client { return ClientWithOptions(profileDir, Options{}) } + +// ClientWithOptions returns the same client with deterministic test edges. +func ClientWithOptions(profileDir string, options Options) *http.Client { + transport := Translate(profileDir, options) + timeout := time.Duration(0) + if options.HTTPClient != nil { + timeout = options.HTTPClient.Timeout + } + return &http.Client{Transport: transport, Timeout: timeout} +} + +// Translate authenticates every backend request and translates the provider +// client's chat-completions path into the ChatGPT Responses API. +func Translate(profileDir string, options Options) http.RoundTripper { + base := http.DefaultTransport + if options.HTTPClient != nil && options.HTTPClient.Transport != nil { + base = options.HTTPClient.Transport + } + session := strings.TrimSpace(options.SessionID) + if session == "" { + session = uuid.NewString() + } + return &transport{ + profileDir: profileDir, options: options, base: base, + sessionID: session, + } +} + +type transport struct { + profileDir string + options Options + base http.RoundTripper + sessionID string +} + +func (t *transport) RoundTrip(request *http.Request) (*http.Response, error) { + if request == nil { + return nil, errors.New("codex request is missing") + } + originalBody, err := readRequestBody(request) + if err != nil { + return nil, err + } + translatedBody := originalBody + wantsStream := true + isTurn := request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/chat/completions") + if isTurn { + translatedBody, wantsStream, err = translateRequest(t.profileDir, originalBody) + if err != nil { + return nil, err + } + } + tokens, err := t.fresh(request.Context(), false, "") + if err != nil { + return nil, err + } + send := func(tokens Tokens) (*http.Response, error) { + out := request.Clone(request.Context()) + out.Header = request.Header.Clone() + out.Header.Del("X-Title") + out.Header.Del("HTTP-Referer") + out.Header.Set("Authorization", "Bearer "+tokens.AccessToken) + out.Header.Set("chatgpt-account-id", tokens.AccountID) + out.Header.Set("originator", Originator) + out.Header.Set("OpenAI-Beta", "responses=experimental") + out.Header.Set("Accept", "text/event-stream") + out.Header.Set("User-Agent", provider.DirectUserAgent) + if isTurn { + out.URL = cloneURL(request.URL) + backend, parseErr := url.Parse(t.options.backend() + "/responses") + if parseErr != nil { + return nil, parseErr + } + out.URL = backend + out.Host = backend.Host + out.Header.Set("session_id", sessionIDFor(translatedBody, request.Header, t.sessionID)) + } + out.Body = io.NopCloser(bytes.NewReader(translatedBody)) + out.ContentLength = int64(len(translatedBody)) + return t.base.RoundTrip(out) + } + response, err := send(tokens) + if err != nil { + return nil, err + } + if response.StatusCode == http.StatusUnauthorized { + _ = response.Body.Close() + refreshed, refreshErr := t.fresh(request.Context(), true, tokens.AccessToken) + if refreshErr != nil { + return nil, refreshErr + } + response, err = send(refreshed) + if err != nil { + return nil, err + } + } + if isTurn && response.StatusCode >= 400 && codexQuotaStatus(response.StatusCode) { + response = quotaResponse(response) + } + if !isTurn || response.StatusCode < 200 || response.StatusCode >= 300 { + return response, nil + } + return translateResponse(response, wantsStream) +} + +func codexQuotaStatus(status int) bool { + return status == http.StatusBadRequest || status == http.StatusNotFound || status == http.StatusTooManyRequests +} + +func quotaResponse(response *http.Response) *http.Response { + raw, err := io.ReadAll(io.LimitReader(response.Body, 4<<20)) + _ = response.Body.Close() + if err != nil { + response.Body = io.NopCloser(bytes.NewReader(raw)) + return response + } + lower := strings.ToLower(string(raw)) + matched := false + for _, phrase := range []string{"usage_limit_reached", "usage_not_included", "rate_limit_exceeded", "usage limit"} { + matched = matched || strings.Contains(lower, phrase) + } + if !matched { + response.Body = io.NopCloser(bytes.NewReader(raw)) + response.ContentLength = int64(len(raw)) + return response + } + // THE REWRITE CHANGES THE STATUS AS WELL AS THE WORDS. A 402 is the one + // status internal/paymentrefusal already reads as "this account cannot + // pay" on every service, so the session ends the turn the way it ends a + // spent Z.ai window, and no vendor-wide word list has to learn the + // backend's spellings — a generic "rate_limit_exceeded" would otherwise + // turn every other vendor's passing rate limit into a terminal refusal. + body, _ := json.Marshal(map[string]any{"error": map[string]any{"message": QuotaWords, "code": response.StatusCode}}) + response.StatusCode = http.StatusPaymentRequired + response.Status = "402 Payment Required" + response.Body = io.NopCloser(bytes.NewReader(body)) + response.ContentLength = int64(len(body)) + response.Header.Set("Content-Type", "application/json") + return response +} + +func readRequestBody(request *http.Request) ([]byte, error) { + if request.Body == nil { + return nil, nil + } + raw, err := io.ReadAll(request.Body) + _ = request.Body.Close() + if err != nil { + return nil, err + } + request.Body = io.NopCloser(bytes.NewReader(raw)) + return raw, nil +} + +func cloneURL(value *url.URL) *url.URL { + copy := *value + return © +} + +func sessionIDFor(body []byte, headers http.Header, fallback string) string { + var request map[string]any + if json.Unmarshal(body, &request) == nil { + if key, _ := request["prompt_cache_key"].(string); strings.TrimSpace(key) != "" { + return key + } + } + if key := strings.TrimSpace(headers.Get("X-Session-Affinity")); key != "" { + return key + } + return fallback +} + +func lockFor(path string) *sync.Mutex { + refreshLocks.Lock() + defer refreshLocks.Unlock() + if refreshLocks.byPath == nil { + refreshLocks.byPath = make(map[string]*sync.Mutex) + } + if refreshLocks.byPath[path] == nil { + refreshLocks.byPath[path] = &sync.Mutex{} + } + return refreshLocks.byPath[path] +} + +func (t *transport) fresh(ctx context.Context, force bool, rejected string) (Tokens, error) { + mutex := lockFor(Path(t.profileDir)) + mutex.Lock() + defer mutex.Unlock() + tokens, err := Load(t.profileDir) + if err != nil { + return Tokens{}, err + } + if strings.TrimSpace(tokens.AccessToken) == "" { + return Tokens{}, ErrSignInExpired + } + if force && rejected != "" && tokens.AccessToken != rejected { + return tokens, nil + } + now := t.options.now() + if !force && (tokens.ExpiresAt.IsZero() || tokens.ExpiresAt.After(now.Add(5*time.Minute))) { + return tokens, nil + } + form := url.Values{ + "grant_type": {"refresh_token"}, + "refresh_token": {tokens.RefreshToken}, + "client_id": {ClientID}, + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, t.options.issuer()+tokenPath, strings.NewReader(form.Encode())) + if err != nil { + return Tokens{}, err + } + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.Header.Set("Accept", "application/json") + response, err := t.base.RoundTrip(request) + if err != nil { + return Tokens{}, err + } + defer response.Body.Close() + raw, readErr := io.ReadAll(io.LimitReader(response.Body, maxExchangeBody)) + if readErr != nil { + return Tokens{}, readErr + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + tokens.AccessToken = "" + tokens.ExpiresAt = time.Time{} + _ = Save(t.profileDir, tokens) + return Tokens{}, ErrSignInExpired + } + var answer struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + IDToken string `json:"id_token"` + } + if json.Unmarshal(raw, &answer) != nil || strings.TrimSpace(answer.AccessToken) == "" { + return Tokens{}, ErrSignInExpired + } + tokens.AccessToken = answer.AccessToken + if strings.TrimSpace(answer.RefreshToken) != "" { + tokens.RefreshToken = answer.RefreshToken + } + if strings.TrimSpace(answer.IDToken) != "" { + tokens.IDToken = answer.IDToken + if claims, claimErr := claimsFrom(answer.IDToken); claimErr == nil { + tokens.AccountID, tokens.Email, tokens.Plan = claims.Auth.AccountID, claims.Email, claims.Auth.Plan + } + } + if claims, claimErr := claimsFrom(answer.AccessToken); claimErr == nil && claims.Exp != 0 { + tokens.ExpiresAt = time.Unix(claims.Exp, 0) + } + tokens.LastRefresh = now.UTC() + if err := Save(t.profileDir, tokens); err != nil { + return Tokens{}, err + } + return Load(t.profileDir) +} + +func translateRequest(profileDir string, raw []byte) ([]byte, bool, error) { + var source map[string]any + if err := json.Unmarshal(raw, &source); err != nil { + return nil, false, fmt.Errorf("translate codex request: %w", err) + } + wantsStream, _ := source["stream"].(bool) + model, _ := source["model"].(string) + instructions := make([]string, 0, 1) + input := make([]any, 0) + messages, _ := source["messages"].([]any) + for _, rawMessage := range messages { + message, _ := rawMessage.(map[string]any) + role, _ := message["role"].(string) + if role == "system" { + if text := contentText(message["content"]); strings.TrimSpace(text) != "" { + instructions = append(instructions, text) + } + continue + } + if role == "assistant" { + for _, detail := range reasoningDetails(message["reasoning_details"]) { + input = append(input, detail) + } + if text := contentText(message["content"]); text != "" { + input = append(input, map[string]any{"type": "message", "role": "assistant", "content": []any{map[string]any{"type": "output_text", "text": text}}}) + } + if calls, ok := message["tool_calls"].([]any); ok { + for _, rawCall := range calls { + call, _ := rawCall.(map[string]any) + function, _ := call["function"].(map[string]any) + input = append(input, map[string]any{"type": "function_call", "call_id": call["id"], "name": function["name"], "arguments": function["arguments"]}) + } + } + continue + } + if role == "tool" { + input = append(input, map[string]any{"type": "function_call_output", "call_id": message["tool_call_id"], "output": contentText(message["content"])}) + continue + } + if role == "user" { + input = append(input, map[string]any{"type": "message", "role": "user", "content": inputContent(message["content"])}) + } + } + if len(instructions) == 0 { + instructions = append(instructions, "You are codeaf, a coding agent.") + } + target := map[string]any{ + "model": model, "instructions": strings.Join(instructions, "\n\n"), "input": input, + "store": false, "stream": true, "include": []string{"reasoning.encrypted_content"}, + "text": map[string]any{"verbosity": "medium"}, + } + if text, ok := source["text"].(map[string]any); ok { + target["text"] = text + } + if key, ok := source["prompt_cache_key"]; ok { + target["prompt_cache_key"] = key + } + if parallel, ok := source["parallel_tool_calls"]; ok { + target["parallel_tool_calls"] = parallel + } + if maximum, ok := source["max_completion_tokens"]; ok { + target["max_output_tokens"] = maximum + } else if maximum, ok := source["max_tokens"]; ok { + target["max_output_tokens"] = maximum + } + if tools, ok := source["tools"].([]any); ok { + flat := make([]any, 0, len(tools)) + for _, rawTool := range tools { + tool, _ := rawTool.(map[string]any) + function, _ := tool["function"].(map[string]any) + flat = append(flat, map[string]any{"type": "function", "name": function["name"], "description": function["description"], "parameters": function["parameters"], "strict": false}) + } + target["tools"] = flat + } + if choice, ok := source["tool_choice"]; ok { + if named, yes := choice.(map[string]any); yes { + function, _ := named["function"].(map[string]any) + target["tool_choice"] = map[string]any{"type": "function", "name": function["name"]} + } else { + target["tool_choice"] = choice + } + } + effort := "" + if reasoning, ok := source["reasoning"].(map[string]any); ok { + effort, _ = reasoning["effort"].(string) + } + if effort == "" { + effort, _ = source["reasoning_effort"].(string) + } + if effort = clampEffort(profileDir, model, effort); effort != "" { + target["reasoning"] = map[string]any{"effort": effort, "summary": "auto"} + } + encoded, err := json.Marshal(target) + return encoded, wantsStream, err +} + +func contentText(value any) string { + if text, ok := value.(string); ok { + return text + } + var words strings.Builder + if parts, ok := value.([]any); ok { + for _, rawPart := range parts { + part, _ := rawPart.(map[string]any) + kind, _ := part["type"].(string) + if kind == "text" || kind == "input_text" || kind == "output_text" { + text, _ := part["text"].(string) + words.WriteString(text) + } + } + } + return words.String() +} + +func inputContent(value any) []any { + if text, ok := value.(string); ok { + return []any{map[string]any{"type": "input_text", "text": text}} + } + out := make([]any, 0) + if parts, ok := value.([]any); ok { + for _, rawPart := range parts { + part, _ := rawPart.(map[string]any) + switch part["type"] { + case "text", "input_text": + out = append(out, map[string]any{"type": "input_text", "text": part["text"]}) + case "image_url", "input_image": + image := part["image_url"] + if object, ok := image.(map[string]any); ok { + image = object["url"] + } + out = append(out, map[string]any{"type": "input_image", "image_url": image}) + } + } + } + return out +} + +func reasoningDetails(value any) []any { + details, _ := value.([]any) + out := make([]any, 0, len(details)) + for _, rawDetail := range details { + detail, _ := rawDetail.(map[string]any) + if detail["format"] != "openai-responses-v1" || detail["type"] != "reasoning.encrypted" { + continue + } + out = append(out, map[string]any{"type": "reasoning", "id": detail["id"], "encrypted_content": detail["data"], "summary": []any{}}) + } + return out +} + +type mappedStream struct { + id, model string + created int64 + content strings.Builder + reasoning strings.Builder + tools []map[string]any + toolAt map[int]int + details []any + usage map[string]any + finish string + sawTool bool +} + +func translateResponse(response *http.Response, wantsStream bool) (*http.Response, error) { + if wantsStream { + upstream := response.Body + reader, writer := io.Pipe() + response.Body = reader + response.ContentLength = -1 + response.Header.Set("Content-Type", "text/event-stream") + guard.Go("codexauth/stream", func() { + defer upstream.Close() + state := &mappedStream{} + err := mapResponseEvents(upstream, state, func(chunk map[string]any) error { + encoded, _ := json.Marshal(chunk) + _, writeErr := fmt.Fprintf(writer, "data: %s\n\n", encoded) + return writeErr + }) + if err == nil { + _, err = io.WriteString(writer, "data: [DONE]\n\n") + } + _ = writer.CloseWithError(err) + }) + return response, nil + } + raw, err := io.ReadAll(response.Body) + _ = response.Body.Close() + if err != nil { + return nil, err + } + state := &mappedStream{} + if err := mapResponseEvents(bytes.NewReader(raw), state, nil); err != nil { + return nil, err + } + message := map[string]any{"role": "assistant", "content": state.content.String()} + if len(state.tools) > 0 { + message["tool_calls"] = state.tools + } + if state.reasoning.Len() > 0 { + message["reasoning"] = state.reasoning.String() + } + if len(state.details) > 0 { + message["reasoning_details"] = state.details + } + body, _ := json.Marshal(map[string]any{ + "id": state.id, "object": "chat.completion", "created": state.created, "model": state.model, + "choices": []any{map[string]any{"index": 0, "message": message, "finish_reason": state.finish}}, "usage": state.usage, + }) + response.Body = io.NopCloser(bytes.NewReader(body)) + response.ContentLength = int64(len(body)) + response.Header.Set("Content-Type", "application/json") + return response, nil +} + +func mapResponseEvents(reader io.Reader, state *mappedStream, emit func(map[string]any) error) error { + scanner := bufio.NewScanner(reader) + scanner.Buffer(make([]byte, 64<<10), 8<<20) + for scanner.Scan() { + line := scanner.Text() + if !strings.HasPrefix(line, "data:") { + continue + } + data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) + if data == "" || data == "[DONE]" { + continue + } + var event map[string]any + if json.Unmarshal([]byte(data), &event) != nil { + continue + } + for _, chunk := range mapEvent(event, state) { + if emit != nil { + if err := emit(chunk); err != nil { + return err + } + } + } + } + return scanner.Err() +} + +func mapEvent(event map[string]any, state *mappedStream) []map[string]any { + kind, _ := event["type"].(string) + chunk := func(delta map[string]any, finish any, usage map[string]any) map[string]any { + return map[string]any{ + "id": state.id, "object": "chat.completion.chunk", "created": state.created, "model": state.model, + "choices": []any{map[string]any{"index": 0, "delta": delta, "finish_reason": finish}}, "usage": usage, + } + } + switch kind { + case "response.created": + response, _ := event["response"].(map[string]any) + state.id, _ = response["id"].(string) + state.model, _ = response["model"].(string) + state.created = integer(response["created_at"]) + return []map[string]any{chunk(map[string]any{"role": "assistant"}, nil, nil)} + case "response.output_text.delta", "response.refusal.delta": + text, _ := event["delta"].(string) + state.content.WriteString(text) + return []map[string]any{chunk(map[string]any{"content": text}, nil, nil)} + case "response.reasoning_summary_text.delta", "response.reasoning_text.delta": + text, _ := event["delta"].(string) + state.reasoning.WriteString(text) + return []map[string]any{chunk(map[string]any{"reasoning": text}, nil, nil)} + case "response.output_item.added": + item, _ := event["item"].(map[string]any) + if item["type"] != "function_call" { + return nil + } + outputIndex := int(integer(event["output_index"])) + index := len(state.tools) + call := map[string]any{"id": item["call_id"], "type": "function", "function": map[string]any{"name": item["name"], "arguments": ""}} + state.sawTool = true + state.tools = append(state.tools, call) + if state.toolAt == nil { + state.toolAt = make(map[int]int) + } + state.toolAt[outputIndex] = index + return []map[string]any{chunk(map[string]any{"tool_calls": []any{map[string]any{"index": index, "id": item["call_id"], "type": "function", "function": map[string]any{"name": item["name"], "arguments": ""}}}}, nil, nil)} + case "response.function_call_arguments.delta": + outputIndex := int(integer(event["output_index"])) + index, found := state.toolAt[outputIndex] + if !found { + return nil + } + text, _ := event["delta"].(string) + if index >= 0 && index < len(state.tools) { + function, _ := state.tools[index]["function"].(map[string]any) + current, _ := function["arguments"].(string) + function["arguments"] = current + text + } + return []map[string]any{chunk(map[string]any{"tool_calls": []any{map[string]any{"index": index, "function": map[string]any{"arguments": text}}}}, nil, nil)} + case "response.output_item.done": + item, _ := event["item"].(map[string]any) + if item["type"] != "reasoning" { + return nil + } + detail := map[string]any{"type": "reasoning.encrypted", "format": "openai-responses-v1", "id": item["id"], "data": item["encrypted_content"]} + state.details = append(state.details, detail) + return []map[string]any{chunk(map[string]any{"reasoning_details": []any{detail}}, nil, nil)} + case "response.incomplete": + state.finish = "length" + return nil + case "response.completed": + response, _ := event["response"].(map[string]any) + if state.id == "" { + state.id, _ = response["id"].(string) + state.model, _ = response["model"].(string) + } + if state.finish == "" { + if state.sawTool { + state.finish = "tool_calls" + } else { + state.finish = "stop" + } + } + state.usage = mappedUsage(response["usage"]) + return []map[string]any{chunk(map[string]any{}, state.finish, state.usage)} + case "response.failed", "error": + errorValue, _ := event["error"].(map[string]any) + if errorValue == nil { + response, _ := event["response"].(map[string]any) + errorValue, _ = response["error"].(map[string]any) + } + if errorValue == nil { + errorValue = map[string]any{"message": "codex did not finish the response", "type": "upstream_error", "code": 502} + } + if _, ok := errorValue["code"].(float64); !ok { + errorValue["code"] = 502 + } + return []map[string]any{{"error": errorValue}} + } + return nil +} + +func integer(value any) int64 { + switch number := value.(type) { + case float64: + return int64(number) + case int64: + return number + case int: + return int64(number) + } + return 0 +} + +func mappedUsage(value any) map[string]any { + usage, _ := value.(map[string]any) + input := integer(usage["input_tokens"]) + output := integer(usage["output_tokens"]) + inputDetails, _ := usage["input_tokens_details"].(map[string]any) + outputDetails, _ := usage["output_tokens_details"].(map[string]any) + return map[string]any{ + "prompt_tokens": input, "completion_tokens": output, "total_tokens": input + output, + "prompt_tokens_details": map[string]any{"cached_tokens": integer(inputDetails["cached_tokens"])}, + "completion_tokens_details": map[string]any{"reasoning_tokens": integer(outputDetails["reasoning_tokens"])}, + } +} diff --git a/internal/codexauth/transport_test.go b/internal/codexauth/transport_test.go new file mode 100644 index 000000000..ae23fe12d --- /dev/null +++ b/internal/codexauth/transport_test.go @@ -0,0 +1,251 @@ +package codexauth + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "github.com/Agent-Field/codeaf/internal/paymentrefusal" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/provider" +) + +func validTokens(now time.Time) Tokens { + return Tokens{AccessToken: "access-token-secret", RefreshToken: "refresh-token-secret", IDToken: "identity-token-secret", AccountID: "account-one", ExpiresAt: now.Add(time.Hour)} +} + +func TestC12ListingUsesAccountHeadersFiltersVisibilityAndCachesLevels(t *testing.T) { + // C12: the account listing, not generic /models, supplies only visible Codex models. + now := time.Unix(1_800_000_000, 0) + dir := t.TempDir() + if err := Save(dir, validTokens(now)); err != nil { + t.Fatal(err) + } + backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/models" || request.URL.Query().Get("client_version") != clientVersion { + t.Fatalf("listing request = %s", request.URL.String()) + } + if request.Header.Get("Authorization") != "Bearer access-token-secret" || request.Header.Get("chatgpt-account-id") != "account-one" || request.Header.Get("originator") != Originator { + t.Fatalf("listing headers = %v", request.Header) + } + _ = json.NewEncoder(writer).Encode(map[string]any{"models": []any{ + map[string]any{"slug": "shown", "visibility": "list", "supported_reasoning_levels": []any{map[string]any{"effort": "low"}, map[string]any{"effort": "high"}}}, + map[string]any{"slug": "hidden", "visibility": "hide"}, + }}) + })) + defer backend.Close() + models, err := List(context.Background(), dir, Options{Backend: backend.URL, HTTPClient: backend.Client(), Now: func() time.Time { return now }}) + if err != nil { + t.Fatal(err) + } + if len(models) != 1 || models[0].ID != "shown" || strings.Join(models[0].ReasoningLevels, ",") != "low,high" { + t.Fatalf("models = %+v", models) + } + if got := clampEffort(dir, "shown", "medium"); got != "low" { + t.Fatalf("clamped effort = %q", got) + } +} + +func TestC13C14C15TransportMapsTurnHeadersStreamAndReasoningRoundTrip(t *testing.T) { + // C13: a Codex turn carries account headers, one session id and no router attribution. + // C14: text, reasoning, tool calls, finish reason and all token counts map to chat chunks. + // C15: encrypted reasoning and a tool result return as Responses input items. + now := time.Unix(1_800_000_000, 0) + dir := t.TempDir() + if err := Save(dir, validTokens(now)); err != nil { + t.Fatal(err) + } + var requests []map[string]any + var headers []http.Header + backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/responses" { + t.Fatalf("turn path = %q", request.URL.Path) + } + var body map[string]any + if err := json.NewDecoder(request.Body).Decode(&body); err != nil { + t.Fatal(err) + } + requests = append(requests, body) + headers = append(headers, request.Header.Clone()) + writer.Header().Set("Content-Type", "text/event-stream") + fmt.Fprintln(writer, `data: {"type":"response.created","response":{"id":"resp-1","model":"gpt-5.5","created_at":1800000000}}`) + fmt.Fprintln(writer) + fmt.Fprintln(writer, `data: {"type":"response.reasoning_summary_text.delta","delta":"thinking"}`) + fmt.Fprintln(writer) + fmt.Fprintln(writer, `data: {"type":"response.output_text.delta","delta":"answer"}`) + fmt.Fprintln(writer) + fmt.Fprintln(writer, `data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call-1","name":"read"}}`) + fmt.Fprintln(writer) + fmt.Fprintln(writer, `data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{\\"path\\":\\"a\\"}"}`) + fmt.Fprintln(writer) + fmt.Fprintln(writer, `data: {"type":"response.output_item.done","item":{"type":"reasoning","id":"rs-1","encrypted_content":"ciphertext"}}`) + fmt.Fprintln(writer) + fmt.Fprintln(writer, `data: {"type":"response.completed","response":{"id":"resp-1","model":"gpt-5.5","usage":{"input_tokens":11,"output_tokens":7,"input_tokens_details":{"cached_tokens":3},"output_tokens_details":{"reasoning_tokens":2}}}}`) + fmt.Fprintln(writer) + })) + defer backend.Close() + client := ClientWithOptions(dir, Options{Backend: backend.URL, HTTPClient: backend.Client(), Now: func() time.Time { return now }, SessionID: "conversation-one"}) + first := map[string]any{ + "model": "gpt-5.5", "stream": true, + "messages": []any{map[string]any{"role": "system", "content": "system one"}, map[string]any{"role": "user", "content": "hello"}}, + "tools": []any{map[string]any{"type": "function", "function": map[string]any{"name": "read", "description": "read a file", "parameters": map[string]any{"type": "object"}}}}, + } + responseBody := doTurn(t, client, backend.URL+"/chat/completions", first) + for _, want := range []string{`"reasoning":"thinking"`, `"content":"answer"`, `"tool_calls"`, `"reasoning_details"`, `"finish_reason":"tool_calls"`, `"prompt_tokens":11`, `"cached_tokens":3`, `"reasoning_tokens":2`} { + if !strings.Contains(responseBody, want) { + t.Errorf("translated stream missing %s: %s", want, responseBody) + } + } + second := map[string]any{ + "model": "gpt-5.5", "stream": true, + "messages": []any{ + map[string]any{"role": "assistant", "content": "answer", "reasoning_details": []any{map[string]any{"type": "reasoning.encrypted", "format": "openai-responses-v1", "id": "rs-1", "data": "ciphertext"}, "foreign"}, "tool_calls": []any{map[string]any{"id": "call-1", "function": map[string]any{"name": "read", "arguments": "{}"}}}}, + map[string]any{"role": "tool", "tool_call_id": "call-1", "content": "file words"}, + }, + } + _ = doTurn(t, client, backend.URL+"/chat/completions", second) + if len(requests) != 2 { + t.Fatalf("requests = %d", len(requests)) + } + encoded, _ := json.Marshal(requests[1]["input"]) + for _, want := range []string{`"type":"reasoning"`, `"encrypted_content":"ciphertext"`, `"type":"function_call"`, `"type":"function_call_output"`, `"output":"file words"`} { + if !bytes.Contains(encoded, []byte(want)) { + t.Errorf("round trip missing %s: %s", want, encoded) + } + } + for _, header := range headers { + if header.Get("Authorization") != "Bearer access-token-secret" || header.Get("chatgpt-account-id") != "account-one" || header.Get("originator") != Originator || header.Get("OpenAI-Beta") != "responses=experimental" || header.Get("session_id") != "conversation-one" || header.Get("User-Agent") != provider.DirectUserAgent { + t.Errorf("turn headers = %v", header) + } + if header.Get("HTTP-Referer") != "" || header.Get("X-Title") != "" || header.Get("Authorization") == "Bearer "+Sentinel { + t.Errorf("router or sentinel header escaped: %v", header) + } + } +} + +func doTurn(t *testing.T, client *http.Client, endpoint string, body map[string]any) string { + t.Helper() + raw, _ := json.Marshal(body) + request, _ := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(raw)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("HTTP-Referer", "must-go") + request.Header.Set("X-Title", "must-go") + response, err := client.Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + translated, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + return string(translated) +} + +func TestC16RefreshesBeforeExpirySingleFlightAndRetriesOneUnauthorizedCall(t *testing.T) { + // C16: near-expiry refresh is single-flight and a 401 forces exactly one refresh and retry. + now := time.Unix(1_800_000_000, 0) + dir := t.TempDir() + tokens := validTokens(now) + tokens.ExpiresAt = now.Add(time.Minute) + if err := Save(dir, tokens); err != nil { + t.Fatal(err) + } + var refreshes atomic.Int32 + var calls atomic.Int32 + access := jwt(t, map[string]any{"exp": now.Add(time.Hour).Unix()}) + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path == tokenPath { + refreshes.Add(1) + _ = json.NewEncoder(writer).Encode(map[string]string{"access_token": access, "refresh_token": "rotated-refresh", "id_token": ""}) + return + } + if calls.Add(1) == 1 && request.Header.Get("Authorization") == "Bearer "+access { + writer.WriteHeader(http.StatusUnauthorized) + return + } + _ = json.NewEncoder(writer).Encode(map[string]any{"models": []any{map[string]any{"slug": "gpt-5.5", "visibility": "list"}}}) + })) + defer server.Close() + options := Options{Issuer: server.URL, Backend: server.URL, HTTPClient: server.Client(), Now: func() time.Time { return now }} + var group sync.WaitGroup + errorsFound := make(chan error, 20) + for range 20 { + group.Add(1) + go func() { + defer group.Done() + _, err := List(context.Background(), dir, options) + errorsFound <- err + }() + } + group.Wait() + close(errorsFound) + for err := range errorsFound { + if err != nil { + t.Fatal(err) + } + } + if refreshes.Load() != 2 { + t.Fatalf("refreshes = %d, want expiry plus one 401 refresh", refreshes.Load()) + } +} + +func TestC16RefusedRefreshReturnsTheSignInSentenceAndMarksTokensUnusable(t *testing.T) { + // C16: a refused rotating token stops without a retry storm and requires sign-in again. + now := time.Unix(1_800_000_000, 0) + dir := t.TempDir() + tokens := validTokens(now) + tokens.ExpiresAt = now + if err := Save(dir, tokens); err != nil { + t.Fatal(err) + } + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { writer.WriteHeader(http.StatusUnauthorized) })) + defer server.Close() + _, err := List(context.Background(), dir, Options{Issuer: server.URL, Backend: server.URL, HTTPClient: server.Client(), Now: func() time.Time { return now }}) + if !errors.Is(err, ErrSignInExpired) { + t.Fatalf("refresh error = %v", err) + } + if Connected(dir) { + t.Fatal("refused refresh still reads connected") + } +} + +func TestC17QuotaResponseNamesCodexAndTheAutomaticReset(t *testing.T) { + // C17: a backend quota envelope becomes the one plain Codex plan-limit sentence. + now := time.Unix(1_800_000_000, 0) + dir := t.TempDir() + if err := Save(dir, validTokens(now)); err != nil { + t.Fatal(err) + } + backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.WriteHeader(http.StatusTooManyRequests) + _, _ = io.WriteString(writer, `{"error":{"code":"usage_limit_reached"}}`) + })) + defer backend.Close() + client := ClientWithOptions(dir, Options{Backend: backend.URL, HTTPClient: backend.Client(), Now: func() time.Time { return now }}) + raw, _ := json.Marshal(map[string]any{"model": "gpt-5.5", "stream": true, "messages": []any{map[string]any{"role": "user", "content": "hello"}}}) + request, _ := http.NewRequest(http.MethodPost, backend.URL+"/chat/completions", bytes.NewReader(raw)) + response, err := client.Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + body, _ := io.ReadAll(response.Body) + // The status is the one every service's payment refusal already carries, + // so the session ends the turn without any vendor-wide word list. + if response.StatusCode != http.StatusPaymentRequired || !strings.Contains(string(body), QuotaWords) { + t.Fatalf("quota response = %d %s", response.StatusCode, body) + } + if !paymentrefusal.Matches(response.StatusCode, body) { + t.Fatalf("the rewritten quota response is not read as a payment refusal: %d %s", response.StatusCode, body) + } +} diff --git a/internal/provider/client.go b/internal/provider/client.go index 477d03bb9..c5287afec 100644 --- a/internal/provider/client.go +++ b/internal/provider/client.go @@ -109,7 +109,8 @@ type Config struct { // reason to wait on the one path where somebody is already watching a failure. NearestModels func(model string) []string - // HTTPClient is optional and exists for deterministic tests. + // HTTPClient is optional. Connected services may use it to adapt their wire + // protocol, and tests use the same seam to keep requests deterministic. HTTPClient *http.Client } diff --git a/internal/provider/codex_identity_test.go b/internal/provider/codex_identity_test.go new file mode 100644 index 000000000..fb3acd234 --- /dev/null +++ b/internal/provider/codex_identity_test.go @@ -0,0 +1,19 @@ +package provider + +import ( + "net/http" + "testing" +) + +func TestC13CodexDirectRequestGetsProductIdentityWithoutRouterAttribution(t *testing.T) { + // C13: the direct Codex backend sees codeaf's user agent and no OpenRouter attribution. + client := &Client{config: Config{BaseURL: "https://chatgpt.com/backend-api/codex", Model: "gpt-5.5", Direct: true}} + request, _ := http.NewRequest(http.MethodPost, client.config.BaseURL, nil) + client.applyRequestIdentity(request) + if request.Header.Get("User-Agent") != DirectUserAgent { + t.Fatalf("user agent = %q", request.Header.Get("User-Agent")) + } + if request.Header.Get("HTTP-Referer") != "" || request.Header.Get("X-Title") != "" { + t.Fatalf("router attribution = %v", request.Header) + } +} diff --git a/internal/provider/funnel_law_test.go b/internal/provider/funnel_law_test.go index cffd48017..47ff47964 100644 --- a/internal/provider/funnel_law_test.go +++ b/internal/provider/funnel_law_test.go @@ -219,8 +219,8 @@ var funnelExemptTrees = map[string]string{ "harnesses": "harness fixtures, run by the harness and not by this build", } -// funnelKnownSecondTransports are the two files that DO reach an endpoint from -// outside this package, named one by one rather than exempted by directory. +// funnelKnownSecondTransports are the files that DO name an endpoint outside +// this package, named one by one rather than exempted by directory. // // A DIRECTORY EXEMPTION WOULD LET THE THIRD ONE IN SILENTLY, which is the // failure this whole law is about, so the debt is enumerated: any file not on @@ -235,6 +235,10 @@ var funnelExemptTrees = map[string]string{ // should end up. var funnelKnownSecondTransports = map[string]string{ "cmd/harness-design/openrouter.go": "the harness-design rig's deliberate flat transport", + // This is not a second call path. It is the translating transport attached + // to provider.Client by config, and the provider funnel still owns the call, + // its lane, its watch and its record. + "internal/codexauth/transport.go": "the provider funnel's Codex wire adapter", } // TestNothingOutsideTheFunnelTalksToAModelEndpoint is law (a). diff --git a/internal/provider/reasoning_test.go b/internal/provider/reasoning_test.go index 1cba23463..cc9dac157 100644 --- a/internal/provider/reasoning_test.go +++ b/internal/provider/reasoning_test.go @@ -88,11 +88,11 @@ func TestAReasoningReplayRefusalIsLearnedForOnlyThatModel(t *testing.T) { } } -// A /model switch mid-conversation. The transcript carries the old model's +// C15: a /model switch mid-conversation. The transcript carries the old model's // working, and OpenRouter answers a replay of it to anyone else with a 404 — // "encrypted payloads can only be replayed to the endpoint that created them". // The words go to the new model; the thinking stays home. -func TestAnotherModelsReasoningStaysHome(t *testing.T) { +func TestC15AnotherModelsReasoningStaysHome(t *testing.T) { client, recorded := newCachingClient(t, "http://provider.test", "reasoning/replay") own := WithMessageReasoning(context.Background(), []MessageReasoning{ {}, {Field: "reasoning_content", Text: "mine", Model: "reasoning/replay"}, {}, From fd32a7479bb0c4dfdca85f69768284e958e6181b Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 21 Sep 2026 15:11:44 -0400 Subject: [PATCH 02/18] config: Codex is a model service, resolved with its home and its own client A profile could not resolve or construct a Codex model source. The vendored rows now carry `codex` after Qwen, a Connected knows the profile home its rotating credentials live in, ClientConfigFor attaches the codex client to that one service with the sentinel key and no price, ConnectCodex keeps a finished sign-in and lists the account's models, and DisconnectService removes the token file with the row. CODEAF_CODEX_ISSUER and CODEAF_CODEX_BACKEND point the real binary at a fixture. What was true: a headless command with no OpenRouter key refused to start even when a connected service could carry it. What is true now: load refuses only when no service at all holds a credential. Co-Authored-By: Claude Fable 5.1 --- internal/config/codex_integration_test.go | 113 ++++++++++++++++++++++ internal/config/codexclient_law_test.go | 62 ++++++++++++ internal/config/config.go | 26 ++++- internal/config/settings.go | 4 + internal/config/sources.go | 63 +++++++++++- internal/modelsource/modelsource.go | 16 ++- internal/modelsource/modelsource_test.go | 14 +-- internal/session/clientdoor.go | 4 +- internal/session/codex_client_test.go | 61 ++++++++++++ 9 files changed, 348 insertions(+), 15 deletions(-) create mode 100644 internal/config/codex_integration_test.go create mode 100644 internal/config/codexclient_law_test.go create mode 100644 internal/session/codex_client_test.go diff --git a/internal/config/codex_integration_test.go b/internal/config/codex_integration_test.go new file mode 100644 index 000000000..d1e1482a1 --- /dev/null +++ b/internal/config/codex_integration_test.go @@ -0,0 +1,113 @@ +package config + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + + "github.com/Agent-Field/codeaf/internal/codexauth" + "github.com/Agent-Field/codeaf/internal/provider" +) + +func TestC13C14RealConfigChainAndProviderClientReachCodexTransport(t *testing.T) { + // C13: the real ResolveSources → ClientConfigFor → provider.Client road reaches /responses. + // C14: the real provider decoder receives answer and usage while Codex price stays unknown. + var seen map[string]any + backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/responses" { + t.Fatalf("provider reached %q, want /responses", request.URL.Path) + } + if request.Header.Get("Authorization") != "Bearer real-chain-access" || request.Header.Get("Authorization") == "Bearer "+codexauth.Sentinel { + t.Fatalf("authorization = %q", request.Header.Get("Authorization")) + } + if request.Header.Get("HTTP-Referer") != "" || request.Header.Get("X-Title") != "" { + t.Fatalf("OpenRouter attribution escaped: %v", request.Header) + } + if err := json.NewDecoder(request.Body).Decode(&seen); err != nil { + t.Fatal(err) + } + writer.Header().Set("Content-Type", "text/event-stream") + fmt.Fprintln(writer, `data: {"type":"response.created","response":{"id":"real-1","model":"gpt-5.5","created_at":1800000000}}`) + fmt.Fprintln(writer) + fmt.Fprintln(writer, `data: {"type":"response.output_text.delta","delta":"real answer"}`) + fmt.Fprintln(writer) + fmt.Fprintln(writer, `data: {"type":"response.completed","response":{"usage":{"input_tokens":5,"output_tokens":2,"input_tokens_details":{"cached_tokens":1},"output_tokens_details":{"reasoning_tokens":1}}}}`) + fmt.Fprintln(writer) + })) + defer backend.Close() + t.Setenv("CODEAF_CODEX_BACKEND", backend.URL) + dir := t.TempDir() + tokens := codexauth.Tokens{AccessToken: "real-chain-access", RefreshToken: "real-chain-refresh", IDToken: "real-chain-identity", AccountID: "acct-real", ExpiresAt: time.Now().Add(time.Hour)} + if err := codexauth.Save(dir, tokens); err != nil { + t.Fatal(err) + } + listed := true + if err := WriteSources(dir, []PersistedSource{{ID: "codex", Written: "codex", Key: codexauth.Sentinel, Listed: &listed}}); err != nil { + t.Fatal(err) + } + configured := ClientConfigFor(ResolveSources(dir, "", DefaultBaseURL), "codex/gpt-5.5") + if configured.HTTPClient == nil || !configured.Direct || configured.Model != "gpt-5.5" || configured.APIKey != codexauth.Sentinel || configured.BillingDoor != "" { + t.Fatalf("configured client = %+v", configured) + } + if _, _, known := configured.ModelPrice("gpt-5.5"); known { + t.Fatal("codex price is known") + } + client, err := provider.NewClient(configured) + if err != nil { + t.Fatal(err) + } + response, err := client.CompleteWithMessages(context.Background(), []ai.Message{{Role: "user", Content: []ai.ContentPart{{Type: "text", Text: "hello through the real chain"}}}}) + if err != nil { + t.Fatal(err) + } + if len(response.Choices) == 0 || len(response.Choices[0].Message.Content) == 0 || response.Choices[0].Message.Content[0].Text != "real answer" { + t.Fatalf("provider response = %+v", response) + } + encoded, _ := json.Marshal(seen) + if !strings.Contains(string(encoded), "hello through the real chain") || !strings.Contains(string(encoded), `"stream":true`) { + t.Fatalf("responses request = %s", encoded) + } +} + +func TestC12ConnectCodexFallsBackAndResolvedModelsRemainQualified(t *testing.T) { + // C12: an unreachable live list persists the connection and returns the four bare fallback ids for qualification by the service. + backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { writer.WriteHeader(http.StatusBadGateway) })) + defer backend.Close() + t.Setenv("CODEAF_CODEX_BACKEND", backend.URL) + dir := t.TempDir() + tokens := codexauth.Tokens{AccessToken: "fallback-access", RefreshToken: "fallback-refresh", IDToken: "fallback-identity", AccountID: "acct", ExpiresAt: time.Now().Add(time.Hour)} + outcome, err := ConnectCodex(context.Background(), dir, tokens) + if err != nil { + t.Fatal(err) + } + if !outcome.Listed || outcome.Refreshed || len(outcome.ModelIDs) != 4 { + t.Fatalf("fallback outcome = %+v", outcome) + } + service, ok := ResolveSources(dir, "", DefaultBaseURL).ByID("codex") + if !ok || service.Qualify(outcome.ModelIDs[0]) != "codex/gpt-5.5" { + t.Fatalf("qualified fallback = %+v, %v", service, outcome.ModelIDs) + } +} + +func TestC18CodexSentinelIsNeverAUsableBearerOutsideItsTransport(t *testing.T) { + // C18: config exposes only the sentinel while its attached transport owns every real token. + dir := t.TempDir() + if err := codexauth.Save(dir, codexauth.Tokens{AccessToken: "private-access", RefreshToken: "private-refresh", IDToken: "private-identity"}); err != nil { + t.Fatal(err) + } + listed := true + if err := WriteSources(dir, []PersistedSource{{ID: "codex", Written: "codex", Key: codexauth.Sentinel, Listed: &listed}}); err != nil { + t.Fatal(err) + } + configured := ClientConfigFor(ResolveSources(dir, "", DefaultBaseURL), "codex/gpt-5.5") + if configured.APIKey != codexauth.Sentinel || strings.Contains(fmt.Sprintf("%v", configured), "private-access") { + t.Fatalf("credential escaped config: %+v", configured) + } +} diff --git a/internal/config/codexclient_law_test.go b/internal/config/codexclient_law_test.go new file mode 100644 index 000000000..f56601a5a --- /dev/null +++ b/internal/config/codexclient_law_test.go @@ -0,0 +1,62 @@ +package config + +import ( + "go/ast" + "go/parser" + "go/token" + "io/fs" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +func TestC13NoSecondCodexProviderConstructorBypassesClientConfig(t *testing.T) { + // C13: ClientConfigFor is the only door that may attach the Codex backend to provider.NewClient. + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatal(err) + } + err = filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + if entry.Name() == ".git" || entry.Name() == ".codex-login-spec" { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + set := token.NewFileSet() + file, parseErr := parser.ParseFile(set, path, nil, 0) + if parseErr != nil { + return nil + } + ast.Inspect(file, func(node ast.Node) bool { + literal, ok := node.(*ast.BasicLit) + if !ok || literal.Kind != token.STRING { + return true + } + value, _ := strconv.Unquote(literal.Value) + if !strings.Contains(value, "chatgpt.com/backend-api/codex") { + return true + } + // The vendored source and codexauth's constant describe the service; + // neither constructs provider.Client. Any other literal is a second + // base-url owner and therefore a bypass around ClientConfigFor. + relative, _ := filepath.Rel(root, path) + if relative != "internal/modelsource/modelsource.go" && relative != "internal/codexauth/tokens.go" { + t.Errorf("%s names the Codex base URL outside its two declarative owners", set.Position(literal.Pos())) + } + return true + }) + return nil + }) + if err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 5a15b816b..07e3faf66 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,6 +17,7 @@ import ( "github.com/Agent-Field/codeaf/internal/calllog" "github.com/Agent-Field/codeaf/internal/catalog" + "github.com/Agent-Field/codeaf/internal/codexauth" "github.com/Agent-Field/codeaf/internal/ctxbudget" "github.com/Agent-Field/codeaf/internal/env" "github.com/Agent-Field/codeaf/internal/modelsource" @@ -453,9 +454,20 @@ func load(requireKey bool) (Config, error) { Swarm: DefaultSwarm, ProfileDir: profileDir, } - config.Sources = resolveSources(config.APIKey, config.BaseURL, persistedSourcesFrom(profileValues), sourceKeyFromRow) + config.Sources = sourceHomes(resolveSources(config.APIKey, config.BaseURL, persistedSourcesFrom(profileValues), func(row PersistedSource, source modelsource.Source) string { + return SourceKeyAt(profileDir, row, source) + }), profileDir) if config.APIKey == "" && requireKey { - return Config{}, ErrNoAPIKey + hasService := false + for _, service := range config.Sources.All() { + if !strings.EqualFold(service.Source.ID, modelsource.DefaultID) && (strings.TrimSpace(service.Key) != "" || service.Source.KeyOptional) { + hasService = true + break + } + } + if !hasService { + return Config{}, ErrNoAPIKey + } } // Every user-tunable knob below resolves through the settings registry's // one order — environment, then the profile's config.json, then the @@ -935,6 +947,12 @@ func ClientConfigFor(sources modelsource.Set, model string) provider.Config { BillingDoor: service.Door.Name, Effort: effort, } + if strings.EqualFold(strings.TrimSpace(service.Source.ID), "codex") { + configured.APIKey = codexauth.Sentinel + configured.HTTPClient = codexauth.Client(service.Home) + configured.BillingDoor = "" + configured.ModelPrice = func(string) (float64, float64, bool) { return 0, 0, false } + } if service.Overflow != nil { configured.PlanOverflow = service.Overflow.Address configured.PlanOverflowDoor = service.Overflow.Name @@ -976,7 +994,9 @@ func (c Config) ClientConfig(model string) provider.Config { // And the model's own list price, which is what the adapter bounds a // latency-sorted request against. Same contract: never blocks, and // "nobody published one" sends no ceiling at all. - configured.ModelPrice = c.Models.PriceNow + if configured.ModelPrice == nil { + configured.ModelPrice = c.Models.PriceNow + } return configured } diff --git a/internal/config/settings.go b/internal/config/settings.go index 727b9acda..379e00817 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -1102,6 +1102,10 @@ var ModelPoolChoices = []string{"on", "read", "off"} // footer and never become editable rows. var OperatorEnvPins = []string{ "CODEAF_BASE_URL", + // These two addresses make the real Codex browser and backend roads + // reproducible against a local fixture. They are plumbing, not preferences. + "CODEAF_CODEX_ISSUER", + "CODEAF_CODEX_BACKEND", // The check seat has no editable profile row. Its environment rung is // launch plumbing, listed read-only without changing the task.Role boundary. "CODEAF_CHECK_MODEL", diff --git a/internal/config/sources.go b/internal/config/sources.go index 6c59f4036..fba7c3c2f 100644 --- a/internal/config/sources.go +++ b/internal/config/sources.go @@ -9,6 +9,7 @@ import ( "sort" "strings" + "github.com/Agent-Field/codeaf/internal/codexauth" "github.com/Agent-Field/codeaf/internal/env" "github.com/Agent-Field/codeaf/internal/modelsource" "github.com/Agent-Field/codeaf/internal/paymentrefusal" @@ -105,6 +106,12 @@ func SourceKeyAt(profileDir string, row PersistedSource, src modelsource.Source) if strings.EqualFold(strings.TrimSpace(src.ID), modelsource.DefaultID) { return APIKeyAt(profileDir) } + if strings.EqualFold(strings.TrimSpace(src.ID), "codex") { + if codexauth.Connected(profileDir) { + return codexauth.Sentinel + } + return "" + } return sourceKeyFromRow(row, src) } @@ -115,9 +122,22 @@ func sourceKeyFromRow(row PersistedSource, src modelsource.Source) string { // ResolveSources builds the whole registry: the synthesised default service // first, then every persisted row this build still knows. func ResolveSources(profileDir, defaultKey, defaultBase string) modelsource.Set { - return resolveSources(defaultKey, defaultBase, PersistedSources(profileDir), func(row PersistedSource, source modelsource.Source) string { + set := resolveSources(defaultKey, defaultBase, PersistedSources(profileDir), func(row PersistedSource, source modelsource.Source) string { return SourceKeyAt(profileDir, row, source) }) + return sourceHomes(set, profileDir) +} + +func sourceHomes(set modelsource.Set, profileDir string) modelsource.Set { + services := set.All() + for index := range services { + services[index].Home = profileDir + if strings.EqualFold(services[index].Source.ID, "codex") { + services[index].Address = codexauth.Backend() + services[index].Source.Address = services[index].Address + } + } + return modelsource.NewSet(services...) } // resolveSources is the file-free half of ResolveSources. Load hands it the @@ -178,6 +198,9 @@ func resolveSources(defaultKey, defaultBase string, rows []PersistedSource, keyA } func resolvedSourceAddress(row PersistedSource, source modelsource.Source) string { + if strings.EqualFold(strings.TrimSpace(source.ID), "codex") { + return codexauth.Backend() + } if modelsource.IsCustomID(source.ID) { return strings.TrimRight(strings.TrimSpace(row.Address), "/") } @@ -187,6 +210,33 @@ func resolvedSourceAddress(row PersistedSource, source modelsource.Source) strin return resolvedRegionAddress(row, source) } +// ConnectCodex keeps a completed browser sign-in, persists its service row and +// seeds the picker from the account's own visible model list. +func ConnectCodex(ctx context.Context, profileDir string, tokens codexauth.Tokens) (modelsource.Outcome, error) { + if err := codexauth.Save(profileDir, tokens); err != nil { + return modelsource.Outcome{}, err + } + listed := true + row := PersistedSource{ID: "codex", Written: "codex", Key: codexauth.Sentinel, Listed: &listed} + if err := persistConnectedSource(profileDir, row); err != nil { + _ = codexauth.Remove(profileDir) + return modelsource.Outcome{}, err + } + models, listErr := codexauth.List(ctx, profileDir, codexauth.Options{}) + refreshed := listErr == nil + if !refreshed { + models = append([]codexauth.Model(nil), codexauth.FallbackModels...) + } + outcome := modelsource.Outcome{Kind: modelsource.OutcomeConnected, Listed: true, Refreshed: refreshed} + for _, model := range models { + if id := strings.TrimSpace(model.ID); id != "" { + outcome.ModelIDs = append(outcome.ModelIDs, id) + } + } + outcome.Models = len(outcome.ModelIDs) + return outcome, nil +} + func resolvedRegionAddress(row PersistedSource, source modelsource.Source) string { if address := selectedRegionAddress(row, source); address != "" { return address @@ -555,7 +605,8 @@ func persistConnectedSource(profileDir string, row PersistedSource) error { return WriteSources(profileDir, rows) } -// DisconnectService removes one service row and its stored key atomically. +// DisconnectService removes one service row and its stored credential. Codex's +// rotating tokens live in their owner-only sibling file and leave with it. func DisconnectService(profileDir, id string) error { rows := PersistedSources(profileDir) kept := rows[:0] @@ -564,5 +615,11 @@ func DisconnectService(profileDir, id string) error { kept = append(kept, row) } } - return WriteSources(profileDir, kept) + if err := WriteSources(profileDir, kept); err != nil { + return err + } + if strings.EqualFold(strings.TrimSpace(id), "codex") { + return codexauth.Remove(profileDir) + } + return nil } diff --git a/internal/modelsource/modelsource.go b/internal/modelsource/modelsource.go index 603ac70b6..69af91b75 100644 --- a/internal/modelsource/modelsource.go +++ b/internal/modelsource/modelsource.go @@ -148,6 +148,9 @@ type Connected struct { Source Source Key string Address string + // Home is the profile directory whose rotating credentials belong to this + // connection. Empty keeps the ordinary codeaf state root. + Home string // Door is the bound billing road. It is zero for a one-door service, so all // older status and runtime behaviour remains byte-identical. Door Door @@ -414,7 +417,7 @@ const ( minimaxPreferredModel = "MiniMax-M3" ) -// Vendored returns the seven service descriptions shipped by this phase. +// Vendored returns the service descriptions shipped by this phase. func Vendored() []Source { return []Source{ { @@ -478,6 +481,12 @@ func Vendored() []Source { Listing: ListingNone, ProbeModel: "qwen3.8-flash", Probe: listingProbe(), Preferred: "qwen3.7-plus", }, + { + ID: "codex", Written: "codex", Name: "Codex", + Address: "https://chatgpt.com/backend-api/codex", + KeyShape: func(key string) bool { return strings.TrimSpace(key) == "chatgpt" }, + Listing: ListingNone, Probe: Probe{}, Preferred: "gpt-5.5", + }, { ID: "ollama", Written: "ollama", Name: "Ollama", Address: "http://localhost:11434/v1", KeyOptional: true, @@ -622,7 +631,10 @@ type Outcome struct { // making a second catalog-shaped response the only road to the picker. ModelIDs []string Listed bool - Door Door + // Refreshed distinguishes an answered live list from the vendored fallback. + // It is meaningful only when Listed is true. + Refreshed bool + Door Door // PlanPaused records that the selected door proved the plan exists but its // current usage window is spent. PlanReset is the vendor's readable reset // time when it supplied one, and Overflow is the separately billed road the diff --git a/internal/modelsource/modelsource_test.go b/internal/modelsource/modelsource_test.go index fc9c75e60..2d4134a39 100644 --- a/internal/modelsource/modelsource_test.go +++ b/internal/modelsource/modelsource_test.go @@ -125,9 +125,10 @@ func TestUnqualifiedIdsStayOnTheDefaultService(t *testing.T) { } } -func TestVendoredRowsAreTheDecidedSeven(t *testing.T) { +func TestVendoredRowsCarryTheCodexServiceInItsDecidedPlace(t *testing.T) { + // C12: Codex is a model service whose models are qualified on every surface. rows := Vendored() - want := []string{"deepseek", "z-ai", "moonshot", "minimax", "qwen", "ollama", "custom"} + want := []string{"deepseek", "z-ai", "moonshot", "minimax", "qwen", "codex", "ollama", "custom"} if len(rows) != len(want) { t.Fatalf("vendored rows = %d, want %d", len(rows), len(want)) } @@ -135,15 +136,15 @@ func TestVendoredRowsAreTheDecidedSeven(t *testing.T) { if rows[i].ID != want[i] { t.Errorf("row %d = %q, want %q", i, rows[i].ID, want[i]) } - if rows[i].Probe.Timeout != ProbeTimeout { + if rows[i].ID != "codex" && rows[i].Probe.Timeout != ProbeTimeout { t.Errorf("row %s probe timeout = %s, want %s", rows[i].ID, rows[i].Probe.Timeout, ProbeTimeout) } } - if !rows[5].KeyOptional { + if !rows[6].KeyOptional { t.Fatal("only Ollama may omit its key") } for index, row := range rows { - if index != 5 && row.KeyOptional { + if index != 6 && row.KeyOptional { t.Fatalf("%s unexpectedly accepts a blank key", row.ID) } } @@ -179,6 +180,7 @@ func TestVendoredListingHintsAndProbeModelsMatchTheProviderSurvey(t *testing.T) {"moonshot", ListingNone, "kimi-k2.7-code", "kimi-k2.7-code"}, {"minimax", ListingNone, "MiniMax-M3", "MiniMax-M3"}, {"qwen", ListingNone, "qwen3.8-flash", "qwen3.7-plus"}, + {"codex", ListingNone, "", "gpt-5.5"}, {"ollama", ListingModels, "", ""}, {"custom", ListingModels, "", ""}, } @@ -190,7 +192,7 @@ func TestVendoredListingHintsAndProbeModelsMatchTheProviderSurvey(t *testing.T) index, row.ID, row.Listing, row.ProbeModel, row.Preferred, expected.id, expected.listing, expected.probeModel, expected.preferred) } - if row.Probe.Method != "GET" || row.Probe.Address != "/models" { + if row.ID != "codex" && (row.Probe.Method != "GET" || row.Probe.Address != "/models") { t.Errorf("row %s does not try the listing first: %+v", row.ID, row.Probe) } } diff --git a/internal/session/clientdoor.go b/internal/session/clientdoor.go index 4f99f493b..e10d65897 100644 --- a/internal/session/clientdoor.go +++ b/internal/session/clientdoor.go @@ -622,7 +622,9 @@ func (c Config) clientConfig(model string, timeout time.Duration) provider.Confi // only when explicit and nobody invents a price or a reasoning shape. configured.SupportsParameter = c.SupportsParameter configured.ReasoningProfile = c.ReasoningProfile - configured.ModelPrice = c.ModelPrice + if configured.ModelPrice == nil { + configured.ModelPrice = c.ModelPrice + } return configured } diff --git a/internal/session/codex_client_test.go b/internal/session/codex_client_test.go new file mode 100644 index 000000000..824d56316 --- /dev/null +++ b/internal/session/codex_client_test.go @@ -0,0 +1,61 @@ +package session + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/codexauth" + account "github.com/Agent-Field/codeaf/internal/config" +) + +func TestC13C14RealAgentUsesTheConfigOwnedCodexClientDoor(t *testing.T) { + // C13: a real session.Agent reaches Codex through ResolveSources and ClientConfigFor. + // C14: its ordinary streamed text and usage cross the same provider boundary as every direct service. + var mutex sync.Mutex + var path, authorization string + var body map[string]any + backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + mutex.Lock() + path, authorization = request.URL.Path, request.Header.Get("Authorization") + _ = json.NewDecoder(request.Body).Decode(&body) + mutex.Unlock() + writer.Header().Set("Content-Type", "text/event-stream") + fmt.Fprintln(writer, `data: {"type":"response.created","response":{"id":"agent-1","model":"gpt-5.5","created_at":1800000000}}`) + fmt.Fprintln(writer) + fmt.Fprintln(writer, `data: {"type":"response.output_text.delta","delta":"agent answer"}`) + fmt.Fprintln(writer) + fmt.Fprintln(writer, `data: {"type":"response.completed","response":{"usage":{"input_tokens":5,"output_tokens":2}}}`) + fmt.Fprintln(writer) + })) + defer backend.Close() + t.Setenv("CODEAF_CODEX_BACKEND", backend.URL) + profile := t.TempDir() + if err := codexauth.Save(profile, codexauth.Tokens{AccessToken: "agent-access-token", RefreshToken: "agent-refresh-token", IDToken: "agent-identity-token", AccountID: "agent-account", ExpiresAt: time.Now().Add(time.Hour)}); err != nil { + t.Fatal(err) + } + listed := true + if err := account.WriteSources(profile, []account.PersistedSource{{ID: "codex", Written: "codex", Key: codexauth.Sentinel, Listed: &listed}}); err != nil { + t.Fatal(err) + } + agent, err := New(Config{ + Workspace: t.TempDir(), Model: "codex/gpt-5.5", System: "Answer briefly.", + Sources: account.ResolveSources(profile, "", account.DefaultBaseURL), + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = agent.Close() }) + drainTurn(t, agent, "hello from the agent") + mutex.Lock() + defer mutex.Unlock() + encoded, _ := json.Marshal(body) + if path != "/responses" || authorization != "Bearer agent-access-token" || !strings.Contains(string(encoded), "hello from the agent") { + t.Fatalf("agent request path=%q authorization=%q body=%s", path, authorization, encoded) + } +} From f536e451890b6d27d4ffbf739840d30ebaf1337f Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 21 Sep 2026 15:11:44 -0400 Subject: [PATCH 03/18] opener: one platform browser opener, shared by the chat surface and the terminal Only the chat surface owned browser opening, so a terminal command had no way to open a sign-in link. The opener moves to internal/opener with the chat's test seam kept pointing at it. Linux still answers xdg-open exactly as before; a WSL box that has no xdg-open at all falls to wslview rather than to a sentence saying the browser did not open. Co-Authored-By: Claude Fable 5.1 --- internal/opener/opener.go | 55 ++++++++++++++++++++++++++++++++++ internal/opener/opener_test.go | 33 ++++++++++++++++++++ internal/tui3/opener.go | 42 +++----------------------- 3 files changed, 92 insertions(+), 38 deletions(-) create mode 100644 internal/opener/opener.go create mode 100644 internal/opener/opener_test.go diff --git a/internal/opener/opener.go b/internal/opener/opener.go new file mode 100644 index 000000000..2242b4021 --- /dev/null +++ b/internal/opener/opener.go @@ -0,0 +1,55 @@ +// Package opener hands a link or path to the desktop without waiting for the +// application that receives it. +package opener + +import ( + "errors" + "os/exec" + "runtime" + "strings" + + "github.com/Agent-Field/codeaf/internal/env" + "github.com/Agent-Field/codeaf/internal/guard" +) + +// Command reports the platform program that opens a target. Linux answers +// xdg-open exactly as the chat surface always has, so a machine where that +// works keeps working; a WSL box that has no xdg-open at all falls to wslview, +// the bridge into the Windows desktop, rather than to a sentence saying the +// browser did not open. The order matters: on a WSL install where xdg-open is +// present it is usually wired to wslview already, and preferring wslview over +// it would change which browser opens on a machine that was fine. +func Command() (string, []string) { + switch runtime.GOOS { + case "darwin": + return "open", nil + case "linux": + if _, err := exec.LookPath("xdg-open"); err != nil && strings.TrimSpace(env.Value("WSL_DISTRO_NAME")) != "" { + return "wslview", nil + } + return "xdg-open", nil + } + return "", nil +} + +// Start hands target to the platform and returns after the process has +// started. The receiving application is allowed to outlive codeaf. +func Start(target string) error { + name, args := Command() + if name == "" { + return errors.New("this machine has no way to open a browser") + } + if strings.TrimSpace(target) == "" { + return errors.New("the browser did not open") + } + command := exec.Command(name, append(append([]string(nil), args...), target)...) + if command.Err != nil { + return errors.New("the browser did not open") + } + guard.Go("opener/start", func() { + if command.Start() == nil { + _ = command.Wait() + } + }) + return nil +} diff --git a/internal/opener/opener_test.go b/internal/opener/opener_test.go new file mode 100644 index 000000000..65d554713 --- /dev/null +++ b/internal/opener/opener_test.go @@ -0,0 +1,33 @@ +package opener + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +// A WSL box without xdg-open reaches the Windows desktop through wslview; one +// that has xdg-open keeps it, because that is what every chat door has always +// called and what a person's browser choice is wired through. +func TestWSLFallsToTheDesktopBridgeOnlyWithoutXdgOpen(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("WSL is a Linux environment") + } + bin := t.TempDir() + t.Setenv("PATH", bin) + t.Setenv("WSL_DISTRO_NAME", "Ubuntu") + if name, arguments := Command(); name != "wslview" || len(arguments) != 0 { + t.Fatalf("WSL opener without xdg-open = %q %v, want wslview", name, arguments) + } + if err := os.WriteFile(filepath.Join(bin, "xdg-open"), []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + if name, _ := Command(); name != "xdg-open" { + t.Fatalf("WSL opener with xdg-open on PATH = %q, want xdg-open", name) + } + t.Setenv("WSL_DISTRO_NAME", "") + if name, _ := Command(); name != "xdg-open" { + t.Fatalf("plain Linux opener = %q, want xdg-open", name) + } +} diff --git a/internal/tui3/opener.go b/internal/tui3/opener.go index f8ad02c97..527235f33 100644 --- a/internal/tui3/opener.go +++ b/internal/tui3/opener.go @@ -1,15 +1,11 @@ package tui3 import ( - "errors" - "os/exec" - "runtime" - "strings" "unicode/utf8" "github.com/charmbracelet/x/ansi" - "github.com/Agent-Field/codeaf/internal/guard" + "github.com/Agent-Field/codeaf/internal/opener" ) // THE TWO WAYS A LINK LEAVES THIS SURFACE. @@ -36,19 +32,13 @@ import ( // so a test can watch what would have been opened without a browser appearing // on the machine running it — the same seam, and the same reason, as // internal/tui's. -var processOpener = startOpener +var processOpener = opener.Start // openerCommand is what this platform calls "open this". An empty name is a // platform with no answer, which is a fact the caller reports rather than // papers over. func openerCommand() (string, []string) { - switch runtime.GOOS { - case "darwin": - return "open", nil - case "linux": - return "xdg-open", nil - } - return "", nil + return opener.Command() } // startOpener hands the target to the platform and does not wait for whatever @@ -64,31 +54,7 @@ func openerCommand() (string, []string) { // (framedisk_law_test.go). A fork that then fails is a link that did not open, // which is the case the link written under every handoff exists for. func startOpener(target string) error { - name, args := openerCommand() - if name == "" { - return errors.New("this machine has no way to open a browser") - } - // AN EMPTY TARGET IS NOTHING TO OPEN, and it is refused HERE so that all six - // doors are refused by one line. `open ""` on a Mac does not fail: the - // platform resolves the empty path to the process's own working directory - // and puts a Finder window on screen — so a sign-in event that arrived with - // no URL opened a file manager on whatever folder codeaf was started in, - // which is the one outcome a handoff must never have. The sentence is the - // one a miss already says, because a person is owed the same answer either - // way: the link under the block is still the way through. - if strings.TrimSpace(target) == "" { - return errors.New("the browser did not open") - } - command := exec.Command(name, append(append([]string(nil), args...), target)...) - if command.Err != nil { - return errors.New("the browser did not open") - } - guard.Go("tui3/open", func() { - if command.Start() == nil { - _ = command.Wait() - } - }) - return nil + return opener.Start(target) } // ── the link, as text ─────────────────────────────────────────────────────── From 083f04734bcfc090500e83455432c16d4c4f2fbe Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 21 Sep 2026 15:11:45 -0400 Subject: [PATCH 04/18] codeaf connect and codeaf disconnect: connect a model service from the terminal Connecting a service required opening the chat. `codeaf connect` lists the model services this profile knows and which are connected; `codeaf connect openrouter` and `codeaf connect codex` sign in in the browser (or print the address with --no-browser, with the ssh -L line for a machine that has none); the key services take a key on stdin or ask for one without echo; `codeaf disconnect ` forgets one. Both are on the help page under Housekeeping, within eighty columns. The chat manual's verb gate carries a narrow bridge for the two new verbs until their pages land in the same pull request. Co-Authored-By: Claude Fable 5.1 --- cmd/codeaf/beltdoors_test.go | 4 +- cmd/codeaf/connect.go | 336 ++++++++++++++++++++++++++ cmd/codeaf/connect_test.go | 232 ++++++++++++++++++ cmd/codeaf/main.go | 16 ++ internal/manual/terminalverbs_test.go | 6 + 5 files changed, 593 insertions(+), 1 deletion(-) create mode 100644 cmd/codeaf/connect.go create mode 100644 cmd/codeaf/connect_test.go diff --git a/cmd/codeaf/beltdoors_test.go b/cmd/codeaf/beltdoors_test.go index 9cd977460..2f0c7af04 100644 --- a/cmd/codeaf/beltdoors_test.go +++ b/cmd/codeaf/beltdoors_test.go @@ -25,7 +25,9 @@ import ( // helpLineCap is the most lines `codeaf --help` may run to: the page was cut // down to the commands, grouped, and five examples, and every line past this is // a command that scrolled off a person's screen. -const helpLineCap = 110 +// Part A's terminal connect door adds its two required forms and descriptions. +// The cap moves by exactly those seven lines; unrelated help growth still fails. +const helpLineCap = 117 // doorsOffThePage are the command words main.go dispatches that `codeaf --help` // deliberately does NOT name, so the check below does not demand a line for diff --git a/cmd/codeaf/connect.go b/cmd/codeaf/connect.go new file mode 100644 index 000000000..45aa61030 --- /dev/null +++ b/cmd/codeaf/connect.go @@ -0,0 +1,336 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "net/url" + "os" + "strings" + + "github.com/charmbracelet/x/term" + + "github.com/Agent-Field/codeaf/internal/codexauth" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/modelsource" + "github.com/Agent-Field/codeaf/internal/opener" + "github.com/Agent-Field/codeaf/internal/openrouterauth" + "github.com/Agent-Field/codeaf/internal/trace" +) + +type codexConnectFlow interface { + URL() string + Wait(context.Context) (codexauth.Tokens, error) + Cancel() +} + +type openRouterConnectFlow interface { + URL() string + Wait(context.Context) (string, error) + Cancel() +} + +var ( + connectCodexFlow = func(ctx context.Context) (codexConnectFlow, error) { + return codexauth.Begin(ctx, codexauth.Options{}) + } + connectOpenRouterFlow = func(ctx context.Context) (openRouterConnectFlow, error) { + return openrouterauth.Begin(ctx, openrouterauth.Options{}) + } + connectOpen = opener.Start + connectInput = os.Stdin + connectInputIsTTY = stdinIsTerminal + connectReadPassword = term.ReadPassword +) + +func runConnect(args []string) error { + flags := commandFlags("connect") + noBrowser := flags.Bool("no-browser", false, "print the browser address without opening it") + region := flags.String("region", "", "service region: intl or cn") + if err := parseCommandFlags(flags, reorder(flags, args)); err != nil { + return err + } + if flags.NArg() == 0 { + return listConnections(config.ProfileDir()) + } + if flags.NArg() != 1 { + return wrongCall("codeaf connect takes one service name") + } + service := strings.ToLower(strings.TrimSpace(flags.Arg(0))) + switch service { + case "codex": + return connectCodex(context.Background(), config.ProfileDir(), *noBrowser) + case modelsource.DefaultID: + return connectOpenRouter(context.Background(), config.ProfileDir(), *noBrowser) + default: + return connectKeyService(context.Background(), config.ProfileDir(), service, strings.TrimSpace(*region)) + } +} + +func connectCodex(ctx context.Context, profileDir string, noBrowser bool) error { + flow, err := connectCodexFlow(ctx) + if err != nil { + return connectFailed("codex", err) + } + defer flow.Cancel() + fmt.Fprintln(usageOut, flow.URL()) + if noBrowser { + port := callbackPort(flow.URL()) + fmt.Fprintf(usageOut, "on a machine without a browser: ssh -L %s:localhost:%s and open the link here\n", port, port) + } else { + _ = connectOpen(flow.URL()) + } + tokens, err := flow.Wait(ctx) + if err != nil { + return connectFailed("codex", err) + } + outcome, err := config.ConnectCodex(ctx, profileDir, tokens) + if err != nil { + return connectFailed("codex", err) + } + line := "codex connected" + if strings.TrimSpace(tokens.Email) != "" { + line += " · " + strings.TrimSpace(tokens.Email) + } + if strings.TrimSpace(tokens.Plan) != "" { + line += " · " + strings.TrimSpace(tokens.Plan) + " plan" + } + if !outcome.Refreshed { + line += " · model list was not refreshed" + } + fmt.Fprintln(usageOut, line) + return nil +} + +func callbackPort(address string) string { + parsed, err := url.Parse(address) + if err != nil { + return "1455" + } + redirect, err := url.Parse(parsed.Query().Get("redirect_uri")) + if err != nil || redirect.Port() == "" { + return "1455" + } + return redirect.Port() +} + +func connectOpenRouter(ctx context.Context, profileDir string, noBrowser bool) error { + flow, err := connectOpenRouterFlow(ctx) + if err != nil { + return connectFailed(modelsource.DefaultID, err) + } + defer flow.Cancel() + fmt.Fprintln(usageOut, flow.URL()) + if !noBrowser { + _ = connectOpen(flow.URL()) + } + key, err := flow.Wait(ctx) + if err != nil { + return connectFailed(modelsource.DefaultID, err) + } + trace.Secret(key) + if err := config.WriteAPIKey(profileDir, key); err != nil { + return connectFailed(modelsource.DefaultID, err) + } + fmt.Fprintln(usageOut, modelsource.DefaultID+" connected") + return nil +} + +func connectFailed(service string, err error) error { + reason := plainWords(err.Error()) + if _, tail, found := strings.Cut(reason, ": "); found { + reason = tail + } + fmt.Fprintln(usageOut, service+" did not connect · "+reason) + return exitStatus(1) +} + +func listConnections(profileDir string) error { + connected := config.ResolveSources(profileDir, config.APIKeyAt(profileDir), config.DefaultBaseURL) + held := make(map[string]bool) + for _, service := range connected.All() { + if service.Source.ID == modelsource.DefaultID { + held[service.Source.ID] = strings.TrimSpace(service.Key) != "" + } else { + held[service.Source.ID] = strings.TrimSpace(service.Key) != "" || service.Source.KeyOptional + } + } + any := false + line := func(name, method string, yes bool) { + state := "not connected" + if yes { + state, any = "connected", true + } + fmt.Fprintf(usageOut, "%s · %s · %s\n", name, state, method) + } + line(modelsource.DefaultID, "browser or key", held[modelsource.DefaultID]) + for _, source := range modelsource.Vendored() { + if modelsource.IsCustomID(source.ID) { + continue + } + method := "key" + if source.ID == "codex" { + method = "browser" + } + line(source.Written, method, held[source.ID]) + } + for _, row := range config.PersistedSources(profileDir) { + if modelsource.IsCustomID(row.ID) { + line(row.Written, "key", held[row.ID]) + } + } + if !any { + fmt.Fprintln(usageOut, "no model service is connected") + } + return nil +} + +func connectKeyService(ctx context.Context, profileDir, name, region string) error { + source, row, found := connectionSource(profileDir, name) + if !found || source.ID == "codex" || source.ID == modelsource.DefaultID { + fmt.Fprintln(usageOut, name+" is not a model service this profile knows") + return exitStatus(1) + } + if len(source.Regions) > 0 && region == "" { + return wrongCall("codeaf connect " + name + " needs --region intl or --region cn") + } + if region != "" { + valid := false + for _, candidate := range source.Regions { + if candidate.ID == region { + valid = true + } + } + if !valid { + return wrongCall("--region must be intl or cn") + } + row.Region = region + } + if !source.KeyOptional { + key, err := readConnectionKey() + if err != nil { + return err + } + row.Key, row.KeyEnv = strings.TrimSpace(key), "" + } + outcome, err := config.ConnectService(ctx, profileDir, row, source, nil) + if err != nil { + return err + } + written := strings.ToLower(strings.TrimSpace(row.Written)) + if written == "" { + written = source.Written + } + fmt.Fprintln(usageOut, connectionOutcome(written, outcome)) + if outcome.Kind != modelsource.OutcomeConnected && outcome.Kind != modelsource.OutcomeAccountCannotPay { + return exitStatus(1) + } + return nil +} + +func connectionSource(profileDir, name string) (modelsource.Source, config.PersistedSource, bool) { + for _, service := range config.ResolveSources(profileDir, "", config.DefaultBaseURL).All() { + if strings.EqualFold(service.Source.ID, name) || strings.EqualFold(service.Source.Written, name) { + for _, row := range config.PersistedSources(profileDir) { + if strings.EqualFold(row.ID, service.Source.ID) { + return service.Source, row, true + } + } + } + } + for _, source := range modelsource.Vendored() { + if strings.EqualFold(source.ID, name) || strings.EqualFold(source.Written, name) { + if modelsource.IsCustomID(source.ID) { + return modelsource.Source{}, config.PersistedSource{}, false + } + return source, config.PersistedSource{ID: source.ID, Written: source.Written}, true + } + } + return modelsource.Source{}, config.PersistedSource{}, false +} + +func readConnectionKey() (string, error) { + if connectInput == nil { + return "", errors.New("no key was provided on stdin") + } + if connectInputIsTTY(connectInput) { + fmt.Fprint(usageErr, "key: ") + raw, err := connectReadPassword(connectInput.Fd()) + fmt.Fprintln(usageErr) + return strings.TrimSpace(string(raw)), err + } + raw, err := io.ReadAll(io.LimitReader(connectInput, 1<<20)) + return strings.TrimSpace(string(raw)), err +} + +func connectionOutcome(service string, outcome modelsource.Outcome) string { + switch outcome.Kind { + case modelsource.OutcomeConnected: + line := service + " is connected" + if door := strings.TrimSpace(outcome.Door.Name); door != "" { + line += " · " + door + } + if outcome.Listed && outcome.Models > 0 { + word := "models" + if outcome.Models == 1 { + word = "model" + } + line += fmt.Sprintf(" · %d %s", outcome.Models, word) + } + return line + case modelsource.OutcomeRefused: + line := service + " refused that key" + if said := strings.TrimSpace(outcome.VendorSaid); said != "" { + line += " — " + said + } + return line + case modelsource.OutcomeAccountCannotPay: + line := service + " accepted the key but the account cannot pay" + if said := strings.TrimSpace(outcome.VendorSaid); said != "" { + line += " — " + said + } + return line + case modelsource.OutcomeUnanswered: + return service + " did not answer · nothing was saved" + case modelsource.OutcomeWrongShape: + return "that is not the shape of a " + service + " key — they start with sk-" + } + return service + " did not connect" +} + +func runDisconnect(args []string) error { + flags := commandFlags("disconnect") + if err := parseCommandFlags(flags, reorder(flags, args)); err != nil { + return err + } + if flags.NArg() != 1 { + return wrongCall("codeaf disconnect needs one service name") + } + profileDir := config.ProfileDir() + name := strings.ToLower(strings.TrimSpace(flags.Arg(0))) + if name == modelsource.DefaultID { + if strings.TrimSpace(config.PersistedAPIKey(profileDir)) == "" { + return disconnectedFailure(name) + } + if err := config.WriteAPIKey(profileDir, ""); err != nil { + return err + } + fmt.Fprintln(usageOut, modelsource.DefaultID+" disconnected") + return nil + } + _, row, found := connectionSource(profileDir, name) + if !found || strings.TrimSpace(row.ID) == "" { + return disconnectedFailure(name) + } + if err := config.DisconnectService(profileDir, row.ID); err != nil { + return err + } + fmt.Fprintln(usageOut, strings.ToLower(strings.TrimSpace(row.Written))+" disconnected") + return nil +} + +func disconnectedFailure(service string) error { + fmt.Fprintln(usageOut, service+" is not connected") + return exitStatus(1) +} diff --git a/cmd/codeaf/connect_test.go b/cmd/codeaf/connect_test.go new file mode 100644 index 000000000..2c451c22f --- /dev/null +++ b/cmd/codeaf/connect_test.go @@ -0,0 +1,232 @@ +package main + +import ( + "bytes" + "context" + "errors" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/codexauth" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/modelsource" +) + +type fakeCodexConnect struct { + address string + tokens codexauth.Tokens + err error +} + +func (f *fakeCodexConnect) URL() string { return f.address } +func (f *fakeCodexConnect) Wait(context.Context) (codexauth.Tokens, error) { return f.tokens, f.err } +func (f *fakeCodexConnect) Cancel() {} + +type fakeOpenRouterConnect struct { + address string + key string + err error +} + +func (f *fakeOpenRouterConnect) URL() string { return f.address } +func (f *fakeOpenRouterConnect) Wait(context.Context) (string, error) { return f.key, f.err } +func (f *fakeOpenRouterConnect) Cancel() {} + +func captureConnect(t *testing.T) (*bytes.Buffer, func()) { + t.Helper() + output, errorsOut := &bytes.Buffer{}, &bytes.Buffer{} + oldOut, oldErr := usageOut, usageErr + usageOut, usageErr = output, errorsOut + return output, func() { usageOut, usageErr = oldOut, oldErr } +} + +func TestC1ConnectCodexPrintsOneAddressOpensAndEndsConnected(t *testing.T) { + // C1: the terminal Codex door prints the link, opens it, waits and reports success. + dir := t.TempDir() + t.Setenv(config.ProfileDirEnv, dir) + backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + _, _ = writer.Write([]byte(`{"models":[{"slug":"gpt-5.5","visibility":"list"}]}`)) + })) + defer backend.Close() + t.Setenv("CODEAF_CODEX_BACKEND", backend.URL) + flow := &fakeCodexConnect{address: "https://auth.example/?redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback", tokens: codexauth.Tokens{AccessToken: "cli-access-token", RefreshToken: "cli-refresh-token", IDToken: "cli-identity-token", Email: "person@example.com", Plan: "pro", ExpiresAt: time.Now().Add(time.Hour)}} + oldFlow, oldOpen := connectCodexFlow, connectOpen + connectCodexFlow = func(context.Context) (codexConnectFlow, error) { return flow, nil } + opened := "" + connectOpen = func(address string) error { opened = address; return nil } + t.Cleanup(func() { connectCodexFlow, connectOpen = oldFlow, oldOpen }) + output, restore := captureConnect(t) + defer restore() + if err := runConnect([]string{"codex"}); err != nil { + t.Fatal(err) + } + if opened != flow.address || strings.Count(output.String(), flow.address) != 1 || !strings.Contains(output.String(), "codex connected · person@example.com · pro plan") { + t.Fatalf("opened %q, output %q", opened, output.String()) + } +} + +func TestC1NoBrowserPrintsForwardingInstructionWithoutOpening(t *testing.T) { + // C1 and D9: --no-browser leaves the handoff to the person and names the fixed port forward. + dir := t.TempDir() + t.Setenv(config.ProfileDirEnv, dir) + backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { writer.WriteHeader(http.StatusBadGateway) })) + defer backend.Close() + t.Setenv("CODEAF_CODEX_BACKEND", backend.URL) + oldFlow, oldOpen := connectCodexFlow, connectOpen + connectCodexFlow = func(context.Context) (codexConnectFlow, error) { + return &fakeCodexConnect{address: "https://auth.example/?redirect_uri=http%3A%2F%2Flocalhost%3A1457%2Fauth%2Fcallback", tokens: codexauth.Tokens{AccessToken: "access-eight", RefreshToken: "refresh-eight", IDToken: "identity-eight", ExpiresAt: time.Now().Add(time.Hour)}}, nil + } + opened := false + connectOpen = func(string) error { opened = true; return nil } + t.Cleanup(func() { connectCodexFlow, connectOpen = oldFlow, oldOpen }) + output, restore := captureConnect(t) + defer restore() + if err := runConnect([]string{"codex", "--no-browser"}); err != nil { + t.Fatal(err) + } + if opened || !strings.Contains(output.String(), "ssh -L 1457:localhost:1457") { + t.Fatalf("opened=%t output=%q", opened, output.String()) + } +} + +func TestC5BusyPortsAreAPlainExitOneOutcome(t *testing.T) { + // C5: a browser listener failure is reported on stdout and exits one without writing tokens. + dir := t.TempDir() + t.Setenv(config.ProfileDirEnv, dir) + old := connectCodexFlow + connectCodexFlow = func(context.Context) (codexConnectFlow, error) { + return nil, errors.New("connect Codex: both browser return ports are busy · finish or cancel the other sign-in and try again") + } + t.Cleanup(func() { connectCodexFlow = old }) + output, restore := captureConnect(t) + defer restore() + err := runConnect([]string{"codex"}) + var status exitStatus + if !errors.As(err, &status) || status != 1 || !strings.Contains(output.String(), "codex did not connect · both browser return ports are busy") { + t.Fatalf("error=%v output=%q", err, output.String()) + } + if _, err := os.Stat(codexauth.Path(dir)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("token file exists after refusal: %v", err) + } +} + +func TestC7ConnectOpenRouterReusesBrowserRoadAndProfileKey(t *testing.T) { + // C7: the terminal OpenRouter door keeps the existing browser exchange and profile key writer. + dir := t.TempDir() + t.Setenv(config.ProfileDirEnv, dir) + oldFlow, oldOpen := connectOpenRouterFlow, connectOpen + connectOpenRouterFlow = func(context.Context) (openRouterConnectFlow, error) { + return &fakeOpenRouterConnect{address: "https://open.example/sign-in", key: "sk-openrouter-test-value"}, nil + } + connectOpen = func(string) error { return nil } + t.Cleanup(func() { connectOpenRouterFlow, connectOpen = oldFlow, oldOpen }) + output, restore := captureConnect(t) + defer restore() + if err := runConnect([]string{modelsource.DefaultID}); err != nil { + t.Fatal(err) + } + if config.PersistedAPIKey(dir) != "sk-openrouter-test-value" || !strings.Contains(output.String(), modelsource.DefaultID+" connected") { + t.Fatalf("key=%q output=%q", config.PersistedAPIKey(dir), output.String()) + } +} + +func TestC8ConnectWithoutAServiceListsMethodsAndNeverDrawsNothing(t *testing.T) { + // C8: the no-argument door lists each known service and says explicitly when none is connected. + dir := t.TempDir() + t.Setenv(config.ProfileDirEnv, dir) + output, restore := captureConnect(t) + defer restore() + if err := runConnect(nil); err != nil { + t.Fatal(err) + } + for _, want := range []string{modelsource.DefaultID + " · not connected · browser or key", "codex · not connected · browser", "deepseek · not connected · key", "no model service is connected"} { + if !strings.Contains(output.String(), want) { + t.Errorf("listing missing %q: %q", want, output.String()) + } + } +} + +func TestC9KeyServiceReadsStdinAndUsesTheSharedConnectProbeWords(t *testing.T) { + // C9 and C20: a custom service reads a non-terminal key and returns the panel's unchanged success sentence. + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Header.Get("Authorization") != "Bearer sk-custom-terminal-value" { + t.Fatalf("authorization = %q", request.Header.Get("Authorization")) + } + _, _ = writer.Write([]byte(`{"data":[{"id":"model-one"}]}`)) + })) + defer server.Close() + dir := t.TempDir() + t.Setenv(config.ProfileDirEnv, dir) + listed := true + if err := config.WriteSources(dir, []config.PersistedSource{{ID: modelsource.CustomID, Written: "lab", Address: server.URL, Key: "old-key-value", Listed: &listed}}); err != nil { + t.Fatal(err) + } + reader, writer, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + _, _ = writer.WriteString("sk-custom-terminal-value\n") + _ = writer.Close() + oldInput, oldTTY := connectInput, connectInputIsTTY + connectInput, connectInputIsTTY = reader, func(*os.File) bool { return false } + t.Cleanup(func() { connectInput, connectInputIsTTY = oldInput, oldTTY; _ = reader.Close() }) + output, restore := captureConnect(t) + defer restore() + if err := runConnect([]string{"lab"}); err != nil { + t.Fatal(err) + } + if strings.TrimSpace(output.String()) != "lab is connected · 1 model" { + t.Fatalf("outcome = %q", output.String()) + } +} + +func TestC10DisconnectForgetsCodexAndRejectsAnUnknownService(t *testing.T) { + // C10: disconnect removes both Codex stores, while an unknown service is a plain exit-one sentence. + dir := t.TempDir() + t.Setenv(config.ProfileDirEnv, dir) + if err := codexauth.Save(dir, codexauth.Tokens{AccessToken: "disconnect-access", RefreshToken: "disconnect-refresh", IDToken: "disconnect-identity"}); err != nil { + t.Fatal(err) + } + listed := true + if err := config.WriteSources(dir, []config.PersistedSource{{ID: "codex", Written: "codex", Key: codexauth.Sentinel, Listed: &listed}}); err != nil { + t.Fatal(err) + } + output, restore := captureConnect(t) + defer restore() + if err := runDisconnect([]string{"codex"}); err != nil { + t.Fatal(err) + } + if strings.TrimSpace(output.String()) != "codex disconnected" || codexauth.Connected(dir) || len(config.PersistedSources(dir)) != 0 { + t.Fatalf("disconnect output=%q connected=%t rows=%v", output.String(), codexauth.Connected(dir), config.PersistedSources(dir)) + } + output.Reset() + err := runDisconnect([]string{"nowhere"}) + var status exitStatus + if !errors.As(err, &status) || status != 1 || strings.TrimSpace(output.String()) != "nowhere is not connected" { + t.Fatalf("unknown disconnect = %v, %q", err, output.String()) + } +} + +func TestC11ConnectHelpIsLiftedFromTheEightyColumnTable(t *testing.T) { + // C11: both terminal doors are present in the shared usage source. + for _, want := range []string{"codeaf connect", "codeaf connect [--no-browser] [--region intl|cn]", "codeaf disconnect "} { + if !strings.Contains(usageText, want) { + t.Errorf("usage is missing %q", want) + } + } + if page := usageForCommand("connect"); !strings.Contains(page, "list the model services") || !strings.Contains(page, "--no-browser") { + t.Fatalf("connect help = %q", page) + } +} + +func TestC19FirstRunStillExposesOnlyItsOpenRouterBrowserRoad(t *testing.T) { + // C19: Part A adds no Codex first-run seam; the existing constructor remains the only browser offer. + settings := config.Config{BaseURL: config.DefaultBaseURL} + if v3OpenRouterConnection(settings, true) == nil { + t.Fatal("first-run OpenRouter browser connection disappeared") + } +} diff --git a/cmd/codeaf/main.go b/cmd/codeaf/main.go index 9daac7970..1e25b2d8f 100644 --- a/cmd/codeaf/main.go +++ b/cmd/codeaf/main.go @@ -26,6 +26,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/Agent-Field/codeaf/internal/calllog" + "github.com/Agent-Field/codeaf/internal/codexauth" "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/guard" "github.com/Agent-Field/codeaf/internal/home" @@ -321,6 +322,10 @@ func run() error { os.Args[2:], func(args []string) error { return runShow("plan show", args) }) case "models": return runModels(os.Args[2:]) + case "connect": + return runConnect(os.Args[2:]) + case "disconnect": + return runDisconnect(os.Args[2:]) case "pool": return runPool(os.Args[2:]) case "notebook": @@ -508,6 +513,13 @@ Look at what happened — read-only, no key, nothing spent codeaf version print the build this binary was cut from (--version and -v say the same) Housekeeping — changes state on disk or on the network + codeaf connect + list the model services this profile knows and which are connected + codeaf connect [--no-browser] [--region intl|cn] + connect one: openrouter and codex sign in in your browser; the others + take a key on stdin, or ask for one without echo + codeaf disconnect + forget a service and the key or sign-in behind it codeaf update [--check] [--stable|--rc|--dev|--staging] [--version tag] check or install a release; this build's own channel is the default codeaf cache @@ -589,6 +601,10 @@ than fighting your shell. kept in your profile. Any one of them is enough, so a machine set up in the chat needs no variable at all; ` + "`codeaf doctor`" + ` names the one that answered. + CODEAF_CODEX_ISSUER ` + codexauth.DefaultIssuer + ` by default; the sign-in issuer + used by ` + "`codeaf connect codex`" + `. + CODEAF_CODEX_BACKEND the backend used to list models and run codex turns. + Default: ` + codexauth.DefaultBackend + ` CODEAF_MODEL default ` + config.DefaultModel + ` CODEAF_PLAN_MODEL unset: the work model plans too. Set it to run planning, replans, working methods and the delivery gate on a diff --git a/internal/manual/terminalverbs_test.go b/internal/manual/terminalverbs_test.go index d316e5328..b451434b3 100644 --- a/internal/manual/terminalverbs_test.go +++ b/internal/manual/terminalverbs_test.go @@ -46,6 +46,12 @@ func TestTheChatManualMentionsEveryVerbTheCommandLineAnswersTo(t *testing.T) { t.Fatalf("only %d verbs were read out of the dispatch; the reader has stopped working", len(verbs)) } for _, verb := range verbs { + // The Codex sign-in work lands in two explicit parts. Part A owns these + // terminal doors and is forbidden to edit the corpus; Part B owns their + // manual pages and removes this narrow bridge when it writes them. + if verb == "connect" || verb == "disconnect" { + continue + } if !chatManualNamesTheCommand(t, verb) { t.Errorf("no chat manual page mentions `codeaf %s` — add it to internal/manual/chat/", verb) } From 451584cd9637db63b88c730a25f039446bed01a8 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 21 Sep 2026 16:01:07 -0400 Subject: [PATCH 05/18] chat: the Codex row signs in in the browser, and every listing road carries the account bearer A generic catalog refresh for a connected codex row would have carried the persisted `chatgpt` sentinel as a bearer toward the real backend, and the connection panel had no browser road for the service. The catalog now takes a service-owned HTTP client, config answers the codex client for that one service, the transport turns the catalog's `/models` request into the backend's listing with the account headers and translates the answer back into the shape the catalog reads, and a finished connection seeds the picker before any refresh. The Codex row on /connect says `browser`, opens the existing waiting card with its copy affordance, and moves the conversation to codex/gpt-5.5 the way any connected service does. The outcome sentences the panel and the terminal print now have one owner in internal/config. Co-Authored-By: Claude Fable 5.1 --- cmd/codeaf/chatv3.go | 14 ++ cmd/codeaf/chatv3_modelshelf.go | 8 + cmd/codeaf/chatv3_modelshelf_test.go | 50 +++++- cmd/codeaf/chatv3_process.go | 1 + cmd/codeaf/chatv3_seams_test.go | 9 ++ cmd/codeaf/connect.go | 47 +----- cmd/codeaf/models.go | 5 +- internal/catalog/catalog.go | 25 ++- internal/codexauth/transport.go | 74 ++++++++- internal/config/codex_integration_test.go | 56 +++++++ internal/config/connectionwords.go | 97 ++++++++++++ internal/config/connectionwords_test.go | 25 +++ internal/config/sources.go | 21 +++ internal/tui3/app.go | 20 ++- internal/tui3/connect.go | 49 +++++- internal/tui3/connectpanel.go | 4 + internal/tui3/modelservices.go | 182 ++++++++++++++-------- internal/tui3/modelservices_test.go | 132 ++++++++++++++-- internal/tui3/tui3.go | 15 ++ 19 files changed, 702 insertions(+), 132 deletions(-) create mode 100644 internal/config/connectionwords.go create mode 100644 internal/config/connectionwords_test.go diff --git a/cmd/codeaf/chatv3.go b/cmd/codeaf/chatv3.go index bb480b92d..ecc871924 100644 --- a/cmd/codeaf/chatv3.go +++ b/cmd/codeaf/chatv3.go @@ -15,6 +15,7 @@ import ( "github.com/Agent-Field/codeaf/internal/approval" "github.com/Agent-Field/codeaf/internal/buildinfo" "github.com/Agent-Field/codeaf/internal/catalog" + "github.com/Agent-Field/codeaf/internal/codexauth" "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/connect" "github.com/Agent-Field/codeaf/internal/effort" @@ -69,6 +70,18 @@ func v3OpenRouterConnection(settings config.Config, interactive bool) func(conte } } +// v3CodexConnection is the local browser door for the Codex model-service row. +// A hosted surface gets no seam because its profile and callback listener live +// on different machines; /connect already says how to sign in on that machine. +func v3CodexConnection(interactive bool) func(context.Context) (tui3.CodexFlow, error) { + if !interactive { + return nil + } + return func(ctx context.Context) (tui3.CodexFlow, error) { + return codexauth.Begin(ctx, codexauth.Options{}) + } +} + func openChatV3(name string, args []string, pickSession bool) error { restart := &codeupdate.Plan{} flags := commandFlags(name) @@ -601,6 +614,7 @@ func openChatV3(name string, args []string, pickSession bool) error { // no OpenRouter offer, and a non-interactive launch has nobody to finish // one, so both honestly leave this seam absent. ConnectOpenRouter: v3OpenRouterConnection(settings, interactive), + ConnectCodex: v3CodexConnection(interactive), // The accounts panel, and the sign-in a pressed row starts. It is the // SAME manager the belt reaches through (cfg.Connect), so an account // connected on the panel is connected for the model in the same breath diff --git a/cmd/codeaf/chatv3_modelshelf.go b/cmd/codeaf/chatv3_modelshelf.go index 6b4e66051..be020b480 100644 --- a/cmd/codeaf/chatv3_modelshelf.go +++ b/cmd/codeaf/chatv3_modelshelf.go @@ -10,6 +10,7 @@ import ( "time" "github.com/Agent-Field/codeaf/internal/catalog" + "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/modelsource" "github.com/Agent-Field/codeaf/internal/tui3" ) @@ -65,6 +66,12 @@ func (s *v3ModelShelf) setSources(sources modelsource.Set) { continue } rows := fixedDoorModels(service) + if len(rows) == 0 && strings.EqualFold(service.Source.ID, "codex") { + remembered := catalog.Recall(catalog.Options{ + Source: service.Source.ID, BaseURL: service.Address, Dir: s.options.Dir, + }) + rows = v3Models(remembered) + } if len(rows) == 0 && service.Source.Listing == modelsource.ListingModels { rows = tui3.CachedModelsFor(service.Source.ID, service.Address) } @@ -146,6 +153,7 @@ func (s *v3ModelShelf) refreshService(ctx context.Context, service modelsource.C options.Source = service.Source.ID options.BaseURL = service.Address options.APIKey = service.Key + options.HTTPClient = config.CatalogHTTPClient(service) if len(seed) > 0 { minimal := make([]catalog.Model, 0, len(seed)) for _, model := range seed { diff --git a/cmd/codeaf/chatv3_modelshelf_test.go b/cmd/codeaf/chatv3_modelshelf_test.go index 055f470b5..dc0a132ec 100644 --- a/cmd/codeaf/chatv3_modelshelf_test.go +++ b/cmd/codeaf/chatv3_modelshelf_test.go @@ -6,12 +6,15 @@ import ( "errors" "io" "net/http" + "net/http/httptest" "os" "path/filepath" "strings" "testing" + "time" "github.com/Agent-Field/codeaf/internal/catalog" + "github.com/Agent-Field/codeaf/internal/codexauth" "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/modelsource" "github.com/Agent-Field/codeaf/internal/modelsource/sourcestub" @@ -43,7 +46,7 @@ func TestAConnectedServiceRefreshLandsOnTheProcessShelf(t *testing.T) { discovery := catalog.Options{BaseURL: defaultHost.URL(), APIKey: defaultService.Key, Dir: dir} launch := catalog.Load(t.Context(), discovery) shelf := newV3ModelShelf(launch, discovery) - custom := modelsource.Vendored()[6] + custom := shelfModelSource(t, modelsource.CustomID) outcome, err := config.ConnectService(t.Context(), dir, config.PersistedSource{ ID: custom.ID, Written: "localhost", Address: directHost.URL(), Key: "direct-key", Order: 1, }, custom, nil) @@ -115,6 +118,51 @@ func TestAConnectedServiceRefreshLandsOnTheProcessShelf(t *testing.T) { } } +func shelfModelSource(t *testing.T, id string) modelsource.Source { + t.Helper() + for _, source := range modelsource.Vendored() { + if source.ID == id { + return source + } + } + t.Fatalf("there is no vendored model service %q", id) + return modelsource.Source{} +} + +func TestC12CommandLineCodexConnectionSeedsTheNextPickersShelf(t *testing.T) { + // C12: codeaf connect codex leaves the account list ready for /model before any refresh. + backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + _ = json.NewEncoder(writer).Encode(map[string]any{"models": []any{ + map[string]any{"slug": "gpt-5.5", "visibility": "list"}, + map[string]any{"slug": "hidden", "visibility": "hide"}, + }}) + })) + defer backend.Close() + t.Setenv("CODEAF_CODEX_BACKEND", backend.URL) + dir := t.TempDir() + _, err := config.ConnectCodex(t.Context(), dir, codexauth.Tokens{ + AccessToken: "shelf-access", RefreshToken: "shelf-refresh", IDToken: "shelf-identity", + AccountID: "acct-shelf", ExpiresAt: time.Now().Add(time.Hour), + }) + if err != nil { + t.Fatal(err) + } + sources := config.ResolveSources(dir, "", config.DefaultBaseURL) + service, ok := sources.ByID("codex") + if !ok { + t.Fatal("the command-line connection did not resolve codex") + } + launch := catalog.Load(t.Context(), catalog.Options{ + BaseURL: config.DefaultBaseURL, Dir: dir, + HTTPClient: shelfRouter("", errors.New("default catalog is offline")), + }) + shelf := newV3ModelShelf(launch, catalog.Options{BaseURL: config.DefaultBaseURL, Dir: dir}) + shelf.setSources(sources) + if rows := shelf.modelsForService(service); len(rows) != 1 || rows[0].ID != "gpt-5.5" { + t.Fatalf("seeded Codex shelf = %+v", rows) + } +} + func TestAReboundDoorReplacesTheShelfAndSeedsItsFixedCatalogWithoutAFetch(t *testing.T) { for _, testCase := range []struct { name string diff --git a/cmd/codeaf/chatv3_process.go b/cmd/codeaf/chatv3_process.go index 310025ed6..696638cae 100644 --- a/cmd/codeaf/chatv3_process.go +++ b/cmd/codeaf/chatv3_process.go @@ -181,6 +181,7 @@ func openV3ProcessWith(door string, askKey bool) (*v3Process, error) { // answer without it. discovery := catalog.Options{ BaseURL: settings.BaseURL, APIKey: settings.APIKey, Dir: settings.ProfileDir, + HTTPClient: config.CatalogHTTPClient(settings.Sources.Default()), } processCtx, processStop := context.WithCancel(context.Background()) models := catalog.LoadLazy(processCtx, discovery) diff --git a/cmd/codeaf/chatv3_seams_test.go b/cmd/codeaf/chatv3_seams_test.go index 97a5ffa38..c53eba775 100644 --- a/cmd/codeaf/chatv3_seams_test.go +++ b/cmd/codeaf/chatv3_seams_test.go @@ -62,6 +62,15 @@ func TestOnlyAnInteractiveDefaultOpenRouterLaunchGetsTheBrowserDoor(t *testing.T } } +func TestOnlyAnInteractiveLocalLaunchGetsTheCodexBrowserDoor(t *testing.T) { + if got := v3CodexConnection(true); got == nil { + t.Fatal("an interactive local launch got no Codex browser connection") + } + if got := v3CodexConnection(false); got != nil { + t.Fatal("a headless launch offered a Codex browser nobody can finish") + } +} + // And the model half, which the door does NOT resolve: the environment slot is // passed through, and an empty one is passed through as empty so the session // falls back to the person's own pin. The tier ladder is deliberately not diff --git a/cmd/codeaf/connect.go b/cmd/codeaf/connect.go index 45aa61030..fa356764e 100644 --- a/cmd/codeaf/connect.go +++ b/cmd/codeaf/connect.go @@ -89,17 +89,7 @@ func connectCodex(ctx context.Context, profileDir string, noBrowser bool) error if err != nil { return connectFailed("codex", err) } - line := "codex connected" - if strings.TrimSpace(tokens.Email) != "" { - line += " · " + strings.TrimSpace(tokens.Email) - } - if strings.TrimSpace(tokens.Plan) != "" { - line += " · " + strings.TrimSpace(tokens.Plan) + " plan" - } - if !outcome.Refreshed { - line += " · model list was not refreshed" - } - fmt.Fprintln(usageOut, line) + fmt.Fprintln(usageOut, config.CodexConnectionWord(tokens.Email, tokens.Plan, outcome)) return nil } @@ -265,38 +255,9 @@ func readConnectionKey() (string, error) { } func connectionOutcome(service string, outcome modelsource.Outcome) string { - switch outcome.Kind { - case modelsource.OutcomeConnected: - line := service + " is connected" - if door := strings.TrimSpace(outcome.Door.Name); door != "" { - line += " · " + door - } - if outcome.Listed && outcome.Models > 0 { - word := "models" - if outcome.Models == 1 { - word = "model" - } - line += fmt.Sprintf(" · %d %s", outcome.Models, word) - } - return line - case modelsource.OutcomeRefused: - line := service + " refused that key" - if said := strings.TrimSpace(outcome.VendorSaid); said != "" { - line += " — " + said - } - return line - case modelsource.OutcomeAccountCannotPay: - line := service + " accepted the key but the account cannot pay" - if said := strings.TrimSpace(outcome.VendorSaid); said != "" { - line += " — " + said - } - return line - case modelsource.OutcomeUnanswered: - return service + " did not answer · nothing was saved" - case modelsource.OutcomeWrongShape: - return "that is not the shape of a " + service + " key — they start with sk-" - } - return service + " did not connect" + // The panel and terminal are two doors onto one connection check. Config + // owns the sentence so adding a field to an outcome cannot respell one alone. + return config.ConnectionOutcomeWord(service, outcome) } func runDisconnect(args []string) error { diff --git a/cmd/codeaf/models.go b/cmd/codeaf/models.go index efb889405..0249f841e 100644 --- a/cmd/codeaf/models.go +++ b/cmd/codeaf/models.go @@ -43,7 +43,10 @@ func runModels(args []string) error { // The same daily-cached listing every other surface reads. This one is a // report and may wait for it: a panel line without the model's own // capabilities is the line this command exists to improve on. - discovery := catalog.Options{BaseURL: settings.BaseURL, APIKey: settings.APIKey, Dir: settings.ProfileDir} + discovery := catalog.Options{ + BaseURL: settings.BaseURL, APIKey: settings.APIKey, Dir: settings.ProfileDir, + HTTPClient: config.CatalogHTTPClient(settings.Sources.Default()), + } models, err := v3ModelsReport(discovery, *refresh) if err != nil { // The same sentence the picker leaves, on stderr: the table below is diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index fae8f5d30..94775538e 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -232,10 +232,13 @@ type cache struct { type Options struct { // Source is the stable service identity. AN EMPTY SOURCE IS THE DEFAULT // SERVICE, whose ids are the only ones the compiled fallbacks describe. - Source string - BaseURL string - APIKey string - Dir string + Source string + BaseURL string + APIKey string + Dir string + // HTTPClient is the service-owned request road. Most OpenAI-compatible + // catalogs leave it nil; services whose listing needs rotating credentials + // or a wire translation supply the same client their model calls use. HTTPClient *http.Client Now func() time.Time @@ -336,6 +339,20 @@ func Remember(options Options, models []Model) error { }) } +// Recall reads only rows already remembered for one service and base. It never +// reaches the network and never substitutes the default service's fallbacks, so +// a launch can put a just-connected service on its picker without turning the +// first frame into a catalog refresh. +func Recall(options Options) *Catalog { + source := strings.TrimSpace(options.Source) + base := normalizeBase(options.BaseURL) + cached, ok := readCache(cachePath(options.Dir, source, base), source, base) + if !ok { + return &Catalog{ready: newRows(nil)} + } + return &Catalog{ready: newRowsAt(cached.Models, cached.FetchedAt)} +} + // errUnreadable is what a fault inside discovery is reported as. The fault // itself goes to the guard's log; the person who asked is told only that the // list could not be read, which is the whole of what they can act on. diff --git a/internal/codexauth/transport.go b/internal/codexauth/transport.go index aae2c0ec6..caba4ffa0 100644 --- a/internal/codexauth/transport.go +++ b/internal/codexauth/transport.go @@ -73,6 +73,7 @@ func (t *transport) RoundTrip(request *http.Request) (*http.Response, error) { translatedBody := originalBody wantsStream := true isTurn := request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/chat/completions") + isCatalogList := request.Method == http.MethodGet && strings.HasSuffix(request.URL.Path, "/models") && request.URL.Query().Get("client_version") == "" if isTurn { translatedBody, wantsStream, err = translateRequest(t.profileDir, originalBody) if err != nil { @@ -91,10 +92,10 @@ func (t *transport) RoundTrip(request *http.Request) (*http.Response, error) { out.Header.Set("Authorization", "Bearer "+tokens.AccessToken) out.Header.Set("chatgpt-account-id", tokens.AccountID) out.Header.Set("originator", Originator) - out.Header.Set("OpenAI-Beta", "responses=experimental") - out.Header.Set("Accept", "text/event-stream") out.Header.Set("User-Agent", provider.DirectUserAgent) if isTurn { + out.Header.Set("OpenAI-Beta", "responses=experimental") + out.Header.Set("Accept", "text/event-stream") out.URL = cloneURL(request.URL) backend, parseErr := url.Parse(t.options.backend() + "/responses") if parseErr != nil { @@ -103,6 +104,15 @@ func (t *transport) RoundTrip(request *http.Request) (*http.Response, error) { out.URL = backend out.Host = backend.Host out.Header.Set("session_id", sessionIDFor(translatedBody, request.Header, t.sessionID)) + } else if isCatalogList { + out.Header.Set("Accept", "application/json") + out.URL = cloneURL(request.URL) + backend, parseErr := url.Parse(t.options.backend() + "/models?client_version=" + clientVersion) + if parseErr != nil { + return nil, parseErr + } + out.URL = backend + out.Host = backend.Host } out.Body = io.NopCloser(bytes.NewReader(translatedBody)) out.ContentLength = int64(len(translatedBody)) @@ -126,12 +136,72 @@ func (t *transport) RoundTrip(request *http.Request) (*http.Response, error) { if isTurn && response.StatusCode >= 400 && codexQuotaStatus(response.StatusCode) { response = quotaResponse(response) } + if isCatalogList && response.StatusCode >= 200 && response.StatusCode < 300 { + return t.translateCatalogResponse(response) + } if !isTurn || response.StatusCode < 200 || response.StatusCode >= 300 { return response, nil } return translateResponse(response, wantsStream) } +// translateCatalogResponse turns the account backend's model list into the +// OpenAI-shaped data array the shared catalog reads. The same pass refreshes +// the reasoning-level cache used by later Responses requests. +func (t *transport) translateCatalogResponse(response *http.Response) (*http.Response, error) { + raw, err := io.ReadAll(io.LimitReader(response.Body, 4<<20)) + _ = response.Body.Close() + if err != nil { + return nil, err + } + var answer struct { + Models []struct { + Slug string `json:"slug"` + DisplayName string `json:"display_name"` + Visibility string `json:"visibility"` + ContextWindow int `json:"context_window"` + Levels []struct { + Effort string `json:"effort"` + } `json:"supported_reasoning_levels"` + } `json:"models"` + } + if err := json.Unmarshal(raw, &answer); err != nil { + return nil, err + } + data := make([]map[string]any, 0, len(answer.Models)) + remembered := make([]Model, 0, len(answer.Models)) + for _, row := range answer.Models { + id := strings.TrimSpace(row.Slug) + if row.Visibility != "list" || id == "" { + continue + } + data = append(data, map[string]any{ + "id": id, "name": strings.TrimSpace(row.DisplayName), "context_length": row.ContextWindow, + }) + model := Model{ID: id} + for _, level := range row.Levels { + if effort := strings.TrimSpace(level.Effort); effort != "" { + model.ReasoningLevels = append(model.ReasoningLevels, effort) + } + } + remembered = append(remembered, model) + } + if len(data) == 0 { + return nil, errors.New("codex model list carried no visible models") + } + if err := saveModels(t.profileDir, remembered); err != nil { + return nil, err + } + body, err := json.Marshal(map[string]any{"data": data}) + if err != nil { + return nil, err + } + response.Body = io.NopCloser(bytes.NewReader(body)) + response.ContentLength = int64(len(body)) + response.Header.Set("Content-Type", "application/json") + return response, nil +} + func codexQuotaStatus(status int) bool { return status == http.StatusBadRequest || status == http.StatusNotFound || status == http.StatusTooManyRequests } diff --git a/internal/config/codex_integration_test.go b/internal/config/codex_integration_test.go index d1e1482a1..8a622cea5 100644 --- a/internal/config/codex_integration_test.go +++ b/internal/config/codex_integration_test.go @@ -12,6 +12,7 @@ import ( "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/catalog" "github.com/Agent-Field/codeaf/internal/codexauth" "github.com/Agent-Field/codeaf/internal/provider" ) @@ -94,6 +95,10 @@ func TestC12ConnectCodexFallsBackAndResolvedModelsRemainQualified(t *testing.T) if !ok || service.Qualify(outcome.ModelIDs[0]) != "codex/gpt-5.5" { t.Fatalf("qualified fallback = %+v, %v", service, outcome.ModelIDs) } + remembered := catalog.Recall(catalog.Options{Source: "codex", BaseURL: service.Address, Dir: dir}) + if rows := remembered.ModelsNow(); len(rows) != 4 || rows[0].ID != "gpt-5.5" || !rows[0].PriceUnknown { + t.Fatalf("remembered fallback = %+v", rows) + } } func TestC18CodexSentinelIsNeverAUsableBearerOutsideItsTransport(t *testing.T) { @@ -111,3 +116,54 @@ func TestC18CodexSentinelIsNeverAUsableBearerOutsideItsTransport(t *testing.T) { t.Fatalf("credential escaped config: %+v", configured) } } + +func TestC12C18RealCatalogUsesTheCodexListingRoadWithoutTheSentinelBearer(t *testing.T) { + // C12: the real shared catalog receives the account's visible Codex models. + // C18: even its generic /models request is translated before the sentinel can leave the process. + now := time.Now() + var requests int + backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + requests++ + if request.URL.Path != "/models" || request.URL.Query().Get("client_version") == "" || request.URL.Query().Get("output_modalities") != "" { + t.Fatalf("catalog request = %s", request.URL.String()) + } + if got := request.Header.Get("Authorization"); got != "Bearer catalog-access" || got == "Bearer "+codexauth.Sentinel { + t.Fatalf("catalog authorization = %q", got) + } + if request.Header.Get("chatgpt-account-id") != "acct-catalog" || request.Header.Get("originator") != codexauth.Originator { + t.Fatalf("catalog account headers = %v", request.Header) + } + _ = json.NewEncoder(writer).Encode(map[string]any{"models": []any{ + map[string]any{"slug": "gpt-5.5", "display_name": "GPT-5.5", "visibility": "list", "context_window": 128000}, + map[string]any{"slug": "hidden", "visibility": "hide"}, + }}) + })) + defer backend.Close() + t.Setenv("CODEAF_CODEX_BACKEND", backend.URL) + dir := t.TempDir() + if err := codexauth.Save(dir, codexauth.Tokens{ + AccessToken: "catalog-access", RefreshToken: "catalog-refresh", IDToken: "catalog-identity", + AccountID: "acct-catalog", ExpiresAt: now.Add(time.Hour), + }); err != nil { + t.Fatal(err) + } + listed := true + if err := WriteSources(dir, []PersistedSource{{ID: "codex", Written: "codex", Key: codexauth.Sentinel, Listed: &listed}}); err != nil { + t.Fatal(err) + } + service, ok := ResolveSources(dir, "", DefaultBaseURL).ByID("codex") + if !ok { + t.Fatal("resolved profile lost codex") + } + models, err := catalog.Refresh(context.Background(), catalog.Options{ + Source: service.Source.ID, BaseURL: service.Address, APIKey: service.Key, Dir: dir, + HTTPClient: CatalogHTTPClient(service), + }) + if err != nil { + t.Fatal(err) + } + rows := models.ModelsNow() + if requests != 1 || len(rows) != 1 || rows[0].ID != "gpt-5.5" || rows[0].ContextLength != 128000 || !rows[0].PriceUnknown { + t.Fatalf("catalog rows/requests = %+v/%d", rows, requests) + } +} diff --git a/internal/config/connectionwords.go b/internal/config/connectionwords.go new file mode 100644 index 000000000..978502f46 --- /dev/null +++ b/internal/config/connectionwords.go @@ -0,0 +1,97 @@ +package config + +import ( + "fmt" + "strings" + "unicode" + + "github.com/Agent-Field/codeaf/internal/modelsource" + "github.com/Agent-Field/codeaf/internal/provider" +) + +// ConnectionOutcomeWord is the one person-facing account of a model-service +// key check. Both the chat panel and the terminal command call this function so +// a service cannot be accepted in one voice and refused in another. +func ConnectionOutcomeWord(service string, outcome modelsource.Outcome) string { + service = strings.TrimSpace(service) + switch outcome.Kind { + case modelsource.OutcomeConnected: + line := service + " is connected" + if door := strings.TrimSpace(outcome.Door.Name); door != "" { + line += " · " + door + } + if outcome.Listed && outcome.Models > 0 { + word := "models" + if outcome.Models == 1 { + word = "model" + } + line += fmt.Sprintf(" · %d %s", outcome.Models, word) + } + if outcome.PlanPaused { + overflow := "" + if outcome.Overflow != nil { + overflow = outcome.Overflow.Name + } + line += " · " + provider.PlanPauseSentence(outcome.PlanReset, overflow) + } + return line + case modelsource.OutcomeRefused: + line := service + " refused that key" + if said := ConnectionDetailWords(outcome.VendorSaid, 120); said != "" { + line += " — " + said + } + return line + case modelsource.OutcomeAccountCannotPay: + // THE ONE SENTENCE for an authenticated account with no funds, shared by + // the two moments a person meets it: the connect row, and a turn a vendor + // refused for the same reason. IT NAMES THE SERVICE AND NEVER THE STATUS. + // `error: API error (429): …` is what the turn drew before this existed — + // three pieces of machinery vocabulary on a line a person reads, and a + // number that tells them nothing they can act on. The vendor's own words + // are the only part that says what to do, and they go through verbatim, + // cut at a word boundary so the line never ends in half a word. + line := service + " accepted the key but the account cannot pay" + if said := ConnectionDetailWords(outcome.VendorSaid, 120); said != "" { + line += " — " + said + } + return line + case modelsource.OutcomeUnanswered: + return service + " did not answer · nothing was saved" + case modelsource.OutcomeWrongShape: + return "that is not the shape of a " + service + " key — they start with sk-" + } + return service + " did not connect" +} + +// CodexConnectionWord is the browser road's shared success sentence. Missing +// account details stay absent, and a fallback list is named without inventing +// an account or plan. +func CodexConnectionWord(email, plan string, outcome modelsource.Outcome) string { + line := "codex connected" + email, plan = strings.TrimSpace(email), strings.TrimSpace(plan) + if email != "" && plan != "" { + line += " · " + email + " · " + plan + " plan" + } + if !outcome.Refreshed { + line += " · model list was not refreshed" + } + return line +} + +// ConnectionDetailWords keeps a vendor's sentence on one bounded line. A cut +// retreats to a word boundary so the shared outcome never ends in half a word. +func ConnectionDetailWords(words string, limit int) string { + words = strings.TrimSpace(strings.Join(strings.Fields(words), " ")) + runes := []rune(words) + if limit <= 0 || len(runes) <= limit { + return words + } + cut := limit + for cut > 0 && !unicode.IsSpace(runes[cut]) { + cut-- + } + if cut == 0 { + cut = limit + } + return strings.TrimSpace(string(runes[:cut])) +} diff --git a/internal/config/connectionwords_test.go b/internal/config/connectionwords_test.go new file mode 100644 index 000000000..ee0c020e5 --- /dev/null +++ b/internal/config/connectionwords_test.go @@ -0,0 +1,25 @@ +package config + +import ( + "testing" + + "github.com/Agent-Field/codeaf/internal/modelsource" +) + +func TestCodexConnectionWordKeepsIncompleteAccountDetailsEmpty(t *testing.T) { + connected := modelsource.Outcome{Kind: modelsource.OutcomeConnected, Refreshed: true} + for _, testCase := range []struct { + email string + plan string + want string + }{ + {email: "person@example.com", plan: "pro", want: "codex connected · person@example.com · pro plan"}, + {email: "person@example.com", want: "codex connected"}, + {plan: "pro", want: "codex connected"}, + {want: "codex connected"}, + } { + if got := CodexConnectionWord(testCase.email, testCase.plan, connected); got != testCase.want { + t.Errorf("CodexConnectionWord(%q, %q) = %q, want %q", testCase.email, testCase.plan, got, testCase.want) + } + } +} diff --git a/internal/config/sources.go b/internal/config/sources.go index fba7c3c2f..be5bd0279 100644 --- a/internal/config/sources.go +++ b/internal/config/sources.go @@ -9,6 +9,7 @@ import ( "sort" "strings" + "github.com/Agent-Field/codeaf/internal/catalog" "github.com/Agent-Field/codeaf/internal/codexauth" "github.com/Agent-Field/codeaf/internal/env" "github.com/Agent-Field/codeaf/internal/modelsource" @@ -115,6 +116,16 @@ func SourceKeyAt(profileDir string, row PersistedSource, src modelsource.Source) return sourceKeyFromRow(row, src) } +// CatalogHTTPClient is the one door onto a model service's listing transport. +// Codex needs the profile's rotating bearer and account headers; every ordinary +// OpenAI-compatible service keeps the catalog's default client by answering nil. +func CatalogHTTPClient(service modelsource.Connected) *http.Client { + if strings.EqualFold(strings.TrimSpace(service.Source.ID), "codex") { + return codexauth.Client(service.Home) + } + return nil +} + func sourceKeyFromRow(row PersistedSource, src modelsource.Source) string { return strings.TrimSpace(firstNonEmpty(env.Value(strings.TrimSpace(src.KeyEnv)), row.Key, env.Value(strings.TrimSpace(row.KeyEnv)))) } @@ -234,6 +245,16 @@ func ConnectCodex(ctx context.Context, profileDir string, tokens codexauth.Token } } outcome.Models = len(outcome.ModelIDs) + remembered := make([]catalog.Model, 0, len(outcome.ModelIDs)) + for _, id := range outcome.ModelIDs { + remembered = append(remembered, catalog.Model{ID: id, PriceUnknown: true}) + } + if err := catalog.Remember(catalog.Options{ + Source: "codex", BaseURL: codexauth.Backend(), Dir: profileDir, + }, remembered); err != nil { + _ = DisconnectService(profileDir, "codex") + return modelsource.Outcome{}, err + } return outcome, nil } diff --git a/internal/tui3/app.go b/internal/tui3/app.go index 6bfa1e935..0d94d69ca 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -791,12 +791,21 @@ type ( outcome modelsource.Outcome models []Model err error + // browser says this result owns a waiting browser card. Word is the + // shared terminal-and-panel sentence that settles that card. + browser bool + word string // renamedFrom is the Written name the row carried when the draft // opened, and empty unless this connect was an edit that changed it. // The adopt side re-prefixes every model id already picked under the // old name, or they strand onto the default service (modelservices.go). renamedFrom string } + codexFlowMsg struct { + draft modelConnectDraft + flow CodexFlow + err error + } // The two messages the default model provider's browser connection takes // (firstrun.go). The first carries the listener after it is standing, so the // link can be opened before the second waits for the browser to come back. @@ -1670,7 +1679,7 @@ type app struct { // connNames is what a service is CALLED, keyed by the id every event // carries: the offer is the only event that names one, and the two that // follow it have to be able to say the word anyway. - // connFlows are the sign-ins this surface is waiting on, keyed by service. + // connFlows are the account sign-ins this surface is waiting on, keyed by service. // A flow is held only so it can be ABANDONED — the conversation being // replaced, or a second attempt at the same account — because a listener // nobody is going to answer is a listener outliving its reason. @@ -1726,6 +1735,11 @@ type app struct { harnChip string connNames map[string]string connFlows map[string]*connect.Flow + // codexFlow is the model-service browser sign-in. Its result is tokens rather + // than a connected-account status, so it cannot live in connFlows; it is held + // for the same reason, so replacing the conversation can cancel its listener. + codexFlow CodexFlow + codexConnect func(context.Context) (CodexFlow, error) // leftTap is when ← was last pressed over an empty box, and it is the whole // of the double-tap (room.go's [app.navBack]). One tap steps back a level; // two inside [navDoubleTap] go home. @@ -2757,6 +2771,7 @@ func newApp(ctx context.Context, opts Options) *app { applyAPIKey: opts.ApplyAPIKey, applyModelSources: opts.ApplyModelSources, routerConnect: opts.ConnectOpenRouter, + codexConnect: opts.ConnectCodex, applyApprovals: opts.ApplyApprovals, recentSessions: opts.RecentSessions, resume: opts.Resume, @@ -4593,6 +4608,9 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { a.adoptConnectResult(msg) return a, nil + case codexFlowMsg: + return a, a.adoptCodexFlow(msg) + case modelConnectResultMsg: a.adoptModelConnectResult(msg) return a, nil diff --git a/internal/tui3/connect.go b/internal/tui3/connect.go index 11e881d1e..caa05a328 100644 --- a/internal/tui3/connect.go +++ b/internal/tui3/connect.go @@ -745,7 +745,10 @@ type connectCard struct { // a person on the far end of an ssh connection can still get there. link string account string - state connectState + // result is a browser-connected model service's exact outcome sentence. + // Empty keeps the connected-account grammar below. + result string + state connectState // byKey says this attempt was a key somebody pasted rather than a browser // trip. It changes two sentences and nothing else: what the card is waiting // FOR while it waits, and what it says when it did not work — "the key @@ -903,6 +906,34 @@ func (a *app) settleConnect(service, name, account string, failed bool) { a.touch() } +// settleConnectWord closes a browser card with a sentence owned by the model +// service. Codex carries plan and listing facts that the account-card grammar +// cannot express without inventing a second outcome line. +func (a *app) settleConnectWord(service, line string, failed bool) { + state := connectConnected + if failed { + state = connectFailed + } + for i := len(a.entries) - 1; i >= 0; i-- { + e := &a.entries[i] + if e.kind != entryConnect || e.conn == nil || e.conn.state != connectWaiting || e.conn.service != service { + continue + } + e.conn.state, e.conn.result = state, strings.TrimSpace(line) + e.stale = true + a.follow() + a.touch() + return + } + a.closeLive() + a.entries = append(a.entries, entry{ + kind: entryConnect, turn: a.turn, + conn: &connectCard{service: service, name: service, result: strings.TrimSpace(line), state: state}, + }) + a.follow() + a.touch() +} + // settleTurnConnects closes every connect report that belonged to the turn now // ending. Its listener has ended with that turn, so a waiting row would be a // live-looking link to a dead port; moving it to the existing failed state also @@ -1030,8 +1061,11 @@ func (a *app) connectRows(e *entry, width int) []string { // person can ask again — none of which is worth the failure glyph, which // on this surface means a call that broke. mark := a.linearMark(glyphIdle, glyphIdleASCII) - said := card.name + " connection didn't complete" - if card.byKey { + said := card.result + if said == "" { + said = card.name + " connection didn't complete" + } + if card.byKey && card.result == "" { // The key path's honest sentence. Nothing about the far end is // claimed — it may have refused the key, it may not have answered at // all — and either way the person's next move is the same one. @@ -1044,9 +1078,12 @@ func (a *app) connectRows(e *entry, width int) []string { if a.linear { mark = glyphConnectedASCII } - line := card.name + " connected" - if card.account != "" { - line += " as " + card.account + line := card.result + if line == "" { + line = card.name + " connected" + if card.account != "" { + line += " as " + card.account + } } return []string{a.pal.add(mark) + a.pal.dim(fit(" "+line, width-1))} } diff --git a/internal/tui3/connectpanel.go b/internal/tui3/connectpanel.go index b2df0eb11..025c80c9f 100644 --- a/internal/tui3/connectpanel.go +++ b/internal/tui3/connectpanel.go @@ -825,6 +825,10 @@ func (a *app) abandonConnects() { a.abandonConnect(service) } a.connFlows = nil + if a.codexFlow != nil { + a.codexFlow.Cancel() + a.codexFlow = nil + } } // adoptConnectResult settles the block the browser — or the key — left open, and diff --git a/internal/tui3/modelservices.go b/internal/tui3/modelservices.go index 7a5631449..0d49a5982 100644 --- a/internal/tui3/modelservices.go +++ b/internal/tui3/modelservices.go @@ -2,6 +2,7 @@ package tui3 import ( "context" + "errors" "net/url" "sort" "strings" @@ -13,7 +14,6 @@ import ( "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/connect" "github.com/Agent-Field/codeaf/internal/modelsource" - "github.com/Agent-Field/codeaf/internal/provider" ) // Model-service rows share the connection panel's row grammar without sharing @@ -205,6 +205,8 @@ func modelConnectionStatus(source modelsource.Source, held bool) connect.Status switch { case source.ID == "ollama": need = "" + case source.ID == "codex": + need = "browser" case modelsource.IsCustomID(source.ID): need = "address · key" case len(source.Regions) > 0: @@ -229,6 +231,8 @@ func modelConnectionStatus(source modelsource.Source, held bool) connect.Status } if source.ID == "ollama" { service.Auth = "none" + } else if source.ID == "codex" { + service.Auth = connect.AuthBrowser } return connect.Status{Service: service, Connected: held, Account: source.Written, KeyEnv: source.KeyEnv} } @@ -241,6 +245,8 @@ func modelServiceTag(row connect.Status) string { switch { case id == "ollama": return "" + case id == "codex": + return "browser" case id == connectionSwitchRowID: // The switch row's sentence is its own Blurb, drawn as the row's // value; a tag would say it twice. @@ -299,6 +305,12 @@ func (a *app) startModelConnect(row connect.Status, fromSheet bool) tea.Cmd { return nil case source.ID == "ollama": return a.beginModelConnect(*draft) + case source.ID == "codex": + a.modelDraft = nil + if !fromSheet { + a.connPanel.close() + } + return a.beginCodexConnect(*draft) default: draft.step = modelConnectKey a.showModelEntry(newModelEntry(row.ID, source.Name, "key", nil, true), fromSheet) @@ -306,6 +318,78 @@ func (a *app) startModelConnect(row connect.Status, fromSheet bool) tea.Cmd { } } +func (a *app) beginCodexConnect(draft modelConnectDraft) tea.Cmd { + connect, ctx := a.codexConnect, a.ctx + if ctx == nil { + ctx = context.Background() + } + return func() tea.Msg { + if connect == nil { + return codexFlowMsg{draft: draft, err: errors.New("codex browser sign-in is unavailable here")} + } + flow, err := connect(ctx) + return codexFlowMsg{draft: draft, flow: flow, err: err} + } +} + +// adoptCodexFlow puts the sign-in address on the same waiting block every +// browser connection uses before it waits. The result then rejoins the ordinary +// model-service adoption path, so the picker, live sources and preferred-model +// move have one implementation. +func (a *app) adoptCodexFlow(msg codexFlowMsg) tea.Cmd { + if msg.err != nil || msg.flow == nil { + reason := "the browser sign-in did not start" + if msg.err != nil { + reason = codexFailureReason(msg.err) + } + a.modelServiceMessage("codex did not connect · " + reason) + return nil + } + if a.codexFlow != nil { + a.codexFlow.Cancel() + } + a.codexFlow = msg.flow + a.openConnectFlow("codex", "codex", msg.flow.URL()) + flow, ctx, dir := msg.flow, a.ctx, a.profileDir + if ctx == nil { + ctx = context.Background() + } + return func() tea.Msg { + defer flow.Cancel() + tokens, err := flow.Wait(ctx) + if err != nil { + return modelConnectResultMsg{ + service: "codex", name: "Codex", written: "codex", browser: true, + word: "codex did not connect · " + codexFailureReason(err), err: err, + } + } + outcome, err := config.ConnectCodex(ctx, dir, tokens) + models := modelsFromListedIDs(outcome.ModelIDs) + word := config.CodexConnectionWord(tokens.Email, tokens.Plan, outcome) + if err != nil { + word = "codex did not connect · " + codexFailureReason(err) + } + return modelConnectResultMsg{ + service: "codex", name: "Codex", written: "codex", outcome: outcome, + models: models, err: err, browser: true, word: word, + } + } +} + +func codexFailureReason(err error) string { + if err == nil { + return "the browser sign-in did not finish" + } + reason := strings.TrimSpace(err.Error()) + if _, tail, found := strings.Cut(reason, ": "); found { + reason = strings.TrimSpace(tail) + } + if reason == "" { + return "the browser sign-in did not finish" + } + return reason +} + func newModelEntry(id, name, blank string, answers []string, secret bool) *keyEntry { return &keyEntry{id: id, name: strings.ToLower(name), blank: blank, answers: answers, secret: secret} } @@ -540,6 +624,7 @@ func (a *app) beginModelConnect(draft modelConnectDraft) tea.Cmd { }, seed) catalog, refreshErr := modelcatalog.Refresh(ctx, modelcatalog.Options{ Source: instance, BaseURL: connected.Address, APIKey: connected.Key, Dir: dir, + HTTPClient: config.CatalogHTTPClient(connected), }) if refreshed := surfaceModels(catalog.ModelsNow()); refreshErr == nil && len(refreshed) > 0 { models = refreshed @@ -614,6 +699,14 @@ func (a *app) defaultServiceModels() []Model { func (a *app) adoptModelConnectResult(msg modelConnectResultMsg) { if msg.err != nil { + if msg.browser { + a.codexFlow = nil + a.settleConnectWord("codex", msg.word, true) + if a.at(pageSettings) { + a.modelServiceMessage(msg.word) + } + return + } a.modelServiceMessage(msg.err.Error()) return } @@ -683,7 +776,18 @@ func (a *app) adoptModelConnectResult(msg modelConnectResultMsg) { default: line = serviceOutcomeWord(service, msg.outcome) } - a.modelServiceMessage(line) + if msg.browser { + a.codexFlow = nil + if strings.TrimSpace(msg.word) != "" { + line = msg.word + } + a.settleConnectWord("codex", line, false) + if a.at(pageSettings) { + a.modelServiceMessage(line) + } + } else { + a.modelServiceMessage(line) + } if nextModel != "" { a.moveConversationToConnectedModel(nextModel) } else if renamedNext != "" { @@ -880,62 +984,28 @@ func (a *app) moveConversationOrDefer(id, written string) { } func serviceOutcomeWord(service string, outcome modelsource.Outcome) string { - switch outcome.Kind { - case modelsource.OutcomeConnected: - return serviceConnectedWord(service, outcome) - case modelsource.OutcomeRefused: - line := service + " refused that key" - if said := truncateVendorWords(outcome.VendorSaid, 120); said != "" { - line += " — " + said - } - return line - case modelsource.OutcomeAccountCannotPay: - return serviceCannotPayWord(service, outcome.VendorSaid) - case modelsource.OutcomeUnanswered: - return service + " did not answer · nothing was saved" - case modelsource.OutcomeWrongShape: - return "that is not the shape of a " + service + " key — they start with sk-" - } - return "" + // The terminal command and panel report the same check. Config owns the + // sentence so neither surface can acquire a private spelling of the outcome. + return config.ConnectionOutcomeWord(service, outcome) } func serviceConnectedWord(service string, outcome modelsource.Outcome) string { - line := service + " is connected" - if door := strings.TrimSpace(outcome.Door.Name); door != "" { - line += " · " + door - } - if outcome.Listed && outcome.Models > 0 { - line += " · " + itoa(outcome.Models) + " " + plural("model", outcome.Models) - } - if outcome.PlanPaused { - overflow := "" - if outcome.Overflow != nil { - overflow = outcome.Overflow.Name - } - line += " · " + provider.PlanPauseSentence(outcome.PlanReset, overflow) - } - return line + // Older panel call sites ask only for success. They still go through the + // shared formatter rather than keeping a second successful-case sentence. + outcome.Kind = modelsource.OutcomeConnected + return config.ConnectionOutcomeWord(service, outcome) } func engineVariableWord(name string) string { return "the engine process reads $" + strings.TrimSpace(name) + " from its own environment" } -// serviceCannotPayWord is the ONE sentence for an authenticated account with no -// funds, and it is shared by the two moments a person meets it: the connect -// row, and a turn that a vendor refused for the same reason. -// -// IT NAMES THE SERVICE AND NEVER THE STATUS. `error: API error (429): …` is -// what the turn drew before this existed — three pieces of machinery vocabulary -// on a line a person reads, and a number that tells them nothing they can act -// on. The vendor's own words are the only part that says what to do, and they -// go through verbatim. +// serviceCannotPayWord sends a turn-time payment refusal through the same +// formatter as the connection result that first proved the account. func serviceCannotPayWord(service, vendorSaid string) string { - line := strings.TrimSpace(service) + " accepted the key but the account cannot pay" - if said := truncateVendorWords(vendorSaid, 120); said != "" { - line += " — " + said - } - return line + return config.ConnectionOutcomeWord(service, modelsource.Outcome{ + Kind: modelsource.OutcomeAccountCannotPay, VendorSaid: vendorSaid, + }) } // serviceWordFor is the name a person calls the service that serves model — @@ -973,22 +1043,6 @@ func serviceStrandedWord(was string) string { return "this conversation was on " + was + " and nothing else here can take it · connect a service or pick a model" } -func truncateVendorWords(words string, limit int) string { - words = strings.TrimSpace(words) - runes := []rune(words) - if limit <= 0 || len(runes) <= limit { - return words - } - cut := limit - for cut > 0 && !unicode.IsSpace(runes[cut]) { - cut-- - } - if cut == 0 { - cut = limit - } - return strings.TrimSpace(string(runes[:cut])) -} - func (a *app) modelServiceMessage(line string) { line = strings.TrimSpace(line) if line == "" { diff --git a/internal/tui3/modelservices_test.go b/internal/tui3/modelservices_test.go index 577787854..fc74ee0cc 100644 --- a/internal/tui3/modelservices_test.go +++ b/internal/tui3/modelservices_test.go @@ -4,13 +4,16 @@ import ( "context" "encoding/json" "net/http" + "net/http/httptest" "os" "path/filepath" "reflect" "strings" "testing" + "time" modelcatalog "github.com/Agent-Field/codeaf/internal/catalog" + "github.com/Agent-Field/codeaf/internal/codexauth" "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/connect" "github.com/Agent-Field/codeaf/internal/modelsource" @@ -30,6 +33,7 @@ func installModelServiceShelf(a *app, dir string) { } models, err := modelcatalog.Refresh(ctx, modelcatalog.Options{ Source: service.Source.ID, BaseURL: service.Address, APIKey: service.Key, Dir: dir, + HTTPClient: config.CatalogHTTPClient(service), }) if err != nil { return nil, err @@ -43,6 +47,114 @@ func installModelServiceShelf(a *app, dir string) { } } +type panelCodexFlow struct { + url string + tokens codexauth.Tokens + cancelled bool +} + +func (f *panelCodexFlow) URL() string { return f.url } + +func (f *panelCodexFlow) Wait(context.Context) (codexauth.Tokens, error) { + return f.tokens, nil +} + +func (f *panelCodexFlow) Cancel() { f.cancelled = true } + +func TestC12C18ConnectCodexBrowserRowUsesTheRealPanelAndMovesToTheListedModel(t *testing.T) { + // C12: the real /connect row adopts the account list and moves to codex/gpt-5.5. + // C18: the panel never asks for or displays a token; the backend sees only the real bearer. + dir := t.TempDir() + var authorization string + requests := 0 + backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + requests++ + authorization = request.Header.Get("Authorization") + if request.URL.Path != "/models" || request.URL.Query().Get("client_version") == "" { + t.Fatalf("panel listing request = %s", request.URL.String()) + } + if authorization != "Bearer panel-access" || authorization == "Bearer "+codexauth.Sentinel { + t.Fatalf("panel listing authorization = %q", authorization) + } + if request.Header.Get("chatgpt-account-id") != "acct-panel" || request.Header.Get("originator") != codexauth.Originator { + t.Fatalf("panel listing account headers = %v", request.Header) + } + _ = json.NewEncoder(writer).Encode(map[string]any{"models": []any{ + map[string]any{"slug": "gpt-5.5", "visibility": "list"}, + map[string]any{"slug": "hidden", "visibility": "hide"}, + }}) + })) + defer backend.Close() + t.Setenv("CODEAF_CODEX_BACKEND", backend.URL) + + a := modelServiceTestApp(t, dir, "~deepseek/deepseek-v4-flash-latest", + modelsource.NewSet(testDefaultService("sk-default-1234567890")), + []Model{{ID: "~deepseek/deepseek-v4-flash-latest"}}) + flow := &panelCodexFlow{ + url: "https://auth.example/authorize?state=panel", + tokens: codexauth.Tokens{ + AccessToken: "panel-access", RefreshToken: "panel-refresh", IDToken: "panel-identity", + AccountID: "acct-panel", Email: "person@example.com", Plan: "pro", ExpiresAt: time.Now().Add(time.Hour), + }, + } + a.codexConnect = func(context.Context) (CodexFlow, error) { return flow, nil } + opened := "" + wasOpener := processOpener + processOpener = func(target string) error { opened = target; return nil } + t.Cleanup(func() { processOpener = wasOpener }) + + a.openConnect() + rowAt := -1 + var row connect.Status + for at := range a.connPanel.hits { + candidate, ok := a.connPanel.at(at) + if ok && candidate.ID == modelConnectionID("codex") { + rowAt, row = at, candidate + break + } + } + if rowAt < 0 || row.Name != "Codex" || modelServiceTag(row) != "browser" { + t.Fatalf("codex row = %+v at %d", row, rowAt) + } + begin := a.connectAct(rowAt) + if begin == nil || a.connPanel.open { + t.Fatal("enter on Codex did not leave the panel for the browser road") + } + _, wait := a.Update(begin()) + if wait == nil || opened != flow.url || len(a.entries) == 0 || !a.connectLinkable(len(a.entries)-1) { + t.Fatalf("waiting card/opened = %v/%q entries=%d", wait != nil, opened, len(a.entries)) + } + if _, ok := a.connectLinkPress(len(a.entries) - 1); !ok || !a.entries[len(a.entries)-1].conn.copied { + t.Fatal("the Codex waiting card did not expose the copy affordance") + } + if _, follow := a.Update(wait()); follow != nil { + t.Fatal("the settled Codex connection unexpectedly started another command") + } + if requests != 1 || authorization != "Bearer panel-access" { + t.Fatalf("panel listing requests/authorization = %d/%q", requests, authorization) + } + if a.model != "codex/gpt-5.5" || a.agent.Model() != "codex/gpt-5.5" { + t.Fatalf("panel left model at %q/%q", a.model, a.agent.Model()) + } + var card *connectCard + var cardEntry *entry + for index := len(a.entries) - 1; index >= 0; index-- { + if a.entries[index].conn != nil && a.entries[index].conn.service == "codex" { + card, cardEntry = a.entries[index].conn, &a.entries[index] + break + } + } + if card == nil || card.state != connectConnected || card.result != "codex connected · person@example.com · pro plan" { + t.Fatalf("settled browser card = %+v", card) + } + if strings.Contains(plain(strings.Join(a.connectRows(cardEntry, 100), "\n")), codexauth.Sentinel) { + t.Fatal("the sentinel appeared on the settled panel card") + } + if !flow.cancelled { + t.Fatal("the settled browser road left its callback listener open") + } +} + func modelServiceTestApp(t *testing.T, dir string, model string, sources modelsource.Set, models []Model) *app { return modelServiceTestAppWithAgent(t, dir, model, sources, models, &fakeAgent{model: model}) } @@ -411,7 +523,7 @@ func TestAnUndocumentedListingGetsAListingServicesPickerAndCacheImmediately(t *t a := modelServiceTestApp(t, dir, "openai/gpt-4.1-mini", modelsource.NewSet(testDefaultService("sk-default-1234567890")), []Model{{ID: "openai/gpt-4.1-mini"}}) installModelServiceShelf(a, dir) - source := modelsource.Vendored()[6] + source := testModelSource(t, modelsource.CustomID) source.Listing = modelsource.ListingNone draft := modelConnectDraft{source: source, row: config.PersistedSource{ ID: "custom", Written: "localhost", Address: server.URL(), Key: "a-custom-key", Order: 1, @@ -446,7 +558,7 @@ func TestAPaymentRefusalConnectsTheAuthenticatedAccount(t *testing.T) { dir := t.TempDir() a := modelServiceTestApp(t, dir, "openai/gpt-4.1-mini", modelsource.NewSet(testDefaultService("sk-default-1234567890")), []Model{{ID: "openai/gpt-4.1-mini"}}) - source := modelsource.Vendored()[6] + source := testModelSource(t, modelsource.CustomID) source.Listing = modelsource.ListingNone source.ProbeModel = "probe-model" draft := modelConnectDraft{source: source, row: config.PersistedSource{ @@ -470,7 +582,7 @@ func TestARenameCarriesTheModelIdsAlreadyPicked(t *testing.T) { a := modelServiceTestApp(t, dir, "openai/gpt-4.1-mini", modelsource.NewSet(testDefaultService("sk-default-1234567890")), []Model{{ID: "openai/gpt-4.1-mini"}}) installModelServiceShelf(a, dir) - source := modelsource.Vendored()[6] + source := testModelSource(t, modelsource.CustomID) source.Listing = modelsource.ListingNone draft := modelConnectDraft{source: source, row: config.PersistedSource{ ID: "custom", Written: "mybox", Address: server.URL(), Key: "a-custom-key", Order: 1, @@ -585,7 +697,7 @@ func renamedRelistedApp(t *testing.T, model string) *app { modelsource.NewSet(testDefaultService("sk-default-1234567890")), []Model{{ID: "openai/gpt-4.1-mini"}}) installModelServiceShelf(a, dir) - source := modelsource.Vendored()[6] // Custom OpenAI-compatible API + source := testModelSource(t, modelsource.CustomID) first := modelConnectDraft{source: source, row: config.PersistedSource{ ID: "custom", Written: "homelab", Address: server.URL(), Key: "a-custom-key", Order: 1, }} @@ -667,7 +779,7 @@ func TestTwoConnectionsDoNotBorrowEachOthersPrefix(t *testing.T) { modelsource.NewSet(testDefaultService("sk-default-1234567890")), []Model{{ID: "openai/gpt-4.1-mini"}}) installModelServiceShelf(a, dir) - template := modelsource.Vendored()[6] // Custom OpenAI-compatible API + template := testModelSource(t, modelsource.CustomID) labSource := template labSource.ID, labSource.Written = "custom", "lab" a.adoptModelConnectResult(a.beginModelConnect(modelConnectDraft{source: labSource, row: config.PersistedSource{ @@ -711,7 +823,7 @@ func TestARenameWhileATurnIsWorkingCarriesThePendingMoveUnderTheNewName(t *testi a := modelServiceTestApp(t, dir, "openai/gpt-4.1-mini", modelsource.NewSet(testDefaultService("sk-default-1234567890")), []Model{{ID: "openai/gpt-4.1-mini"}}) installModelServiceShelf(a, dir) - source := modelsource.Vendored()[6] + source := testModelSource(t, modelsource.CustomID) source.Listing = modelsource.ListingNone draft := modelConnectDraft{source: source, row: config.PersistedSource{ ID: "custom", Written: "homelab", Address: server.URL(), Key: "a-custom-key", Order: 1, @@ -776,7 +888,7 @@ func TestARenameWhileATurnIsWorkingAndNoMoveIsPendingDefersTheRespelledLivePick( a := modelServiceTestApp(t, dir, "openai/gpt-4.1-mini", modelsource.NewSet(testDefaultService("sk-default-1234567890")), []Model{{ID: "openai/gpt-4.1-mini"}}) installModelServiceShelf(a, dir) - source := modelsource.Vendored()[6] + source := testModelSource(t, modelsource.CustomID) source.Listing = modelsource.ListingNone draft := modelConnectDraft{source: source, row: config.PersistedSource{ ID: "custom", Written: "homelab", Address: server.URL(), Key: "a-custom-key", Order: 1, @@ -839,7 +951,7 @@ func TestARenameDuringAWorkingTurnLeavesAPendingSwitchAlone(t *testing.T) { a := modelServiceTestApp(t, dir, "openai/gpt-4.1-mini", modelsource.NewSet(testDefaultService("sk-default-1234567890")), []Model{{ID: "openai/gpt-4.1-mini"}}) installModelServiceShelf(a, dir) - source := modelsource.Vendored()[6] + source := testModelSource(t, modelsource.CustomID) source.Listing = modelsource.ListingNone // TWO CONNECTIONS, minted the way the surface mints them: the first keeps @@ -1063,7 +1175,7 @@ func TestTheModelServiceWordsAreExactAndVendorWordsStopAtAWordBoundary(t *testin t.Errorf("engine variable word = %q", got) } words := strings.Repeat("word ", 30) + "tail" - got := truncateVendorWords(words, 120) + got := config.ConnectionDetailWords(words, 120) if len(got) > 120 || strings.HasSuffix(got, "wor") { t.Fatalf("vendor words were not cut at a word boundary: %q", got) } @@ -1298,7 +1410,7 @@ func TestACustomServiceUsesItsWrittenNameOnRefusalAndSuccess(t *testing.T) { defer agent.Close() a := modelServiceTestAppWithAgent(t, t.TempDir(), agent.Model(), modelsource.NewSet(base), []Model{{ID: agent.Model()}}, agent) draft := modelConnectDraft{ - source: modelsource.Vendored()[6], + source: testModelSource(t, modelsource.CustomID), row: config.PersistedSource{ID: "custom", Written: "localhost", Address: server.URL(), Key: "a-custom-key", Order: 1}, } msg := a.beginModelConnect(draft)().(modelConnectResultMsg) diff --git a/internal/tui3/tui3.go b/internal/tui3/tui3.go index 0c4eb89f7..9e47def0a 100644 --- a/internal/tui3/tui3.go +++ b/internal/tui3/tui3.go @@ -48,6 +48,7 @@ import ( tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/codexauth" "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/effort" "github.com/Agent-Field/codeaf/internal/leave" @@ -402,6 +403,15 @@ type OpenRouterFlow interface { Cancel() } +// CodexFlow is one browser sign-in that returns the ChatGPT-plan credentials +// config keeps outside the surface. The loopback listener and token file both +// belong to the door; the surface only shows, waits and cancels the attempt. +type CodexFlow interface { + URL() string + Wait(context.Context) (codexauth.Tokens, error) + Cancel() +} + // Options configures one surface. type Options struct { // Agent is the conversation this surface shows. Required. @@ -1048,6 +1058,11 @@ type Options struct { // browser behind it. ConnectOpenRouter func(context.Context) (OpenRouterFlow, error) + // ConnectCodex starts the Codex CLI-compatible browser sign-in used by the + // Codex model-service row. Nil leaves that browser row without a local road, + // as on a hosted surface; the whole /connect panel already explains why. + ConnectCodex func(context.Context) (CodexFlow, error) + // Linear is the SCREEN-READER TIER: one column, no animation, no hover, // ASCII markers instead of the pastel glyph set. Everything the surface says // it still says — the difference is that it says all of it in words and From 9bbfd0549bad69c23f5d777a688a4dfcc26b3739 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 21 Sep 2026 16:01:07 -0400 Subject: [PATCH 06/18] manual, README, GUIDE: a ChatGPT plan connects as Codex, in the browser or from the terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manual described only key-based model services and did not name the two terminal verbs; its verb gate carried a bridge for them. The pages now say how Codex signs in (the way the Codex CLI does, under OpenAI's terms for a ChatGPT plan), that its models are the account's own and a new connection lands on codex/gpt-5.5, that no price is ever shown and tokens are still counted, the plan-limit and expiry sentences, that first run does not offer it, and what `codeaf connect` and `codeaf disconnect` do and do not do — regions, the ssh tunnel line, custom services staying in the chat, and the door rule for a headless command with no OpenRouter key. The bridge is gone. Three probes in the asker's own words reach the pages. Co-Authored-By: Claude Fable 5.1 --- README.md | 9 ++-- docs/GUIDE.md | 15 ++++--- internal/manual/chat/commands.md | 6 ++- internal/manual/chat/getting-started.md | 4 ++ internal/manual/chat/models-and-cost.md | 26 ++++++++++++ .../manual/chat/running-from-the-terminal.md | 42 +++++++++++++++++-- internal/manual/chat/services.md | 8 ++-- internal/manual/chat/starting-codeaf.md | 3 +- internal/manual/chat_test.go | 5 +++ internal/manual/terminalverbs_test.go | 6 --- 10 files changed, 99 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 553d1a2b1..c18078b8d 100644 --- a/README.md +++ b/README.md @@ -63,8 +63,9 @@ curl -fsSL https://agentfield.ai/get/codeaf | VERSION= bash To build it yourself: `git clone`, `make build`, `bin/codeaf` ([guide](docs/GUIDE.md#install)). -On first start it asks for a key: OpenRouter, DeepSeek, GLM, Kimi, MiniMax or -Qwen. Ollama needs none. +On first start it connects OpenRouter in your browser, or takes a key. Codex signs in +a ChatGPT plan from `/connect` or `codeaf connect codex`; DeepSeek, GLM, Kimi, MiniMax +and Qwen take keys; Ollama needs none. ## One window for every project @@ -169,8 +170,8 @@ request goes to the provider that has been fastest for that kind of call. The right model for each call: the spend page showing what ran it, by model and role: glm-5.3, deepseek-v4-flash and qwen3.8-27b with calls, tokens and dollars -Providers built in: OpenRouter, DeepSeek, GLM, Kimi, MiniMax, Qwen, Ollama and -any OpenAI-compatible endpoint. +Providers built in: OpenRouter, DeepSeek, GLM, Kimi, MiniMax, Qwen, Codex through a +ChatGPT plan, Ollama and any OpenAI-compatible endpoint. ## Model Pool diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 0401e7b94..4fb094604 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -283,10 +283,13 @@ after the conversation. Memory keeps person-, project-, or machine-scoped record ## Models, keys, and spending -Key resolution is `OPENROUTER_API_KEY`, then `OPENAI_API_KEY`, then `api_key` in the -profile's `config.json`. With no key, an interactive local launch opens a two-page setup -that offers to connect OpenRouter in a browser or take a pasted key. A non-interactive -chat with no key stops instead, with `codeaf chat needs a model to talk with.` +Key resolution for the default service is `OPENROUTER_API_KEY`, then +`OPENAI_API_KEY`, then `api_key` in the profile's `config.json`. With no credential, +an interactive local launch opens a two-page setup that offers to connect OpenRouter +in a browser or take a pasted key. First run is unchanged and does not offer Codex. +A non-interactive chat starts when the default service has a key or any connected +service holds its credential; a call to a service without one still fails when it is +made. With no credential anywhere it stops with `codeaf chat needs a model to talk with.`
The two first-run screens, word for word @@ -307,7 +310,9 @@ The second page is `Daily limit`, `Chat model` and `Work crew`. The chat model resolves from `--model`, then saved `model.talk`, then `CODEAF_MODEL`, then `~deepseek/deepseek-v4-flash-latest`. The last value is a floating alias. Besides OpenRouter, the connection screen supports DeepSeek, Z.ai, Moonshot, MiniMax, Alibaba -Qwen, Ollama, and a custom OpenAI-compatible service; a qualified slug such as +Qwen, Codex through a ChatGPT plan, Ollama, and a custom OpenAI-compatible service. +The same supported services can be managed without opening the chat with `codeaf +connect` and `codeaf disconnect`; a qualified slug such as `qwen/` selects its service. Provider routing defaults to `simple`: an unpinned OpenRouter call carries no provider diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index 7a862cdc7..550313738 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -1521,8 +1521,10 @@ status sheet. Change that machine's profile there. ## /connect — your connected accounts `/connect` (or `/connections`) opens the connection panel. Its pinned `models` group -holds the five built-in model services plus every one already connected; the account -catalog groups follow it. Pick a row and connect it. There is no argument form. **Custom OpenAI-compatible API** connects a custom service: it asks for a +holds the six built-in model services plus every one already connected; the account +catalog groups follow it. The Codex row says `browser`; enter opens the sign-in road and +the waiting card keeps the address available to copy. The other listed services say what +they need. Pick a row and connect it. There is no argument form. **Custom OpenAI-compatible API** connects a custom service: it asks for a base URL, then a name of your own with the host's own spelling pre-filled (`127.0.0.1` becomes `127-0-0-1`), then a key. Several custom connections sit beside each other, each under its name; once one is connected an `add custom connection` row appears and diff --git a/internal/manual/chat/getting-started.md b/internal/manual/chat/getting-started.md index 4f702a08c..bf2ff2396 100644 --- a/internal/manual/chat/getting-started.md +++ b/internal/manual/chat/getting-started.md @@ -35,6 +35,10 @@ returns on the next local interactive launch because that model cannot work with conversation on a connected direct service's model does not owe OpenRouter a key, so that step stays away. +Codex is deliberately not another first-run step. After setup, its browser sign-in is +available from the Codex row in `/connect`, or from `codeaf connect codex` without +opening the chat. + The header reads `codeaf` on the left and `setup · 2 of 2` on the right; with only one screen to show there is no count at all. The foot names the keys that work on the row you are standing on — `tab` walks the rows, `?` opens a control's detail — and on a narrow diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index bc48c916f..63c98d8b2 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -78,6 +78,32 @@ Over `--host`, the picker and its prices are this laptop's catalog, while the co window used for compaction comes from the far machine's catalog. The machine doing the work owns that execution limit even when the two catalog caches differ. +## Sign in with ChatGPT and use my Codex plan — models, price, limits and expiry + +Open `/connect`, choose **Codex**, and finish the browser sign-in. This signs in the way +the Codex CLI does; OpenAI's terms for a ChatGPT plan apply to what runs on it. The +service reads the model list belonging to that account, and a new connection moves this +conversation to `codex/gpt-5.5`. Every model from it is qualified as `codex/`. + +A Codex call has no dollar price codeaf can know. The spend page therefore shows no +invented `$0.00` or unknown-price label; when the backend reports usage, it still counts +the prompt, completion, cached-prompt and reasoning tokens. The plan is paid for outside +codeaf. When its allowance is exhausted, the turn says exactly: + +``` +codex reached your chatgpt plan's usage limit · it resets on its own +``` + +An expired sign-in says: + +``` +codex sign-in has expired · /connect or codeaf connect codex signs in again +``` + +This sign-in does not add an OpenAI API key, cannot connect a custom endpoint, does not +put Codex on first-run setup, and does not replace codeaf's own instructions with the +Codex CLI's base instructions. Use the custom-service row for an OpenAI-compatible API. + ## Can I switch models while it is replying — I changed the model in the middle of an answer, does it change now or wait? **Your word wins at the next request, within a second, and never at the next turn.** diff --git a/internal/manual/chat/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md index ea1c44b99..7c7652439 100644 --- a/internal/manual/chat/running-from-the-terminal.md +++ b/internal/manual/chat/running-from-the-terminal.md @@ -157,7 +157,8 @@ talk to it chat · resume hand it work do "" · exec "" · run look at what happened why self · why · notebook · competence · services · logs · models · doctor · manual · version -housekeeping cache · cache clean · rebuild · wake · serve · devices · help env +housekeeping connect · disconnect · cache · cache clean · rebuild · wake · + serve · devices · help env plan work by hand plan new "" · plan show · plan revise "…" · plan run ``` @@ -173,6 +174,30 @@ by hand: **`codeaf engine`** is the far half of `chat --host`, started by ssh, a **`codeaf tick`** is the one bounded pass the background timer runs every five minutes. Neither draws anything or reads a key. +## Connect from the terminal without opening the chat — codeaf connect and codeaf disconnect + +`codeaf connect` lists every model service this profile knows, whether it is connected, +and whether its door is a browser or a key. `codeaf connect codex` signs a ChatGPT plan +in through the browser; `codeaf connect openrouter` uses OpenRouter's existing browser +road. Add `--no-browser` to print the address without opening it. For Codex on another +machine, the next line gives the tunnel to run before opening that address here: + +``` +ssh -L 1455:localhost:1455 +``` + +If that sign-in chose port 1457 instead, the printed command uses 1457. DeepSeek and +MiniMax take a key through the same checked connection as `/connect`; Ollama takes none. +Z.ai, Moonshot and Qwen take a key and also need `--region intl` or `--region cn`. A key +is read from stdin when it is piped, or asked for without echo on a terminal. A new custom +service is created only in the chat: an unknown custom name says it is not a service this +profile knows. Once the chat has created one, `codeaf connect ` can reconnect +that instance with a key. + +`codeaf disconnect ` forgets the connection and its key or sign-in. Neither +command sends a prompt, calls a model or adds model spend. They do not print keys or +tokens. + ## The belt's hands from a shell — codeaf patch, codeaf doc, codeaf web fetch, codeaf web search, codeaf image Four verbs reach, from a terminal, the same hands the conversation's model uses — each @@ -817,7 +842,14 @@ safe in a shell prompt, a CI step or a bug report. model catalog, but spends nothing of yours. **These spend**, because all of them call a model: `chat`, `do`, `exec`, `run`, -`plan new`, `plan revise`, `plan run` and `wake`. Without a key each fails at the door with the same two lines: +`plan new`, `plan revise`, `plan run` and `wake`. The door opens when the default +service has a key or any connected service holds its credential — a Codex sign-in, an +Ollama connection, a vendor key. So with no OpenRouter key a headless command still +starts once another service is connected; point it at that service's model with +`--model` or the profile's `model.talk`, because a call to a service that has no +credential still fails when it is made, with `no API key: this session has not been +given one yet`. With no credential anywhere, each fails at the door with the same two +lines: ``` codeaf needs a model to work with. @@ -832,8 +864,10 @@ Each directly connected service may instead name its own environment variable, w stored with that service. `codeaf doctor`'s first row still reports only which default-service key answered — `key set · OPENROUTER_API_KEY`, or `key set · /home/you/.codeaf/config.json`, or `key none ·` and the two lines above. -**These change state without spending**: `cache clean`, `rebuild`, `notebook -retract|restore`, `services stop` and `devices revoke`. The two that destroy something ask +**These change state without model spending**: `connect`, `disconnect`, `cache clean`, +`rebuild`, `notebook retract|restore`, `services stop` and `devices revoke`. A browser +connection may make authentication and model-list network requests, but sends no prompt. +The two that destroy something ask first — `cache clean` wants the word `now` typed out, the same word `/cache clean now` wants in the chat, and `rebuild` wants `y` — and `--yes` skips the question on both. The other three act at once, and all three can be undone: a retracted belief restores, a stopped service starts again, a revoked device pairs again. diff --git a/internal/manual/chat/services.md b/internal/manual/chat/services.md index b3e293afe..ca480d577 100644 --- a/internal/manual/chat/services.md +++ b/internal/manual/chat/services.md @@ -2,9 +2,11 @@ ## Add a key — connect a service, add an api key for another provider, use a different model service -An api key for another provider is added here. Open `/connect` or `/connections`. The `models` group lists DeepSeek, Z.ai, Moonshot, -MiniMax, Alibaba Qwen, Ollama and **Custom OpenAI-compatible API**, followed by any service already -connected and an `add custom connection` row. +Another model service is added here. Open `/connect` or `/connections`. The `models` +group lists DeepSeek, Z.ai, Moonshot, MiniMax, Alibaba Qwen, Codex, Ollama and **Custom +OpenAI-compatible API**, followed by any service already connected and an `add custom +connection` row. Codex says `browser`; it signs in a ChatGPT plan instead of asking for +an API key. Ollama needs no key. The other named vendors ask for theirs. Pick a row and answer its fields. A successful listed service says `deepseek-direct is connected · 6 models`; one without a list says only `deepseek-direct is connected`. A service with more than one billing door names the one it diff --git a/internal/manual/chat/starting-codeaf.md b/internal/manual/chat/starting-codeaf.md index 2b323a93b..a811f2de0 100644 --- a/internal/manual/chat/starting-codeaf.md +++ b/internal/manual/chat/starting-codeaf.md @@ -71,7 +71,8 @@ OpenRouter step returns on any later local interactive launch while no key exist including a named or resumed conversation using the default service, and `enter` on an unsent message brings it back without clearing the draft. A conversation on a connected direct service's model sends without an OpenRouter key and does not open that step. The -getting-started page has the whole flow. +getting-started page has the whole flow. First run does not offer Codex; connect a +ChatGPT plan later from the Codex row in `/connect` or with `codeaf connect codex`. A `--once` or piped run cannot open a browser. When its model uses the keyless default service it stops at the door with `codeaf chat needs a model to talk with.` Its next line diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 3e809f42d..3df4973fd 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -2675,6 +2675,11 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"what background processes are still running", "running-from-the-terminal"}, {"how do I replay the journal and rebuild the tables", "running-from-the-terminal"}, {"which commands need no api key", "running-from-the-terminal"}, + // C12 and C18: the plan sign-in must be reachable in the words of both + // the account a person owns and the terminal door they want to use. + {"sign in with chatgpt", "models-and-cost"}, + {"use my codex plan", "models-and-cost"}, + {"connect from the terminal without opening the chat", "running-from-the-terminal"}, {"how do I read a plan file back as a table", "running-from-the-terminal"}, {"why does codeaf show --help print a file error", "running-from-the-terminal"}, diff --git a/internal/manual/terminalverbs_test.go b/internal/manual/terminalverbs_test.go index b451434b3..d316e5328 100644 --- a/internal/manual/terminalverbs_test.go +++ b/internal/manual/terminalverbs_test.go @@ -46,12 +46,6 @@ func TestTheChatManualMentionsEveryVerbTheCommandLineAnswersTo(t *testing.T) { t.Fatalf("only %d verbs were read out of the dispatch; the reader has stopped working", len(verbs)) } for _, verb := range verbs { - // The Codex sign-in work lands in two explicit parts. Part A owns these - // terminal doors and is forbidden to edit the corpus; Part B owns their - // manual pages and removes this narrow bridge when it writes them. - if verb == "connect" || verb == "disconnect" { - continue - } if !chatManualNamesTheCommand(t, verb) { t.Errorf("no chat manual page mentions `codeaf %s` — add it to internal/manual/chat/", verb) } From 844384d810173af2c5334453977debaa4f4aec8c Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 21 Sep 2026 16:02:05 -0400 Subject: [PATCH 07/18] docs/changes: a ChatGPT plan connects as Codex, and codeaf connect from the terminal (#1336) Co-Authored-By: Claude Fable 5.1 --- docs/changes/unreleased/1336-codex-connect.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 docs/changes/unreleased/1336-codex-connect.md diff --git a/docs/changes/unreleased/1336-codex-connect.md b/docs/changes/unreleased/1336-codex-connect.md new file mode 100644 index 000000000..2e8dd13fd --- /dev/null +++ b/docs/changes/unreleased/1336-codex-connect.md @@ -0,0 +1,16 @@ +--- +kind: added +title: a ChatGPT plan connects as the Codex model service, in the browser or with codeaf connect +pr: 1336 +surface: [chat, engine, docs] +invalidates: + - "Codex was not available as a connected model service. A ChatGPT plan now signs in from `/connect` or `codeaf connect codex` and its account models appear as `codex/`." + - "A generic Codex model-list refresh could send the persisted `chatgpt` sentinel as a bearer. Every listing road now replaces it with the rotating account bearer and required account headers before reaching the backend." + - "Every non-keyless model-service row opened key entry. The Codex row now says `browser` and uses the waiting card, copy affordance, and preferred-model move." + - "The terminal command and chat panel owned separate model-connection outcome sentences. Both now read the same formatter in `internal/config`." + - "The chat manual omitted `codeaf connect` and `codeaf disconnect`, and its terminal-verb gate temporarily exempted them. Both verbs are now documented and checked like every other terminal verb." + - "A headless command refused to start with no OpenRouter key even when another service was connected. It now starts when any connected service holds its credential; a call to a service without one still fails when it is made." + - "Connected-service documentation covered API-key services but not a ChatGPT plan's account list, limits, expiry, or unknown price. The Codex pages now state those boundaries and token-only accounting." +--- + +Codex uses the account's own visible model list and never exposes the persisted sentinel or rotating tokens to the catalog or screen. First run remains OpenRouter-only; device-code sign-in, OpenAI API-key sign-in, Codex base instructions, websockets, connector scopes, and the resident remain out of scope. From b8d709526a146f1d1488d0ba3c842b959c45141b Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 21 Sep 2026 17:52:48 -0400 Subject: [PATCH 08/18] codexauth, session: the transport survives other processes, and a terminal refusal keeps its own words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rotating refresh tokens were serialised inside one process only, so two codeaf processes near expiry could invalidate each other's sign-in; any non-2xx refresh answer, a 429 or a 5xx included, signed the person out; a failed Responses event could end as an empty success, and a cut reply ended without a finish reason; and an echoed bearer in an error body could reach the call log. The refresh now holds the repository's file lock across load, refresh and save and re-reads first, so whichever process refreshed wins; only the issuer's own 400/401 marks the sign-in expired; response.failed and error become a classifiable 402 or 502 on both roads; an incomplete reply carries `length` only for max_output_tokens; the transport scrubs its own tokens from every body, status and error it returns, and provider's error ingress and call log scrub as a second boundary; absent usage stays absent. The session's ending sentence now prefers a typed refusal's own words before the generic taxonomy: a plan refusal names the service and what the vendor said, a spent window says when it resets, and an expired Codex sign-in says how to sign in again — in a headless run as in the chat, for Z.ai as for Codex. A headless run used to say `your key was not accepted for this model` for all of them. An expired sign-in is terminal for the dispatcher's retry walk. Co-Authored-By: Claude Fable 5.1 --- internal/codexauth/tokens.go | 15 +- internal/codexauth/transport.go | 246 ++++++++++++-- internal/codexauth/transport_test.go | 461 +++++++++++++++++++++++++- internal/provider/calllog.go | 6 +- internal/provider/client.go | 5 + internal/provider/dispatch.go | 16 + internal/session/codex_client_test.go | 248 ++++++++++++++ internal/session/loop.go | 56 +++- internal/session/plan_doors_test.go | 27 +- internal/tui3/feed.go | 5 + internal/tui3/paymentnote_test.go | 8 + 11 files changed, 1050 insertions(+), 43 deletions(-) diff --git a/internal/codexauth/tokens.go b/internal/codexauth/tokens.go index b78589b82..0c4ed00c0 100644 --- a/internal/codexauth/tokens.go +++ b/internal/codexauth/tokens.go @@ -24,9 +24,22 @@ const ( clientVersion = "0.144.1" ) +// signInExpiredError is terminal before the provider's ordinary transport +// recovery. The issuer has already made the one decision another send cannot +// improve, and the session owns the actionable sentence this value carries. +type signInExpiredError struct{} + +func (*signInExpiredError) Error() string { + return "codex sign-in has expired · /connect or codeaf connect codex signs in again" +} + +// TerminalTransportFailure tells the shared provider dispatcher not to spend +// its retry window asking the same expired sign-in again. +func (*signInExpiredError) TerminalTransportFailure() bool { return true } + // ErrSignInExpired is the one actionable sentence returned when the issuer no // longer accepts a profile's rotating refresh token. -var ErrSignInExpired = errors.New("codex sign-in has expired · /connect or codeaf connect codex signs in again") +var ErrSignInExpired error = &signInExpiredError{} // Tokens is the complete durable answer from one browser sign-in. type Tokens struct { diff --git a/internal/codexauth/transport.go b/internal/codexauth/transport.go index caba4ffa0..d99cd2932 100644 --- a/internal/codexauth/transport.go +++ b/internal/codexauth/transport.go @@ -10,14 +10,18 @@ import ( "io" "net/http" "net/url" + "os" + "path/filepath" "strings" "sync" "time" "github.com/google/uuid" + "github.com/Agent-Field/codeaf/internal/filelock" "github.com/Agent-Field/codeaf/internal/guard" "github.com/Agent-Field/codeaf/internal/provider" + "github.com/Agent-Field/codeaf/internal/trace" ) var refreshLocks struct { @@ -120,7 +124,7 @@ func (t *transport) RoundTrip(request *http.Request) (*http.Response, error) { } response, err := send(tokens) if err != nil { - return nil, err + return nil, scrubTransportError(err) } if response.StatusCode == http.StatusUnauthorized { _ = response.Body.Close() @@ -130,7 +134,14 @@ func (t *transport) RoundTrip(request *http.Request) (*http.Response, error) { } response, err = send(refreshed) if err != nil { - return nil, err + return nil, scrubTransportError(err) + } + } + response.Status = string(trace.Scrub([]byte(response.Status))) + if response.StatusCode < 200 || response.StatusCode >= 300 { + response, err = scrubHTTPResponse(response) + if err != nil { + return nil, scrubTransportError(err) } } if isTurn && response.StatusCode >= 400 && codexQuotaStatus(response.StatusCode) { @@ -140,6 +151,9 @@ func (t *transport) RoundTrip(request *http.Request) (*http.Response, error) { return t.translateCatalogResponse(response) } if !isTurn || response.StatusCode < 200 || response.StatusCode >= 300 { + if response.StatusCode >= 200 && response.StatusCode < 300 { + return scrubHTTPResponse(response) + } return response, nil } return translateResponse(response, wantsStream) @@ -154,6 +168,7 @@ func (t *transport) translateCatalogResponse(response *http.Response) (*http.Res if err != nil { return nil, err } + raw = trace.Scrub(raw) var answer struct { Models []struct { Slug string `json:"slug"` @@ -213,12 +228,7 @@ func quotaResponse(response *http.Response) *http.Response { response.Body = io.NopCloser(bytes.NewReader(raw)) return response } - lower := strings.ToLower(string(raw)) - matched := false - for _, phrase := range []string{"usage_limit_reached", "usage_not_included", "rate_limit_exceeded", "usage limit"} { - matched = matched || strings.Contains(lower, phrase) - } - if !matched { + if !quotaPayload(raw) { response.Body = io.NopCloser(bytes.NewReader(raw)) response.ContentLength = int64(len(raw)) return response @@ -238,6 +248,55 @@ func quotaResponse(response *http.Response) *http.Response { return response } +func quotaPayload(raw []byte) bool { + lower := strings.ToLower(string(raw)) + for _, phrase := range []string{"usage_limit_reached", "usage_not_included", "rate_limit_exceeded", "usage limit"} { + if strings.Contains(lower, phrase) { + return true + } + } + return false +} + +// scrubHTTPResponse removes every credential already registered by Load before +// a response can reach the provider client, its call log, or a session journal. +// It is used on bounded control and error bodies; successful turn streams are +// scrubbed event by event in [mapResponseEvents]. +func scrubHTTPResponse(response *http.Response) (*http.Response, error) { + if response == nil || response.Body == nil { + return response, nil + } + raw, err := io.ReadAll(io.LimitReader(response.Body, 8<<20)) + _ = response.Body.Close() + if err != nil { + return nil, err + } + raw = trace.Scrub(raw) + response.Body = io.NopCloser(bytes.NewReader(raw)) + response.ContentLength = int64(len(raw)) + response.Status = string(trace.Scrub([]byte(response.Status))) + return response, nil +} + +type scrubbedTransportError struct { + err error + said string +} + +func (e *scrubbedTransportError) Error() string { return e.said } +func (e *scrubbedTransportError) Unwrap() error { return e.err } + +func scrubTransportError(err error) error { + if err == nil { + return nil + } + said := string(trace.Scrub([]byte(err.Error()))) + if said == err.Error() { + return err + } + return &scrubbedTransportError{err: err, said: said} +} + func readRequestBody(request *http.Request) ([]byte, error) { if request.Body == nil { return nil, nil @@ -285,8 +344,19 @@ func (t *transport) fresh(ctx context.Context, force bool, rejected string) (Tok mutex := lockFor(Path(t.profileDir)) mutex.Lock() defer mutex.Unlock() + lock, err := lockTokenFile(t.profileDir) + if err != nil { + return Tokens{}, err + } + defer func() { + _ = filelock.Unlock(lock) + _ = lock.Close() + }() tokens, err := Load(t.profileDir) if err != nil { + if errors.Is(err, os.ErrNotExist) { + return Tokens{}, ErrSignInExpired + } return Tokens{}, err } if strings.TrimSpace(tokens.AccessToken) == "" { @@ -312,7 +382,7 @@ func (t *transport) fresh(ctx context.Context, force bool, rejected string) (Tok request.Header.Set("Accept", "application/json") response, err := t.base.RoundTrip(request) if err != nil { - return Tokens{}, err + return Tokens{}, scrubTransportError(err) } defer response.Body.Close() raw, readErr := io.ReadAll(io.LimitReader(response.Body, maxExchangeBody)) @@ -320,10 +390,16 @@ func (t *transport) fresh(ctx context.Context, force bool, rejected string) (Tok return Tokens{}, readErr } if response.StatusCode < 200 || response.StatusCode >= 300 { - tokens.AccessToken = "" - tokens.ExpiresAt = time.Time{} - _ = Save(t.profileDir, tokens) - return Tokens{}, ErrSignInExpired + if response.StatusCode == http.StatusBadRequest || response.StatusCode == http.StatusUnauthorized { + tokens.AccessToken = "" + tokens.ExpiresAt = time.Time{} + if err := Save(t.profileDir, tokens); err != nil { + return Tokens{}, err + } + return Tokens{}, ErrSignInExpired + } + status := strings.TrimSpace(string(trace.Scrub([]byte(response.Status)))) + return Tokens{}, fmt.Errorf("refresh codex sign-in: issuer answered %s", status) } var answer struct { AccessToken string `json:"access_token"` @@ -331,7 +407,7 @@ func (t *transport) fresh(ctx context.Context, force bool, rejected string) (Tok IDToken string `json:"id_token"` } if json.Unmarshal(raw, &answer) != nil || strings.TrimSpace(answer.AccessToken) == "" { - return Tokens{}, ErrSignInExpired + return Tokens{}, errors.New("refresh codex sign-in: issuer returned an unreadable answer") } tokens.AccessToken = answer.AccessToken if strings.TrimSpace(answer.RefreshToken) != "" { @@ -343,6 +419,7 @@ func (t *transport) fresh(ctx context.Context, force bool, rejected string) (Tok tokens.AccountID, tokens.Email, tokens.Plan = claims.Auth.AccountID, claims.Email, claims.Auth.Plan } } + tokens.ExpiresAt = time.Time{} if claims, claimErr := claimsFrom(answer.AccessToken); claimErr == nil && claims.Exp != 0 { tokens.ExpiresAt = time.Unix(claims.Exp, 0) } @@ -353,6 +430,26 @@ func (t *transport) fresh(ctx context.Context, force bool, rejected string) (Tok return Load(t.profileDir) } +// lockTokenFile extends the in-process refresh mutex across codeaf processes. +// The sidecar is never removed: the operating system owns the live lock, and a +// process exit releases it without a stale-file protocol. The token file is +// re-read only after this returns, so a waiter sees whichever refresh won. +func lockTokenFile(profileDir string) (*os.File, error) { + path := Path(profileDir) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return nil, fmt.Errorf("refresh codex sign-in: make profile directory: %w", err) + } + lock, err := os.OpenFile(path+".lock", os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, fmt.Errorf("refresh codex sign-in: open token lock: %w", err) + } + if err := filelock.Lock(lock, true, false); err != nil { + _ = lock.Close() + return nil, fmt.Errorf("refresh codex sign-in: lock tokens: %w", err) + } + return lock, nil +} + func translateRequest(profileDir string, raw []byte) ([]byte, bool, error) { var source map[string]any if err := json.Unmarshal(raw, &source); err != nil { @@ -514,6 +611,13 @@ type mappedStream struct { usage map[string]any finish string sawTool bool + terminal bool + failure *mappedFailure +} + +type mappedFailure struct { + status int + value map[string]any } func translateResponse(response *http.Response, wantsStream bool) (*http.Response, error) { @@ -531,7 +635,7 @@ func translateResponse(response *http.Response, wantsStream bool) (*http.Respons _, writeErr := fmt.Fprintf(writer, "data: %s\n\n", encoded) return writeErr }) - if err == nil { + if err == nil && state.failure == nil { _, err = io.WriteString(writer, "data: [DONE]\n\n") } _ = writer.CloseWithError(err) @@ -547,6 +651,15 @@ func translateResponse(response *http.Response, wantsStream bool) (*http.Respons if err := mapResponseEvents(bytes.NewReader(raw), state, nil); err != nil { return nil, err } + if state.failure != nil { + body, _ := json.Marshal(map[string]any{"error": state.failure.value}) + response.StatusCode = state.failure.status + response.Status = fmt.Sprintf("%d %s", state.failure.status, http.StatusText(state.failure.status)) + response.Body = io.NopCloser(bytes.NewReader(body)) + response.ContentLength = int64(len(body)) + response.Header.Set("Content-Type", "application/json") + return response, nil + } message := map[string]any{"role": "assistant", "content": state.content.String()} if len(state.tools) > 0 { message["tool_calls"] = state.tools @@ -557,10 +670,14 @@ func translateResponse(response *http.Response, wantsStream bool) (*http.Respons if len(state.details) > 0 { message["reasoning_details"] = state.details } - body, _ := json.Marshal(map[string]any{ + completion := map[string]any{ "id": state.id, "object": "chat.completion", "created": state.created, "model": state.model, - "choices": []any{map[string]any{"index": 0, "message": message, "finish_reason": state.finish}}, "usage": state.usage, - }) + "choices": []any{map[string]any{"index": 0, "message": message, "finish_reason": state.finish}}, + } + if state.usage != nil { + completion["usage"] = state.usage + } + body, _ := json.Marshal(completion) response.Body = io.NopCloser(bytes.NewReader(body)) response.ContentLength = int64(len(body)) response.Header.Set("Content-Type", "application/json") @@ -579,6 +696,7 @@ func mapResponseEvents(reader io.Reader, state *mappedStream, emit func(map[stri if data == "" || data == "[DONE]" { continue } + data = string(trace.Scrub([]byte(data))) var event map[string]any if json.Unmarshal([]byte(data), &event) != nil { continue @@ -590,6 +708,9 @@ func mapResponseEvents(reader io.Reader, state *mappedStream, emit func(map[stri } } } + if state.terminal { + return nil + } } return scanner.Err() } @@ -597,10 +718,14 @@ func mapResponseEvents(reader io.Reader, state *mappedStream, emit func(map[stri func mapEvent(event map[string]any, state *mappedStream) []map[string]any { kind, _ := event["type"].(string) chunk := func(delta map[string]any, finish any, usage map[string]any) map[string]any { - return map[string]any{ + value := map[string]any{ "id": state.id, "object": "chat.completion.chunk", "created": state.created, "model": state.model, - "choices": []any{map[string]any{"index": 0, "delta": delta, "finish_reason": finish}}, "usage": usage, + "choices": []any{map[string]any{"index": 0, "delta": delta, "finish_reason": finish}}, + } + if usage != nil { + value["usage"] = usage } + return value } switch kind { case "response.created": @@ -654,14 +779,31 @@ func mapEvent(event map[string]any, state *mappedStream) []map[string]any { state.details = append(state.details, detail) return []map[string]any{chunk(map[string]any{"reasoning_details": []any{detail}}, nil, nil)} case "response.incomplete": - state.finish = "length" - return nil + response, _ := event["response"].(map[string]any) + state.setIdentity(response) + details, _ := response["incomplete_details"].(map[string]any) + if details == nil { + details, _ = event["incomplete_details"].(map[string]any) + } + reason, _ := details["reason"].(string) + if strings.TrimSpace(reason) == "max_output_tokens" { + state.finish = "length" + state.usage = mappedUsage(response["usage"]) + state.terminal = true + return []map[string]any{chunk(map[string]any{}, state.finish, state.usage)} + } + message := "codex did not finish the response" + if reason = strings.TrimSpace(reason); reason != "" { + message += " · " + reason + } + state.failure = classifyMappedFailure(map[string]any{ + "message": message, "type": "upstream_error", "code": http.StatusBadGateway, + }) + state.terminal = true + return []map[string]any{{"error": state.failure.value}} case "response.completed": response, _ := event["response"].(map[string]any) - if state.id == "" { - state.id, _ = response["id"].(string) - state.model, _ = response["model"].(string) - } + state.setIdentity(response) if state.finish == "" { if state.sawTool { state.finish = "tool_calls" @@ -670,6 +812,7 @@ func mapEvent(event map[string]any, state *mappedStream) []map[string]any { } } state.usage = mappedUsage(response["usage"]) + state.terminal = true return []map[string]any{chunk(map[string]any{}, state.finish, state.usage)} case "response.failed", "error": errorValue, _ := event["error"].(map[string]any) @@ -678,16 +821,52 @@ func mapEvent(event map[string]any, state *mappedStream) []map[string]any { errorValue, _ = response["error"].(map[string]any) } if errorValue == nil { - errorValue = map[string]any{"message": "codex did not finish the response", "type": "upstream_error", "code": 502} - } - if _, ok := errorValue["code"].(float64); !ok { - errorValue["code"] = 502 + errorValue = make(map[string]any) + if message, _ := event["message"].(string); strings.TrimSpace(message) != "" { + errorValue["message"] = message + } + if code, ok := event["code"]; ok { + errorValue["code"] = code + } } - return []map[string]any{{"error": errorValue}} + state.failure = classifyMappedFailure(errorValue) + state.terminal = true + return []map[string]any{{"error": state.failure.value}} } return nil } +func (s *mappedStream) setIdentity(response map[string]any) { + if s == nil || response == nil || s.id != "" { + return + } + s.id, _ = response["id"].(string) + s.model, _ = response["model"].(string) + if created := integer(response["created_at"]); created != 0 { + s.created = created + } +} + +func classifyMappedFailure(value map[string]any) *mappedFailure { + encoded, _ := json.Marshal(value) + if quotaPayload(encoded) { + return &mappedFailure{status: http.StatusPaymentRequired, value: map[string]any{ + "message": QuotaWords, "type": "upstream_error", "code": http.StatusPaymentRequired, + }} + } + message, _ := value["message"].(string) + if message = strings.TrimSpace(message); message == "" { + message = "codex did not finish the response" + } + kind, _ := value["type"].(string) + if kind = strings.TrimSpace(kind); kind == "" || kind == "error" { + kind = "upstream_error" + } + return &mappedFailure{status: http.StatusBadGateway, value: map[string]any{ + "message": message, "type": kind, "code": http.StatusBadGateway, + }} +} + func integer(value any) int64 { switch number := value.(type) { case float64: @@ -701,7 +880,10 @@ func integer(value any) int64 { } func mappedUsage(value any) map[string]any { - usage, _ := value.(map[string]any) + usage, ok := value.(map[string]any) + if !ok || usage == nil { + return nil + } input := integer(usage["input_tokens"]) output := integer(usage["output_tokens"]) inputDetails, _ := usage["input_tokens_details"].(map[string]any) diff --git a/internal/codexauth/transport_test.go b/internal/codexauth/transport_test.go index ae23fe12d..0a5198505 100644 --- a/internal/codexauth/transport_test.go +++ b/internal/codexauth/transport_test.go @@ -6,23 +6,85 @@ import ( "encoding/json" "errors" "fmt" - "github.com/Agent-Field/codeaf/internal/paymentrefusal" "io" "net/http" "net/http/httptest" + "os" + "os/exec" + "path/filepath" "strings" "sync" "sync/atomic" "testing" "time" + "github.com/Agent-Field/codeaf/internal/filelock" + "github.com/Agent-Field/codeaf/internal/paymentrefusal" "github.com/Agent-Field/codeaf/internal/provider" ) +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return f(request) +} + func validTokens(now time.Time) Tokens { return Tokens{AccessToken: "access-token-secret", RefreshToken: "refresh-token-secret", IDToken: "identity-token-secret", AccountID: "account-one", ExpiresAt: now.Add(time.Hour)} } +func TestTransportScrubsEveryOwnedTokenFromBodyStatusAndErrors(t *testing.T) { + // C6 and C18: access, refresh and identity tokens are equally secret. The + // response and error are inspected at the transport boundary so the test + // proves the bytes are gone before any downstream sink can receive them. + now := time.Now() + dir := t.TempDir() + tokens := Tokens{ + AccessToken: "access-boundary-secret", RefreshToken: "refresh-boundary-secret", + IDToken: "identity-boundary-secret", AccountID: "account-one", ExpiresAt: now.Add(time.Hour), + } + if err := Save(dir, tokens); err != nil { + t.Fatal(err) + } + secrets := []string{tokens.AccessToken, tokens.RefreshToken, tokens.IDToken} + echo := strings.Join(secrets, " ") + responseTransport := Translate(dir, Options{HTTPClient: &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusBadRequest, Status: "400 " + echo, + Header: make(http.Header), Body: io.NopCloser(strings.NewReader(echo)), Request: request, + }, nil + })}}) + request, _ := http.NewRequest(http.MethodGet, "https://example.invalid/echo", nil) + response, err := responseTransport.RoundTrip(request) + if err != nil { + t.Fatal(err) + } + body, err := io.ReadAll(response.Body) + _ = response.Body.Close() + if err != nil { + t.Fatal(err) + } + observable := response.Status + "\n" + string(body) + for _, secret := range secrets { + if strings.Contains(observable, secret) { + t.Errorf("response exposed token bytes %q: %s", secret, observable) + } + } + + errorTransport := Translate(dir, Options{HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, errors.New(echo) + })}}) + _, err = errorTransport.RoundTrip(request) + if err == nil { + t.Fatal("echoed-token transport error unexpectedly succeeded") + } + for _, secret := range secrets { + if strings.Contains(err.Error(), secret) { + t.Errorf("transport error exposed token bytes %q: %v", secret, err) + } + } +} + func TestC12ListingUsesAccountHeadersFiltersVisibilityAndCachesLevels(t *testing.T) { // C12: the account listing, not generic /models, supplies only visible Codex models. now := time.Unix(1_800_000_000, 0) @@ -133,6 +195,12 @@ func TestC13C14C15TransportMapsTurnHeadersStreamAndReasoningRoundTrip(t *testing } func doTurn(t *testing.T, client *http.Client, endpoint string, body map[string]any) string { + t.Helper() + _, translated := doTurnResponse(t, client, endpoint, body) + return translated +} + +func doTurnResponse(t *testing.T, client *http.Client, endpoint string, body map[string]any) (int, string) { t.Helper() raw, _ := json.Marshal(body) request, _ := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(raw)) @@ -148,7 +216,128 @@ func doTurn(t *testing.T, client *http.Client, endpoint string, body map[string] if err != nil { t.Fatal(err) } - return string(translated) + return response.StatusCode, string(translated) +} + +func TestFailedResponseEventsAreClassifiableOnStreamingAndWholeResponseRoads(t *testing.T) { + // C17 and §4.2: either terminal event becomes a numeric chat-completions + // refusal. Quota retains its payment status and shared sentence; every + // other backend failure retains its message under 502. + for _, stream := range []bool{true, false} { + for _, eventKind := range []string{"response.failed", "error"} { + for _, quota := range []bool{true, false} { + name := fmt.Sprintf("stream=%t/%s/quota=%t", stream, eventKind, quota) + t.Run(name, func(t *testing.T) { + now := time.Now() + dir := t.TempDir() + if err := Save(dir, validTokens(now)); err != nil { + t.Fatal(err) + } + message, code := "backend broke while answering", "backend_failed" + if quota { + message, code = "usage_limit_reached for this account", "rate_limit_exceeded" + } + backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "text/event-stream") + if eventKind == "response.failed" { + fmt.Fprintf(writer, "data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"message\":%q,\"type\":\"server_error\",\"code\":%q}}}\n\n", message, code) + return + } + fmt.Fprintf(writer, "data: {\"type\":\"error\",\"message\":%q,\"code\":%q}\n\n", message, code) + })) + defer backend.Close() + client := ClientWithOptions(dir, Options{Backend: backend.URL, HTTPClient: backend.Client(), Now: func() time.Time { return now }}) + status, body := doTurnResponse(t, client, backend.URL+"/chat/completions", map[string]any{ + "model": "gpt-5.5", "stream": stream, + "messages": []any{map[string]any{"role": "user", "content": "hello"}}, + }) + wantStatus, wantCode, wantMessage := http.StatusBadGateway, `"code":502`, message + if quota { + wantStatus, wantCode, wantMessage = http.StatusPaymentRequired, `"code":402`, QuotaWords + } + if stream { + wantStatus = http.StatusOK + } + if status != wantStatus || !strings.Contains(body, wantCode) || !strings.Contains(body, wantMessage) { + t.Fatalf("translated failure status=%d body=%s; want status=%d, %s and %q", status, body, wantStatus, wantCode, wantMessage) + } + }) + } + } + } +} + +func TestIncompleteResponseCarriesLengthOrAClassifiedFailure(t *testing.T) { + // §4.2: max-output exhaustion is a completed, cut reply; every other + // incomplete reason is a failure and cannot become a silent [DONE]. + for _, stream := range []bool{true, false} { + for _, reason := range []string{"max_output_tokens", "content_filter"} { + t.Run(fmt.Sprintf("stream=%t/%s", stream, reason), func(t *testing.T) { + now := time.Now() + dir := t.TempDir() + if err := Save(dir, validTokens(now)); err != nil { + t.Fatal(err) + } + backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "text/event-stream") + fmt.Fprintln(writer, `data: {"type":"response.created","response":{"id":"cut-1","model":"gpt-5.5","created_at":1800000000}}`) + fmt.Fprintln(writer) + fmt.Fprintf(writer, "data: {\"type\":\"response.incomplete\",\"response\":{\"id\":\"cut-1\",\"model\":\"gpt-5.5\",\"incomplete_details\":{\"reason\":%q}}}\n\n", reason) + })) + defer backend.Close() + client := ClientWithOptions(dir, Options{Backend: backend.URL, HTTPClient: backend.Client(), Now: func() time.Time { return now }}) + status, body := doTurnResponse(t, client, backend.URL+"/chat/completions", map[string]any{ + "model": "gpt-5.5", "stream": stream, + "messages": []any{map[string]any{"role": "user", "content": "hello"}}, + }) + if reason == "max_output_tokens" { + if status != http.StatusOK || !strings.Contains(body, `"finish_reason":"length"`) { + t.Fatalf("max-output response status=%d body=%s", status, body) + } + return + } + wantStatus := http.StatusBadGateway + if stream { + wantStatus = http.StatusOK + } + if status != wantStatus || !strings.Contains(body, `"code":502`) || !strings.Contains(body, reason) { + t.Fatalf("other incomplete status=%d body=%s", status, body) + } + }) + } + } +} + +func TestMissingUsageStaysAbsent(t *testing.T) { + // The emptiness law: an upstream that supplied no usage creates no usage + // object in either a terminal stream chunk or a whole completion. + for _, stream := range []bool{true, false} { + t.Run(fmt.Sprintf("stream=%t", stream), func(t *testing.T) { + now := time.Now() + dir := t.TempDir() + if err := Save(dir, validTokens(now)); err != nil { + t.Fatal(err) + } + backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "text/event-stream") + fmt.Fprintln(writer, `data: {"type":"response.created","response":{"id":"empty-usage","model":"gpt-5.5","created_at":1800000000}}`) + fmt.Fprintln(writer) + fmt.Fprintln(writer, `data: {"type":"response.output_text.delta","delta":"answer"}`) + fmt.Fprintln(writer) + fmt.Fprintln(writer, `data: {"type":"response.completed","response":{"id":"empty-usage","model":"gpt-5.5"}}`) + fmt.Fprintln(writer) + })) + defer backend.Close() + client := ClientWithOptions(dir, Options{Backend: backend.URL, HTTPClient: backend.Client(), Now: func() time.Time { return now }}) + _, body := doTurnResponse(t, client, backend.URL+"/chat/completions", map[string]any{ + "model": "gpt-5.5", "stream": stream, + "messages": []any{map[string]any{"role": "user", "content": "hello"}}, + }) + if strings.Contains(body, `"usage"`) { + t.Fatalf("absent usage became a usage object: %s", body) + } + }) + } } func TestC16RefreshesBeforeExpirySingleFlightAndRetriesOneUnauthorizedCall(t *testing.T) { @@ -199,6 +388,274 @@ func TestC16RefreshesBeforeExpirySingleFlightAndRetriesOneUnauthorizedCall(t *te } } +func TestCodexRefreshProcessHelper(t *testing.T) { + profile := os.Getenv("CODEAF_CODEX_REFRESH_HELPER_PROFILE") + if profile == "" { + return + } + if err := os.WriteFile(os.Getenv("CODEAF_CODEX_REFRESH_HELPER_READY"), nil, 0o600); err != nil { + t.Fatal(err) + } + endpoint := os.Getenv("CODEAF_CODEX_REFRESH_HELPER_ENDPOINT") + models, err := List(context.Background(), profile, Options{Issuer: endpoint, Backend: endpoint}) + if err != nil { + t.Fatal(err) + } + if len(models) != 1 || models[0].ID != "gpt-5.5" { + t.Fatalf("models = %+v", models) + } +} + +func TestC16RotatingRefreshIsSafeAcrossProcesses(t *testing.T) { + // C16 and D7: two independent processes start from the same expired file. + // The issuer spends a refresh token once, so both calls can succeed only if + // the second process reloads the winner's access token under a file lock. + dir := t.TempDir() + tokens := validTokens(time.Now()) + tokens.AccessToken = "expired-access-token" + tokens.RefreshToken = "one-use-refresh-token" + tokens.ExpiresAt = time.Now().Add(-time.Hour) + if err := Save(dir, tokens); err != nil { + t.Fatal(err) + } + access := jwt(t, map[string]any{"exp": time.Now().Add(time.Hour).Unix()}) + var mutex sync.Mutex + refreshUsed := false + refreshes := 0 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case tokenPath: + _ = request.ParseForm() + mutex.Lock() + defer mutex.Unlock() + refreshes++ + if refreshUsed || request.Form.Get("refresh_token") != "one-use-refresh-token" { + writer.WriteHeader(http.StatusUnauthorized) + _, _ = io.WriteString(writer, `{"error":"invalid_grant"}`) + return + } + refreshUsed = true + _ = json.NewEncoder(writer).Encode(map[string]string{ + "access_token": access, "refresh_token": "rotated-refresh-token", + }) + case "/models": + if request.Header.Get("Authorization") != "Bearer "+access { + t.Fatalf("models authorization = %q", request.Header.Get("Authorization")) + } + _ = json.NewEncoder(writer).Encode(map[string]any{"models": []any{ + map[string]any{"slug": "gpt-5.5", "visibility": "list"}, + }}) + default: + http.NotFound(writer, request) + } + })) + defer server.Close() + + gate, err := lockTokenFile(dir) + if err != nil { + t.Fatal(err) + } + type child struct { + command *exec.Cmd + ready string + output bytes.Buffer + } + children := make([]*child, 2) + for index := range children { + ready := filepath.Join(t.TempDir(), "ready") + command := exec.Command(os.Args[0], "-test.run=^TestCodexRefreshProcessHelper$") + command.Env = append(os.Environ(), + "CODEAF_CODEX_REFRESH_HELPER_PROFILE="+dir, + "CODEAF_CODEX_REFRESH_HELPER_ENDPOINT="+server.URL, + "CODEAF_CODEX_REFRESH_HELPER_READY="+ready, + ) + children[index] = &child{command: command, ready: ready} + command.Stdout, command.Stderr = &children[index].output, &children[index].output + if err := command.Start(); err != nil { + t.Fatal(err) + } + } + for _, process := range children { + waitForRefreshSignal(t, process.ready) + } + if err := filelock.Unlock(gate); err != nil { + t.Fatal(err) + } + if err := gate.Close(); err != nil { + t.Fatal(err) + } + for _, process := range children { + if err := process.command.Wait(); err != nil { + t.Fatalf("refresh helper failed: %v\n%s", err, process.output.String()) + } + } + mutex.Lock() + defer mutex.Unlock() + if refreshes != 1 { + t.Fatalf("issuer received %d refreshes, want one use of the rotating token", refreshes) + } + kept, err := Load(dir) + if err != nil { + t.Fatal(err) + } + if kept.AccessToken != access || kept.RefreshToken != "rotated-refresh-token" || !Connected(dir) { + t.Fatalf("shared token file did not end signed in: %+v", kept) + } +} + +func waitForRefreshSignal(t *testing.T, path string) { + t.Helper() + deadline := time.Now().Add(30 * time.Second) + for { + if _, err := os.Stat(path); err == nil { + return + } else if !errors.Is(err, os.ErrNotExist) { + t.Fatal(err) + } + if time.Now().After(deadline) { + t.Fatalf("refresh helper did not become ready: %s", path) + } + time.Sleep(10 * time.Millisecond) + } +} + +func TestC16OnlyIssuerCredentialRefusalsExpireTheSavedSignIn(t *testing.T) { + // C16: a 400 or 401 changes the durable bytes to the disconnected shape. + // Pacing, server trouble, and an unreadable success leave every byte alone, + // and a second call reaches the issuer again instead of staying signed out. + for _, testCase := range []struct { + name string + status int + body string + expire bool + }{ + {name: "invalid grant", status: http.StatusBadRequest, body: `{"error":"invalid_grant"}`, expire: true}, + {name: "invalid client", status: http.StatusUnauthorized, body: `{"error":"invalid_client"}`, expire: true}, + {name: "pacing", status: http.StatusTooManyRequests, body: `{"error":"slow down"}`}, + {name: "issuer failure", status: http.StatusServiceUnavailable, body: `{"error":"try again"}`}, + {name: "malformed success", status: http.StatusOK, body: `{not-json`}, + } { + t.Run(testCase.name, func(t *testing.T) { + now := time.Now() + dir := t.TempDir() + tokens := validTokens(now) + tokens.ExpiresAt = now.Add(-time.Minute) + if err := Save(dir, tokens); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(Path(dir)) + if err != nil { + t.Fatal(err) + } + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + requests.Add(1) + writer.WriteHeader(testCase.status) + _, _ = io.WriteString(writer, testCase.body) + })) + defer server.Close() + options := Options{Issuer: server.URL, Backend: server.URL, HTTPClient: server.Client(), Now: func() time.Time { return now }} + _, firstErr := List(context.Background(), dir, options) + if firstErr == nil { + t.Fatal("failed refresh returned no error") + } + after, err := os.ReadFile(Path(dir)) + if err != nil { + t.Fatal(err) + } + if testCase.expire { + want := tokens + want.AccessToken = "" + want.ExpiresAt = time.Time{} + wantBytes, _ := json.Marshal(want) + if !errors.Is(firstErr, ErrSignInExpired) || !bytes.Equal(after, wantBytes) { + t.Fatalf("expired result error=%v\nbytes=%s\nwant=%s", firstErr, after, wantBytes) + } + _, _ = List(context.Background(), dir, options) + if requests.Load() != 1 { + t.Fatalf("expired sign-in contacted issuer %d times, want one", requests.Load()) + } + return + } + if errors.Is(firstErr, ErrSignInExpired) || !bytes.Equal(after, before) { + t.Fatalf("transient failure changed sign-in: error=%v\nbefore=%s\nafter=%s", firstErr, before, after) + } + _, _ = List(context.Background(), dir, options) + if requests.Load() != 2 { + t.Fatalf("next turn made %d issuer attempts, want two", requests.Load()) + } + }) + } +} + +func TestC16NetworkRefreshFailureLeavesTheTokenFileByteForByte(t *testing.T) { + now := time.Now() + dir := t.TempDir() + tokens := validTokens(now) + tokens.ExpiresAt = now.Add(-time.Minute) + if err := Save(dir, tokens); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(Path(dir)) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + endpoint, client := server.URL, server.Client() + server.Close() + options := Options{Issuer: endpoint, Backend: endpoint, HTTPClient: client, Now: func() time.Time { return now }} + for range 2 { + _, err := List(context.Background(), dir, options) + if err == nil || errors.Is(err, ErrSignInExpired) { + t.Fatalf("network refresh error = %v", err) + } + } + after, err := os.ReadFile(Path(dir)) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(after, before) { + t.Fatalf("network failure changed token bytes:\nbefore=%s\nafter=%s", before, after) + } +} + +func TestRefreshWithoutADecodableExpiryClearsTheOldExpiry(t *testing.T) { + now := time.Now() + dir := t.TempDir() + tokens := validTokens(now) + tokens.ExpiresAt = now.Add(-time.Minute) + if err := Save(dir, tokens); err != nil { + t.Fatal(err) + } + var refreshes atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path == tokenPath { + refreshes.Add(1) + _ = json.NewEncoder(writer).Encode(map[string]string{ + "access_token": "opaque-refreshed-access", "refresh_token": "new-refresh-token", + }) + return + } + _ = json.NewEncoder(writer).Encode(map[string]any{"models": []any{ + map[string]any{"slug": "gpt-5.5", "visibility": "list"}, + }}) + })) + defer server.Close() + options := Options{Issuer: server.URL, Backend: server.URL, HTTPClient: server.Client(), Now: func() time.Time { return now }} + for range 2 { + if _, err := List(context.Background(), dir, options); err != nil { + t.Fatal(err) + } + } + kept, err := Load(dir) + if err != nil { + t.Fatal(err) + } + if !kept.ExpiresAt.IsZero() || refreshes.Load() != 1 { + t.Fatalf("refreshed expiry=%v refreshes=%d, want absent expiry and one refresh", kept.ExpiresAt, refreshes.Load()) + } +} + func TestC16RefusedRefreshReturnsTheSignInSentenceAndMarksTokensUnusable(t *testing.T) { // C16: a refused rotating token stops without a retry storm and requires sign-in again. now := time.Unix(1_800_000_000, 0) diff --git a/internal/provider/calllog.go b/internal/provider/calllog.go index 7598841d8..c277a2b35 100644 --- a/internal/provider/calllog.go +++ b/internal/provider/calllog.go @@ -399,7 +399,7 @@ func (c *Client) record(facts recordFacts) { EmptyAtCeiling: c.emptyAtCeiling(facts.request, facts.response), } if facts.err != nil { - record.Error = calllog.ClipError(namedCancel(facts.ctx, facts.err)) + record.Error = calllog.ClipError(string(trace.Scrub([]byte(namedCancel(facts.ctx, facts.err))))) } // ── A FAILURE IS PRICED LIKE AN ANSWER, BECAUSE IT WAS BILLED LIKE ONE // @@ -504,9 +504,9 @@ func (c *Client) record(facts recordFacts) { } if calllog.Bodies() && calllog.Path() != "" { if facts.knobs.trace != nil { - record.RequestBody = string(facts.knobs.trace.body) + record.RequestBody = string(trace.Scrub(facts.knobs.trace.body)) } - record.ResponseBody = string(facts.responseBody) + record.ResponseBody = string(trace.Scrub(facts.responseBody)) } record.Ended = facts.ended facts.knobs.trace.track(facts, record.ID) diff --git a/internal/provider/client.go b/internal/provider/client.go index c5287afec..cb9c4344b 100644 --- a/internal/provider/client.go +++ b/internal/provider/client.go @@ -2662,6 +2662,11 @@ const maxSentenceClip = 160 // nothing until it says which provider and what they said, and both are in the // metadata OpenRouter already sends (see [APIError]). func apiError(status int, payload []byte) error { + // A PROVIDER'S ERROR BODY IS AN OBSERVABLE SINK. Connected transports + // register every credential they hold, and an upstream is free to echo a + // bearer inside this payload; scrub it before Body, Message or Raw can reach + // the journal, the call log, diagnostics, or a surface. + payload = trace.Scrub(payload) failure := &APIError{Status: status, Body: string(payload)} var decoded errorBody if err := json.Unmarshal(payload, &decoded); err == nil { diff --git a/internal/provider/dispatch.go b/internal/provider/dispatch.go index 4d332e7d7..6526fc570 100644 --- a/internal/provider/dispatch.go +++ b/internal/provider/dispatch.go @@ -660,6 +660,13 @@ func (c *Client) send(ctx context.Context, request *ai.Request, knobs callKnobs, if ctx.Err() != nil { return nil, fmt.Errorf("execute request: %w", err) } + // A TRANSPORT MAY KNOW THE CREDENTIAL IT OWNS IS FINISHED. That is + // not a reachability fault and cannot improve under this dispatcher's + // machine walk; preserve the typed cause for the session's person-facing + // sentence and return after the one request that established it. + if terminalTransportFailureFrom(err) { + return nil, fmt.Errorf("execute request: %w", err) + } lastErr = fmt.Errorf("execute request: %w", err) // AND A FAULT IS NOT A CEILING, whatever an earlier attempt of this // call learned: the bytes never reached anybody, so nothing has been @@ -998,6 +1005,15 @@ func (c *Client) send(ctx context.Context, request *ai.Request, knobs callKnobs, return nil, fmt.Errorf("after %d attempts: %w", attempts, lastErr) } +// terminalTransportFailureFrom is a narrow structural seam for transports +// that own credentials the provider package must not import. The exported +// method lets the typed cause survive wrappers without coupling the dispatcher +// to a particular account implementation. +func terminalTransportFailureFrom(err error) bool { + var terminal interface{ TerminalTransportFailure() bool } + return errors.As(err, &terminal) && terminal.TerminalTransportFailure() +} + // PlanPauseError is the typed end of a request whose fixed-price window is // temporarily unavailable. Cause preserves the vendor refusal for the journal; // surfaces read this type so the person sees only the actionable pause sentence diff --git a/internal/session/codex_client_test.go b/internal/session/codex_client_test.go index 824d56316..6e709ff09 100644 --- a/internal/session/codex_client_test.go +++ b/internal/session/codex_client_test.go @@ -1,17 +1,25 @@ package session import ( + "bytes" + "context" "encoding/json" "fmt" + "io/fs" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "sync" + "sync/atomic" "testing" "time" + "github.com/Agent-Field/codeaf/internal/calllog" "github.com/Agent-Field/codeaf/internal/codexauth" account "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/trace" ) func TestC13C14RealAgentUsesTheConfigOwnedCodexClientDoor(t *testing.T) { @@ -59,3 +67,243 @@ func TestC13C14RealAgentUsesTheConfigOwnedCodexClientDoor(t *testing.T) { t.Fatalf("agent request path=%q authorization=%q body=%s", path, authorization, encoded) } } + +func TestCodexQuotaRefusalEndsARealHeadlessTurnInThePlansWords(t *testing.T) { + // C17: the final EventError says what happened to the plan. The transport's + // typed payment refusal must survive the session boundary that used to turn + // every 402 into "your key was not accepted for this model". + backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusPaymentRequired) + _, _ = fmt.Fprintf(writer, `{"error":{"message":%q,"code":402}}`, codexauth.QuotaWords) + })) + defer backend.Close() + t.Setenv("CODEAF_CODEX_BACKEND", backend.URL) + + profile := t.TempDir() + if err := codexauth.Save(profile, codexauth.Tokens{ + AccessToken: "quota-access-token", RefreshToken: "quota-refresh-token", + IDToken: "quota-identity-token", AccountID: "quota-account", ExpiresAt: time.Now().Add(time.Hour), + }); err != nil { + t.Fatal(err) + } + listed := true + if err := account.WriteSources(profile, []account.PersistedSource{{ + ID: "codex", Written: "codex", Key: codexauth.Sentinel, Listed: &listed, + }}); err != nil { + t.Fatal(err) + } + agent, err := New(Config{ + Workspace: t.TempDir(), Model: "codex/gpt-5.5", + Sources: account.ResolveSources(profile, "", account.DefaultBaseURL), + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = agent.Close() }) + + failure := turnFailure(t, agent, "use the plan") + want := "codex accepted the key but the account cannot pay — " + codexauth.QuotaWords + if failure.Err == nil || failure.Err.Error() != want { + t.Fatalf("final EventError = %v, want %q", failure.Err, want) + } +} + +func TestCodexExpiredOrRemovedSignInEndsARealTurnWithTheRecoverySentence(t *testing.T) { + // C16: an issuer refusal and a token file removed beneath a live agent are + // the same observable condition. Neither path retries, and neither exposes + // a filesystem error or the generic transport sentence. + const want = "codex sign-in has expired · /connect or codeaf connect codex signs in again" + for _, testCase := range []struct { + name string + remove bool + }{ + {name: "issuer refused refresh"}, + {name: "token file removed", remove: true}, + } { + t.Run(testCase.name, func(t *testing.T) { + var issuerCalls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path == "/oauth/token" { + issuerCalls.Add(1) + writer.WriteHeader(http.StatusUnauthorized) + _, _ = fmt.Fprintln(writer, `{"error":"invalid_grant"}`) + return + } + t.Fatalf("expired sign-in reached backend path %q", request.URL.Path) + })) + defer server.Close() + t.Setenv("CODEAF_CODEX_ISSUER", server.URL) + t.Setenv("CODEAF_CODEX_BACKEND", server.URL) + profile := t.TempDir() + expires := time.Now().Add(-time.Minute) + if testCase.remove { + expires = time.Now().Add(time.Hour) + } + if err := codexauth.Save(profile, codexauth.Tokens{ + AccessToken: "expiry-access-token", RefreshToken: "expiry-refresh-token", + IDToken: "expiry-identity-token", AccountID: "expiry-account", ExpiresAt: expires, + }); err != nil { + t.Fatal(err) + } + listed := true + if err := account.WriteSources(profile, []account.PersistedSource{{ + ID: "codex", Written: "codex", Key: codexauth.Sentinel, Listed: &listed, + }}); err != nil { + t.Fatal(err) + } + agent, err := New(Config{ + Workspace: t.TempDir(), Model: "codex/gpt-5.5", + Sources: account.ResolveSources(profile, "", account.DefaultBaseURL), + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = agent.Close() }) + if testCase.remove { + if err := os.Remove(codexauth.Path(profile)); err != nil { + t.Fatal(err) + } + } + failure := turnFailure(t, agent, "continue this conversation") + if failure.Err == nil || failure.Err.Error() != want { + t.Fatalf("final EventError = %v, want %q", failure.Err, want) + } + wantIssuerCalls := int32(1) + if testCase.remove { + wantIssuerCalls = 0 + } + if issuerCalls.Load() != wantIssuerCalls { + t.Fatalf("issuer calls = %d, want %d", issuerCalls.Load(), wantIssuerCalls) + } + }) + } +} + +func TestCodexEchoedBearerNeverReachesAnyObservableFailureSink(t *testing.T) { + // C6 and C18: the transport owns these credentials. A hostile backend may + // echo the bearer, but the final event, transcript, optional body-bearing + // call log, and debug record must all contain scrubbed bytes instead. + const access = "codex-sink-access-secret" + const refresh = "codex-sink-refresh-secret" + const identity = "codex-sink-identity-secret" + backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Header.Get("Authorization") != "Bearer "+access { + t.Fatalf("backend bearer = %q", request.Header.Get("Authorization")) + } + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusBadRequest) + _, _ = fmt.Fprintf(writer, `{"error":{"message":%q}}`, "backend echoed Bearer "+access) + })) + defer backend.Close() + t.Setenv("CODEAF_CODEX_BACKEND", backend.URL) + profile := t.TempDir() + if err := codexauth.Save(profile, codexauth.Tokens{ + AccessToken: access, RefreshToken: refresh, IDToken: identity, + AccountID: "sink-account", ExpiresAt: time.Now().Add(time.Hour), + }); err != nil { + t.Fatal(err) + } + listed := true + if err := account.WriteSources(profile, []account.PersistedSource{{ + ID: "codex", Written: "codex", Key: codexauth.Sentinel, Listed: &listed, + }}); err != nil { + t.Fatal(err) + } + journal := filepath.Join(t.TempDir(), "session.jsonl") + logPath := filepath.Join(t.TempDir(), "calls.jsonl") + t.Setenv(calllog.EnvVar, logPath) + t.Setenv(calllog.BodiesEnvVar, "1") + calllog.Open("") + t.Cleanup(func() { + calllog.Close() + _ = os.Setenv(calllog.EnvVar, calllog.OffValue) + calllog.Open("") + }) + ctx := trace.Begin(context.Background()) + if trace.EnableRun(ctx) == "" { + t.Fatal("debug record did not turn on") + } + agent, err := New(Config{ + Workspace: t.TempDir(), Model: "codex/gpt-5.5", SessionFile: journal, + Sources: account.ResolveSources(profile, "", account.DefaultBaseURL), + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = agent.Close() }) + events, err := agent.Submit(ctx, "provoke the echoed bearer") + if err != nil { + t.Fatal(err) + } + collected := collect(t, events) + failure, ok := firstOfKind(collected, EventError) + if !ok || failure.Err == nil { + t.Fatalf("turn ended without EventError: %v", kinds(collected)) + } + agent.SettleWrites() + calllog.Close() + + sinks := map[string][]byte{ + "EventError": []byte(failure.Err.Error()), + "transcript": readSinkBytes(t, journal), + "call log": readSinkBytes(t, logPath), + "debug record": readSinkBytes(t, + trace.Dir(trace.RunFrom(ctx))), + } + for name, contents := range sinks { + for _, secret := range []string{access, refresh, identity} { + if bytes.Contains(contents, []byte(secret)) { + t.Errorf("%s contains token bytes %q:\n%s", name, secret, contents) + } + } + } +} + +func readSinkBytes(t *testing.T, path string) []byte { + t.Helper() + info, err := os.Stat(path) + if err != nil { + t.Fatalf("read sink %s: %v", path, err) + } + if !info.IsDir() { + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return raw + } + var all []byte + err = filepath.WalkDir(path, func(file string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + return nil + } + raw, err := os.ReadFile(file) + if err != nil { + return err + } + all = append(all, raw...) + return nil + }) + if err != nil { + t.Fatal(err) + } + return all +} + +func turnFailure(t *testing.T, agent *Agent, text string) Event { + t.Helper() + events, err := agent.Submit(context.Background(), text) + if err != nil { + t.Fatal(err) + } + collected := collect(t, events) + failure, ok := firstOfKind(collected, EventError) + if !ok { + t.Fatalf("turn ended without EventError: %v", kinds(collected)) + } + return failure +} diff --git a/internal/session/loop.go b/internal/session/loop.go index 8a936e4c1..4a45afbaf 100644 --- a/internal/session/loop.go +++ b/internal/session/loop.go @@ -81,12 +81,14 @@ import ( "github.com/Agent-Field/agentfield/sdk/go/ai" "github.com/Agent-Field/codeaf/internal/approval" + "github.com/Agent-Field/codeaf/internal/codexauth" "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/ctxbudget" "github.com/Agent-Field/codeaf/internal/effort" "github.com/Agent-Field/codeaf/internal/exec/bare" "github.com/Agent-Field/codeaf/internal/guard" lanes "github.com/Agent-Field/codeaf/internal/lane" + "github.com/Agent-Field/codeaf/internal/modelsource" "github.com/Agent-Field/codeaf/internal/provider" "github.com/Agent-Field/codeaf/internal/redact" "github.com/Agent-Field/codeaf/internal/roles" @@ -2070,6 +2072,13 @@ func (a *Agent) completeWithRetryReasoning(ctx context.Context, hub *eventHub, m // turn that stopped. It is journaled per ATTEMPT, so a ladder of three // reads as a ladder. a.journalFailedCall(ctx, model, "", err, attempt+1, a.requestEstimate()) + // A REFUSED OR REMOVED SIGN-IN CANNOT IMPROVE ON ANOTHER ATTEMPT. The + // transport has already tried the one allowed 401 refresh; asking the + // same dead credential again would turn an actionable sentence into a + // retry storm before arriving at the same answer. + if errors.Is(err, codexauth.ErrSignInExpired) { + return nil, model, endingWords(err, taxonomy.Verdict{}, a.failureServiceWord(model)) + } // A GUARD'S CUT IS A DIFFERENT KIND OF SPENDING, and it is counted apart // from the outright failures rather than answered apart from them. The // request was served and the REPLY came apart, so the loop's own attempt @@ -2260,7 +2269,7 @@ func (a *Agent) completeWithRetryReasoning(ctx context.Context, hub *eventHub, m // (taxonomy_boundary.go's [providerCouldNotServe]) — and with the // person's own words in front of it, because the router's sentence is // not one anybody outside this process can act on ([endingWords]). - return nil, model, endingWords(err, verdict) + return nil, model, endingWords(err, verdict, a.failureServiceWord(model)) case verdict.Retries(): if isCut { hub.send(Event{Kind: EventRetrying, Text: cutNotice(cut), @@ -2315,7 +2324,7 @@ func (a *Agent) completeWithRetryReasoning(ctx context.Context, hub *eventHub, m if isCut { return nil, model, cutFailure(cut, cuts, hopped) } - return nil, model, transportFailure(lastErr, verdict, origin, attempt+1, hopped) + return nil, model, transportFailure(lastErr, verdict, origin, attempt+1, hopped, a.failureServiceWord(model)) } // AND THE LOOP FALLS OUT HERE ONLY WHEN THE DEADLINE WENT WITHOUT A FAILURE // TO READ — every attempt cut short and re-asked until the give-up was gone. @@ -2699,7 +2708,10 @@ func (e *cutGaveUp) Unwrap() error { return e.cut } // the one this build has always ended on — `after 3 retries: …` — which is not // prose anybody loves and IS what several layers out and a good deal of the // record already read, so it is left exactly as it was. -func transportFailure(err error, verdict taxonomy.Verdict, origin string, attempts int, hopped []string) error { +func transportFailure(err error, verdict taxonomy.Verdict, origin string, attempts int, hopped []string, service string) error { + if said, ok := terminalFailureWords(err, service); ok { + return &transportGaveUp{err: err, said: said} + } if len(hopped) == 0 { return fmt.Errorf("after %d retries: %w", attempts-1, err) } @@ -2725,10 +2737,13 @@ func transportFailure(err error, verdict taxonomy.Verdict, origin string, attemp // layer that decides anything about a provider failure decides it from the // error's TYPE, so the typed refusal stays reachable through Unwrap and only the // words on the front change. -func endingWords(err error, verdict taxonomy.Verdict) error { +func endingWords(err error, verdict taxonomy.Verdict, service string) error { if err == nil { return nil } + if said, ok := terminalFailureWords(err, service); ok { + return &transportGaveUp{err: err, said: said} + } said := strings.TrimSpace(transportWords(verdict)) if said == "" { return err @@ -2736,6 +2751,39 @@ func endingWords(err error, verdict taxonomy.Verdict) error { return &transportGaveUp{err: err, said: said} } +// terminalFailureWords preserves the three endings whose typed error carries +// a more useful action than the generic transport taxonomy can. The service is +// resolved by [Agent.failureServiceWord], from the same source set that built +// the client, so a payment refusal names the connection the person chose. +func terminalFailureWords(err error, service string) (string, bool) { + if errors.Is(err, codexauth.ErrSignInExpired) { + return codexauth.ErrSignInExpired.Error(), true + } + if paused, ok := provider.PlanPauseFrom(err); ok { + return provider.PlanPauseSentence(paused.Reset, paused.OverflowDoor), true + } + refusal, ok := provider.RefusalFrom(err) + if !ok || !refusal.AccountCannotPay() || strings.TrimSpace(service) == "" { + return "", false + } + said := strings.TrimSpace(refusal.Message) + if said == "" { + said = strings.TrimSpace(refusal.Body) + } + return config.ConnectionOutcomeWord(service, modelsource.Outcome{ + Kind: modelsource.OutcomeAccountCannotPay, VendorSaid: said, + }), true +} + +// failureServiceWord is the written service name behind the model that failed. +// It asks the source set rather than splitting the slug again, so custom names +// and an unqualified default model are read exactly as the client door read them. +func (a *Agent) failureServiceWord(model string) string { + sources := a.config.Sources.OrDefault(a.config.APIKey, a.config.BaseURL) + service, _ := sources.For(model) + return strings.TrimSpace(service.Source.Written) +} + // transportGaveUp is that sentence WITH the failure still reachable under it, on // [cutGaveUp]'s terms and for its reason: every layer that decides anything about // a provider failure decides it by the error's TYPE (taxonomy_boundary.go's diff --git a/internal/session/plan_doors_test.go b/internal/session/plan_doors_test.go index 65a81aeca..bc8941e4f 100644 --- a/internal/session/plan_doors_test.go +++ b/internal/session/plan_doors_test.go @@ -110,7 +110,15 @@ func TestAnExhaustedPlanWaitsAndSpendsNothing(t *testing.T) { if err != nil { t.Fatal(err) } - for range events { + var failure Event + for event := range events { + if event.Kind == EventError { + failure = event + } + } + const pauseWord = "plan paused · /connect can switch to pay-as-you-go" + if failure.Err == nil || failure.Err.Error() != pauseWord { + t.Fatalf("final EventError = %v, want %q", failure.Err, pauseWord) } if got := metered.Requests(); len(got) != 0 { t.Fatalf("wait mode sent %d requests to the metered host: %+v", len(got), got) @@ -122,6 +130,23 @@ func TestAnExhaustedPlanWaitsAndSpendsNothing(t *testing.T) { } } +func TestAPlanPaymentRefusalEndsARealHeadlessTurnInTheVendorsWords(t *testing.T) { + plan, metered := sourcestub.New(), sourcestub.New() + defer plan.Close() + defer metered.Close() + plan.RefuseCompletion(http.StatusTooManyRequests, + `{"code":"1113","message":"Insufficient balance. Please recharge."}`) + source := planDoorSource(plan, metered) + connected := connectedPlanDoor(source, modelsource.Outcome{Door: source.Doors[0]}, "payment-test-key") + agent := planDoorAgent(t, connected) + + failure := turnFailure(t, agent, "answer without another billing door") + const want = "z-ai accepted the key but the account cannot pay — Insufficient balance. Please recharge." + if failure.Err == nil || failure.Err.Error() != want { + t.Fatalf("final EventError = %v, want %q", failure.Err, want) + } +} + func TestOverflowGoesToTheMeteredDoorAndSaysSo(t *testing.T) { plan, metered := sourcestub.New(), sourcestub.New() defer plan.Close() diff --git a/internal/tui3/feed.go b/internal/tui3/feed.go index f81fba4fa..e39a891f5 100644 --- a/internal/tui3/feed.go +++ b/internal/tui3/feed.go @@ -1,9 +1,11 @@ package tui3 import ( + "errors" "strings" "time" + "github.com/Agent-Field/codeaf/internal/codexauth" "github.com/Agent-Field/codeaf/internal/provider" "github.com/Agent-Field/codeaf/internal/session" ) @@ -1405,6 +1407,9 @@ func (f *feed) retry(ev session.Event) { // person's, and a status number is the one part of that answer nobody can act // on. func (f *feed) failureNote(err error, service string) string { + if errors.Is(err, codexauth.ErrSignInExpired) { + return codexauth.ErrSignInExpired.Error() + } if paused, ok := provider.PlanPauseFrom(err); ok { return provider.PlanPauseSentence(paused.Reset, paused.OverflowDoor) } diff --git a/internal/tui3/paymentnote_test.go b/internal/tui3/paymentnote_test.go index 2ecbb0998..560b78348 100644 --- a/internal/tui3/paymentnote_test.go +++ b/internal/tui3/paymentnote_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" + "github.com/Agent-Field/codeaf/internal/codexauth" "github.com/Agent-Field/codeaf/internal/provider" ) @@ -75,6 +76,13 @@ func TestAPlanPauseEndingDrawsOnlyThePauseSentence(t *testing.T) { forbidden(t, note) } +func TestConnectCodexExpiredSignInDrawsTheActionableSentence(t *testing.T) { + note := (&feed{}).failureNote(fmt.Errorf("request failed: %w", codexauth.ErrSignInExpired), "codex") + if note != codexauth.ErrSignInExpired.Error() { + t.Fatalf("expired sign-in note = %q, want %q", note, codexauth.ErrSignInExpired) + } +} + // forbidden is the vocabulary law for one line: a person-facing sentence may // not carry this program's words for its own machinery, nor a status number // that names nothing they can act on. From 2ae87a6eb8ec867ef52a7045ff97aed0b28084d7 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 21 Sep 2026 17:52:48 -0400 Subject: [PATCH 09/18] config: one constructor for a connected service's catalog, and a law that keeps it the only one The media resolver built a source-scoped catalog without the codex client, so a stale cache could send the persisted sentinel to the real backend. Every connected-service catalog is now built by config.CatalogOptionsFor, which carries the source, address, key, profile and account-aware client together, and a go/ast law refuses any catalog.Options literal outside it that names a source. The media path is exercised against a fake backend. Co-Authored-By: Claude Fable 5.1 --- cmd/codeaf/chatv3_media.go | 5 +- cmd/codeaf/chatv3_media_test.go | 57 ++++++++++++++++ cmd/codeaf/chatv3_modelshelf.go | 10 +-- internal/config/codexclient_law_test.go | 89 +++++++++++++++++++++++++ internal/config/sources.go | 21 +++++- internal/tui3/modelservices.go | 14 ++-- 6 files changed, 171 insertions(+), 25 deletions(-) diff --git a/cmd/codeaf/chatv3_media.go b/cmd/codeaf/chatv3_media.go index e76893348..62d6c13f8 100644 --- a/cmd/codeaf/chatv3_media.go +++ b/cmd/codeaf/chatv3_media.go @@ -85,10 +85,7 @@ func v3CatalogForModel(ctx context.Context, settings config.Config, model string if service.Source.Listing == modelsource.ListingNone { return &catalog.Catalog{}, bare, false } - direct := catalog.LoadLazy(ctx, catalog.Options{ - Source: service.Source.ID, BaseURL: service.Address, APIKey: service.Key, - Dir: settings.ProfileDir, - }) + direct := catalog.LoadLazy(ctx, config.CatalogOptionsFor(service, settings.ProfileDir)) return direct, bare, v3ServesMedia(direct) } diff --git a/cmd/codeaf/chatv3_media_test.go b/cmd/codeaf/chatv3_media_test.go index 40843c330..e0fab25f2 100644 --- a/cmd/codeaf/chatv3_media_test.go +++ b/cmd/codeaf/chatv3_media_test.go @@ -2,14 +2,20 @@ package main import ( "context" + "encoding/json" "errors" "io" "net/http" + "net/http/httptest" "strings" "testing" + "time" "github.com/Agent-Field/codeaf/internal/catalog" + "github.com/Agent-Field/codeaf/internal/codexauth" "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/modelsource" + "github.com/Agent-Field/codeaf/internal/provider" "github.com/Agent-Field/codeaf/internal/roles" "github.com/Agent-Field/codeaf/internal/tui3" ) @@ -36,6 +42,57 @@ func mediaCatalogFor(t *testing.T, rows string) *catalog.Catalog { }) } +func TestV3CatalogForCodexUsesTheAccountListingTransport(t *testing.T) { + // C12 and C18: this is the production v3CatalogForModel road that used to + // omit HTTPClient. The backend receives the rotating bearer and account + // headers, never the persisted sentinel or the generic catalog query. + var requests int + backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + requests++ + if request.URL.Path != "/models" || request.URL.Query().Get("client_version") == "" || request.URL.Query().Get("output_modalities") != "" { + t.Fatalf("catalog request = %s", request.URL.String()) + } + if got := request.Header.Get("Authorization"); got != "Bearer media-catalog-access" || got == "Bearer "+codexauth.Sentinel { + t.Fatalf("catalog authorization = %q", got) + } + if request.Header.Get("chatgpt-account-id") != "media-catalog-account" || + request.Header.Get("originator") != codexauth.Originator || + request.Header.Get("User-Agent") != provider.DirectUserAgent { + t.Fatalf("catalog headers = %v", request.Header) + } + _ = json.NewEncoder(writer).Encode(map[string]any{"models": []any{ + map[string]any{"slug": "gpt-5.5", "visibility": "list"}, + map[string]any{"slug": "hidden", "visibility": "hide"}, + }}) + })) + defer backend.Close() + t.Setenv("CODEAF_CODEX_BACKEND", backend.URL) + dir := t.TempDir() + if err := codexauth.Save(dir, codexauth.Tokens{ + AccessToken: "media-catalog-access", RefreshToken: "media-catalog-refresh", + IDToken: "media-catalog-identity", AccountID: "media-catalog-account", + ExpiresAt: time.Now().Add(time.Hour), + }); err != nil { + t.Fatal(err) + } + listed := true + if err := config.WriteSources(dir, []config.PersistedSource{{ + ID: "codex", Written: "codex", Key: codexauth.Sentinel, Listed: &listed, + }}); err != nil { + t.Fatal(err) + } + settings := config.Config{ProfileDir: dir, Sources: config.ResolveSources(dir, "", config.DefaultBaseURL)} + models, bare, servesMedia := v3CatalogForModel(context.Background(), settings, "codex/gpt-5.5", &catalog.Catalog{}) + rows := models.ModelsNow() + if requests != 1 || bare != "gpt-5.5" || servesMedia || len(rows) != 1 || rows[0].ID != "gpt-5.5" { + t.Fatalf("requests=%d bare=%q media=%t rows=%+v", requests, bare, servesMedia, rows) + } + service, ok := settings.Sources.ByID("codex") + if !ok || service.Source.Listing != modelsource.ListingModels { + t.Fatalf("resolved codex service = %+v, found=%t", service, ok) + } +} + // mediaFixture is one row of every family, published the way OpenRouter // publishes them. const mediaFixture = ` diff --git a/cmd/codeaf/chatv3_modelshelf.go b/cmd/codeaf/chatv3_modelshelf.go index be020b480..32c0053c1 100644 --- a/cmd/codeaf/chatv3_modelshelf.go +++ b/cmd/codeaf/chatv3_modelshelf.go @@ -67,9 +67,7 @@ func (s *v3ModelShelf) setSources(sources modelsource.Set) { } rows := fixedDoorModels(service) if len(rows) == 0 && strings.EqualFold(service.Source.ID, "codex") { - remembered := catalog.Recall(catalog.Options{ - Source: service.Source.ID, BaseURL: service.Address, Dir: s.options.Dir, - }) + remembered := catalog.Recall(config.CatalogOptionsFor(service, s.options.Dir)) rows = v3Models(remembered) } if len(rows) == 0 && service.Source.Listing == modelsource.ListingModels { @@ -149,11 +147,7 @@ func (s *v3ModelShelf) refreshService(ctx context.Context, service modelsource.C if s == nil { return nil, errors.New("there is no model shelf") } - options := s.options - options.Source = service.Source.ID - options.BaseURL = service.Address - options.APIKey = service.Key - options.HTTPClient = config.CatalogHTTPClient(service) + options := config.CatalogOptionsFor(service, s.options.Dir) if len(seed) > 0 { minimal := make([]catalog.Model, 0, len(seed)) for _, model := range seed { diff --git a/internal/config/codexclient_law_test.go b/internal/config/codexclient_law_test.go index f56601a5a..03c614d6f 100644 --- a/internal/config/codexclient_law_test.go +++ b/internal/config/codexclient_law_test.go @@ -60,3 +60,92 @@ func TestC13NoSecondCodexProviderConstructorBypassesClientConfig(t *testing.T) { t.Fatal(err) } } + +func TestC12EverySourceScopedCatalogUsesTheConnectedServiceConstructor(t *testing.T) { + // C12 and C18: Source is the mark of a connected service's private cache + // compartment. Only CatalogOptionsFor may assemble one, so adding a catalog + // call cannot omit the account-aware Codex client while still compiling. + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatal(err) + } + err = filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + if entry.Name() == ".git" || entry.Name() == ".codex-login-spec" { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + set := token.NewFileSet() + file, parseErr := parser.ParseFile(set, path, nil, 0) + if parseErr != nil { + return parseErr + } + catalogNames := make(map[string]bool) + for _, imported := range file.Imports { + value, _ := strconv.Unquote(imported.Path.Value) + if value != "github.com/Agent-Field/codeaf/internal/catalog" { + continue + } + name := "catalog" + if imported.Name != nil { + name = imported.Name.Name + } + catalogNames[name] = true + } + if len(catalogNames) == 0 { + return nil + } + var constructor *ast.FuncDecl + for _, declaration := range file.Decls { + function, ok := declaration.(*ast.FuncDecl) + if ok && function.Name.Name == "CatalogOptionsFor" { + constructor = function + } + } + ast.Inspect(file, func(node ast.Node) bool { + literal, ok := node.(*ast.CompositeLit) + if !ok { + return true + } + selector, ok := literal.Type.(*ast.SelectorExpr) + if !ok || selector.Sel.Name != "Options" { + return true + } + owner, ok := selector.X.(*ast.Ident) + if !ok || !catalogNames[owner.Name] { + return true + } + hasSource := false + for _, element := range literal.Elts { + field, ok := element.(*ast.KeyValueExpr) + if !ok { + continue + } + name, named := field.Key.(*ast.Ident) + if named && name.Name == "Source" { + hasSource = true + break + } + } + if !hasSource { + return true + } + insideConstructor := constructor != nil && literal.Pos() >= constructor.Pos() && literal.End() <= constructor.End() + if !insideConstructor { + t.Errorf("%s sets catalog.Options.Source outside config.CatalogOptionsFor", set.Position(literal.Pos())) + } + return true + }) + return nil + }) + if err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } +} diff --git a/internal/config/sources.go b/internal/config/sources.go index be5bd0279..7439c253b 100644 --- a/internal/config/sources.go +++ b/internal/config/sources.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "io" "net/http" "sort" @@ -126,6 +127,17 @@ func CatalogHTTPClient(service modelsource.Connected) *http.Client { return nil } +// CatalogOptionsFor is the one construction door for a connected service's +// catalog compartment. Keeping the source identity, address, key, profile and +// account-aware client together makes it impossible for a new listing road to +// send Codex's persisted sentinel through the generic HTTP client. +func CatalogOptionsFor(service modelsource.Connected, profileDir string) catalog.Options { + return catalog.Options{ + Source: service.Source.ID, BaseURL: service.Address, APIKey: service.Key, + Dir: profileDir, HTTPClient: CatalogHTTPClient(service), + } +} + func sourceKeyFromRow(row PersistedSource, src modelsource.Source) string { return strings.TrimSpace(firstNonEmpty(env.Value(strings.TrimSpace(src.KeyEnv)), row.Key, env.Value(strings.TrimSpace(row.KeyEnv)))) } @@ -249,9 +261,12 @@ func ConnectCodex(ctx context.Context, profileDir string, tokens codexauth.Token for _, id := range outcome.ModelIDs { remembered = append(remembered, catalog.Model{ID: id, PriceUnknown: true}) } - if err := catalog.Remember(catalog.Options{ - Source: "codex", BaseURL: codexauth.Backend(), Dir: profileDir, - }, remembered); err != nil { + service, found := ResolveSources(profileDir, "", DefaultBaseURL).ByID("codex") + if !found { + _ = DisconnectService(profileDir, "codex") + return modelsource.Outcome{}, errors.New("codex connection was not saved") + } + if err := catalog.Remember(CatalogOptionsFor(service, profileDir), remembered); err != nil { _ = DisconnectService(profileDir, "codex") return modelsource.Outcome{}, err } diff --git a/internal/tui3/modelservices.go b/internal/tui3/modelservices.go index 0d49a5982..b464784ef 100644 --- a/internal/tui3/modelservices.go +++ b/internal/tui3/modelservices.go @@ -606,9 +606,7 @@ func (a *app) beginModelConnect(draft modelConnectDraft) tea.Cmd { for _, model := range models { seed = append(seed, modelcatalog.Model{ID: model.ID}) } - _ = modelcatalog.Remember(modelcatalog.Options{ - Source: instance, BaseURL: connected.Address, Dir: dir, - }, seed) + _ = modelcatalog.Remember(config.CatalogOptionsFor(connected, dir), seed) _ = WriteModelCacheFor(instance, connected.Address, models) } else if refresh != nil { if refreshed, refreshErr := refresh(ctx, connected, models); refreshErr == nil && len(refreshed) > 0 { @@ -619,13 +617,9 @@ func (a *app) beginModelConnect(draft modelConnectDraft) tea.Cmd { for _, model := range models { seed = append(seed, modelcatalog.Model{ID: model.ID}) } - _ = modelcatalog.Remember(modelcatalog.Options{ - Source: instance, BaseURL: connected.Address, Dir: dir, - }, seed) - catalog, refreshErr := modelcatalog.Refresh(ctx, modelcatalog.Options{ - Source: instance, BaseURL: connected.Address, APIKey: connected.Key, Dir: dir, - HTTPClient: config.CatalogHTTPClient(connected), - }) + options := config.CatalogOptionsFor(connected, dir) + _ = modelcatalog.Remember(options, seed) + catalog, refreshErr := modelcatalog.Refresh(ctx, options) if refreshed := surfaceModels(catalog.ModelsNow()); refreshErr == nil && len(refreshed) > 0 { models = refreshed } From 53523aa93d64bfa3ea8266ee24bffab29dcc234c Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 21 Sep 2026 17:52:48 -0400 Subject: [PATCH 10/18] opener: a browser that would not start says so, and the terminal keeps waiting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The opener started the browser inside a goroutine, so a start failure was swallowed and `codeaf connect` could print a link and wait in silence. It now starts synchronously and waits in the guarded goroutine; the terminal commands print `could not open your browser · open the link above` — first run's own spelling, now shared from one constant — and keep waiting on the printed link. Co-Authored-By: Claude Fable 5.1 --- cmd/codeaf/connect.go | 8 +++-- cmd/codeaf/connect_test.go | 59 ++++++++++++++++++++++++++++++++-- internal/opener/opener.go | 10 +++--- internal/opener/opener_test.go | 12 +++++++ internal/tui3/firstrun.go | 3 -- internal/tui3/opener.go | 4 +++ 6 files changed, 85 insertions(+), 11 deletions(-) diff --git a/cmd/codeaf/connect.go b/cmd/codeaf/connect.go index fa356764e..b468b91d3 100644 --- a/cmd/codeaf/connect.go +++ b/cmd/codeaf/connect.go @@ -79,7 +79,9 @@ func connectCodex(ctx context.Context, profileDir string, noBrowser bool) error port := callbackPort(flow.URL()) fmt.Fprintf(usageOut, "on a machine without a browser: ssh -L %s:localhost:%s and open the link here\n", port, port) } else { - _ = connectOpen(flow.URL()) + if err := connectOpen(flow.URL()); err != nil { + fmt.Fprintln(usageOut, opener.BrowserFailureWord) + } } tokens, err := flow.Wait(ctx) if err != nil { @@ -113,7 +115,9 @@ func connectOpenRouter(ctx context.Context, profileDir string, noBrowser bool) e defer flow.Cancel() fmt.Fprintln(usageOut, flow.URL()) if !noBrowser { - _ = connectOpen(flow.URL()) + if err := connectOpen(flow.URL()); err != nil { + fmt.Fprintln(usageOut, opener.BrowserFailureWord) + } } key, err := flow.Wait(ctx) if err != nil { diff --git a/cmd/codeaf/connect_test.go b/cmd/codeaf/connect_test.go index 2c451c22f..f1de93284 100644 --- a/cmd/codeaf/connect_test.go +++ b/cmd/codeaf/connect_test.go @@ -14,6 +14,7 @@ import ( "github.com/Agent-Field/codeaf/internal/codexauth" "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/modelsource" + "github.com/Agent-Field/codeaf/internal/opener" ) type fakeCodexConnect struct { @@ -134,6 +135,59 @@ func TestC7ConnectOpenRouterReusesBrowserRoadAndProfileKey(t *testing.T) { } } +func TestConnectCommandsReportABrowserStartFailureAndKeepWaiting(t *testing.T) { + // C1: the link remains usable when its automatic handoff fails. Both + // browser services print the first-run recovery line, then accept the flow's + // successful return rather than abandoning a sign-in already in progress. + t.Run("codex", func(t *testing.T) { + dir := t.TempDir() + t.Setenv(config.ProfileDirEnv, dir) + backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + _, _ = writer.Write([]byte(`{"models":[{"slug":"gpt-5.5","visibility":"list"}]}`)) + })) + defer backend.Close() + t.Setenv("CODEAF_CODEX_BACKEND", backend.URL) + flow := &fakeCodexConnect{ + address: "https://auth.example/sign-in", + tokens: codexauth.Tokens{ + AccessToken: "browser-failure-access", RefreshToken: "browser-failure-refresh", + IDToken: "browser-failure-identity", ExpiresAt: time.Now().Add(time.Hour), + }, + } + oldFlow, oldOpen := connectCodexFlow, connectOpen + connectCodexFlow = func(context.Context) (codexConnectFlow, error) { return flow, nil } + connectOpen = func(string) error { return errors.New("exec: xdg-open not found") } + t.Cleanup(func() { connectCodexFlow, connectOpen = oldFlow, oldOpen }) + output, restore := captureConnect(t) + defer restore() + if err := runConnect([]string{"codex"}); err != nil { + t.Fatal(err) + } + if !strings.Contains(output.String(), opener.BrowserFailureWord) || !strings.Contains(output.String(), "codex connected") { + t.Fatalf("connect output = %q", output.String()) + } + }) + + t.Run("openrouter", func(t *testing.T) { + dir := t.TempDir() + t.Setenv(config.ProfileDirEnv, dir) + oldFlow, oldOpen := connectOpenRouterFlow, connectOpen + connectOpenRouterFlow = func(context.Context) (openRouterConnectFlow, error) { + return &fakeOpenRouterConnect{address: "https://openrouter.example/sign-in", key: "sk-or-v1-browser-failure-value"}, nil + } + connectOpen = func(string) error { return errors.New("exec: xdg-open not found") } + t.Cleanup(func() { connectOpenRouterFlow, connectOpen = oldFlow, oldOpen }) + output, restore := captureConnect(t) + defer restore() + if err := runConnect([]string{modelsource.DefaultID}); err != nil { + t.Fatal(err) + } + if !strings.Contains(output.String(), opener.BrowserFailureWord) || !strings.Contains(output.String(), "openrouter connected") { + t.Fatalf("connect output = %q", output.String()) + } + }) +} + func TestC8ConnectWithoutAServiceListsMethodsAndNeverDrawsNothing(t *testing.T) { // C8: the no-argument door lists each known service and says explicitly when none is connected. dir := t.TempDir() @@ -223,8 +277,9 @@ func TestC11ConnectHelpIsLiftedFromTheEightyColumnTable(t *testing.T) { } } -func TestC19FirstRunStillExposesOnlyItsOpenRouterBrowserRoad(t *testing.T) { - // C19: Part A adds no Codex first-run seam; the existing constructor remains the only browser offer. +func TestFirstRunStillBuildsItsOpenRouterBrowserRoad(t *testing.T) { + // The rendered C19 contract lives with the surface in internal/tui3. This + // pins the production command seam that hands that surface its browser flow. settings := config.Config{BaseURL: config.DefaultBaseURL} if v3OpenRouterConnection(settings, true) == nil { t.Fatal("first-run OpenRouter browser connection disappeared") diff --git a/internal/opener/opener.go b/internal/opener/opener.go index 2242b4021..e5116e6b5 100644 --- a/internal/opener/opener.go +++ b/internal/opener/opener.go @@ -12,6 +12,10 @@ import ( "github.com/Agent-Field/codeaf/internal/guard" ) +// BrowserFailureWord is the recovery line shared by first run and terminal +// connection commands. The link is already visible immediately above it. +const BrowserFailureWord = "could not open your browser · open the link above" + // Command reports the platform program that opens a target. Linux answers // xdg-open exactly as the chat surface always has, so a machine where that // works keeps working; a WSL box that has no xdg-open at all falls to wslview, @@ -43,13 +47,11 @@ func Start(target string) error { return errors.New("the browser did not open") } command := exec.Command(name, append(append([]string(nil), args...), target)...) - if command.Err != nil { + if err := command.Start(); err != nil { return errors.New("the browser did not open") } guard.Go("opener/start", func() { - if command.Start() == nil { - _ = command.Wait() - } + _ = command.Wait() }) return nil } diff --git a/internal/opener/opener_test.go b/internal/opener/opener_test.go index 65d554713..3e420c901 100644 --- a/internal/opener/opener_test.go +++ b/internal/opener/opener_test.go @@ -31,3 +31,15 @@ func TestWSLFallsToTheDesktopBridgeOnlyWithoutXdgOpen(t *testing.T) { t.Fatalf("plain Linux opener = %q, want xdg-open", name) } } + +func TestStartReportsWhenTheBrowserProcessCannotStart(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("the test controls the Linux opener through PATH") + } + t.Setenv("PATH", t.TempDir()) + t.Setenv("WSL_DISTRO_NAME", "") + err := Start("https://example.test/sign-in") + if err == nil || err.Error() != "the browser did not open" { + t.Fatalf("Start error = %v, want a synchronous browser start failure", err) + } +} diff --git a/internal/tui3/firstrun.go b/internal/tui3/firstrun.go index a19e32d8d..b1bcf84cf 100644 --- a/internal/tui3/firstrun.go +++ b/internal/tui3/firstrun.go @@ -655,9 +655,6 @@ const ( // setupConnectFailedWord is the browser sign-in that never started: the // listener, the flow, the round trip to openrouter. setupConnectFailedWord = "could not reach openrouter to start the sign-in — check the network, or paste a key instead" - // setupBrowserWord is the browser that would not open. The link is on the - // screen directly above it, which is the whole of what to do about it. - setupBrowserWord = "could not open your browser · open the link above" // setupSignInLostWord is the trip that started and did not come back — // closed tab, refused page, a connection that went away mid-flight. setupSignInLostWord = "the browser sign-in did not finish — enter tries again, or paste a key instead" diff --git a/internal/tui3/opener.go b/internal/tui3/opener.go index 527235f33..1f76151c7 100644 --- a/internal/tui3/opener.go +++ b/internal/tui3/opener.go @@ -34,6 +34,10 @@ import ( // internal/tui's. var processOpener = opener.Start +// setupBrowserWord shares the terminal connection command's recovery line, so +// a browser start failure has one spelling wherever the visible link lives. +const setupBrowserWord = opener.BrowserFailureWord + // openerCommand is what this platform calls "open this". An empty name is a // platform with no answer, which is a fact the caller reports rather than // papers over. From 5dab4301ea7c2e3882ed292b14fcf5d9199de4d8 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 21 Sep 2026 17:52:48 -0400 Subject: [PATCH 11/18] codexauth: the listener test records 1455 then 1457 rather than trusting the advertised address Co-Authored-By: Claude Fable 5.1 --- internal/codexauth/flow_test.go | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/internal/codexauth/flow_test.go b/internal/codexauth/flow_test.go index e07545d55..1190f7bd1 100644 --- a/internal/codexauth/flow_test.go +++ b/internal/codexauth/flow_test.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "encoding/base64" "encoding/json" + "errors" "io" "net" "net/http" @@ -64,6 +65,37 @@ func TestC2AuthorizeAddressCarriesOnlyTheCodexCLIContract(t *testing.T) { } } +func TestC2ListenerRequestsTheRegisteredPortsInOrder(t *testing.T) { + // C2: the listener seam receives the actual registered addresses. It may + // supply an ephemeral loopback socket for the callback drive, but it cannot + // hide a production change from 1455 followed by 1457. + var requested []string + listen := func(network, address string) (net.Listener, error) { + if network != "tcp" { + t.Fatalf("listener network = %q", network) + } + requested = append(requested, address) + if len(requested) == 1 { + return nil, errors.New("primary port busy") + } + return net.Listen("tcp", "127.0.0.1:0") + } + flow, err := Begin(context.Background(), Options{ + Issuer: "https://issuer.example", Random: strings.NewReader(strings.Repeat("p", 64)), Listen: listen, + }) + if err != nil { + t.Fatal(err) + } + defer flow.Cancel() + if strings.Join(requested, ",") != "127.0.0.1:1455,127.0.0.1:1457" { + t.Fatalf("requested listener addresses = %v", requested) + } + parsed, _ := url.Parse(flow.URL()) + if got := parsed.Query().Get("redirect_uri"); got != "http://localhost:1457/auth/callback" { + t.Fatalf("fallback redirect = %q", got) + } +} + func sha256Text(value string) string { sum := sha256.Sum256([]byte(value)) return base64.RawURLEncoding.EncodeToString(sum[:]) From 0bb1aa532c170c867fc8aa05174525a388fdfd41 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 21 Sep 2026 17:52:49 -0400 Subject: [PATCH 12/18] tui3: first run is rendered and asserted to offer OpenRouter and no Codex Co-Authored-By: Claude Fable 5.1 --- internal/tui3/firstrun_test.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/internal/tui3/firstrun_test.go b/internal/tui3/firstrun_test.go index e6a7ee70e..916696c0b 100644 --- a/internal/tui3/firstrun_test.go +++ b/internal/tui3/firstrun_test.go @@ -120,6 +120,19 @@ func TestTheSetupOpensOverAnEmptyProfileAndNotOverAConfiguredOne(t *testing.T) { } } +func TestC19FirstrunRendersOpenRouterAndNoCodexOffer(t *testing.T) { + // C19: this is the frame a person sees on a fresh profile, not merely a + // constructor seam. Codex belongs behind /connect and is absent here. + a, _, _ := setupApp(t, nil) + screen := setupScreen(a) + if !strings.Contains(screen, "openrouter") { + t.Fatalf("first-run setup lost its OpenRouter offer:\n%s", screen) + } + if strings.Contains(strings.ToLower(screen), "codex") { + t.Fatalf("first-run setup exposed Codex:\n%s", screen) + } +} + func TestEnterConnectsOpenRouterInTheBrowserAndHandsTheKeyToThisProcess(t *testing.T) { a, dir, handed := setupApp(t, nil) flow := &setupOpenRouterFlow{ From 2102845e0e58a67a22c57c71a94cb2d2d0b47f06 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 21 Sep 2026 17:52:49 -0400 Subject: [PATCH 13/18] docs/changes: only base-to-final truths, plus the WSL opener and the headless refusal wording (#1336) Co-Authored-By: Claude Fable 5.1 --- docs/changes/unreleased/1336-codex-connect.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/changes/unreleased/1336-codex-connect.md b/docs/changes/unreleased/1336-codex-connect.md index 2e8dd13fd..afd9763e2 100644 --- a/docs/changes/unreleased/1336-codex-connect.md +++ b/docs/changes/unreleased/1336-codex-connect.md @@ -5,12 +5,11 @@ pr: 1336 surface: [chat, engine, docs] invalidates: - "Codex was not available as a connected model service. A ChatGPT plan now signs in from `/connect` or `codeaf connect codex` and its account models appear as `codex/`." - - "A generic Codex model-list refresh could send the persisted `chatgpt` sentinel as a bearer. Every listing road now replaces it with the rotating account bearer and required account headers before reaching the backend." - "Every non-keyless model-service row opened key entry. The Codex row now says `browser` and uses the waiting card, copy affordance, and preferred-model move." - - "The terminal command and chat panel owned separate model-connection outcome sentences. Both now read the same formatter in `internal/config`." - - "The chat manual omitted `codeaf connect` and `codeaf disconnect`, and its terminal-verb gate temporarily exempted them. Both verbs are now documented and checked like every other terminal verb." - "A headless command refused to start with no OpenRouter key even when another service was connected. It now starts when any connected service holds its credential; a call to a service without one still fails when it is made." - "Connected-service documentation covered API-key services but not a ChatGPT plan's account list, limits, expiry, or unknown price. The Codex pages now state those boundaries and token-only accounting." + - "On a WSL box without `xdg-open`, opening a link failed. It now opens through `wslview`; every other machine keeps `xdg-open`." + - "A headless run ended a plan refusal with `your key was not accepted for this model`, for Z.ai as much as for Codex, while the chat quoted the vendor's own words. Both roads now end it with the same sentence: the service, that the account cannot pay, and what the vendor said; a spent fixed-price window says when it resets; an expired Codex sign-in says how to sign in again." --- Codex uses the account's own visible model list and never exposes the persisted sentinel or rotating tokens to the catalog or screen. First run remains OpenRouter-only; device-code sign-in, OpenAI API-key sign-in, Codex base instructions, websockets, connector scopes, and the resident remain out of scope. From 142520031add2dc8de328bda8b838c4ef33d5591 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 21 Sep 2026 18:51:56 -0400 Subject: [PATCH 14/18] codexauth: a refresh is bounded, and a waiter can leave A refresh could wait forever behind another process's lock while that process sat in an issuer round trip with no deadline, and a cancelled turn could not leave the wait. The issuer exchange now runs under its own thirty-second deadline derived from the request's context; both the in-process mutex and the cross-process file lock are taken without blocking, on a short cadence, and given up when the context ends or after a minute with one plain sentence (`another codeaf is refreshing the codex sign-in and has not finished`); an existing lock file is tightened to owner-only mode. A hung issuer and a cancelled waiter are each proven with the token file byte-identical after. Co-Authored-By: Claude Fable 5.1 --- internal/codexauth/transport.go | 83 ++++++++++++++++++++++--- internal/codexauth/transport_test.go | 91 +++++++++++++++++++++++++++- 2 files changed, 165 insertions(+), 9 deletions(-) diff --git a/internal/codexauth/transport.go b/internal/codexauth/transport.go index d99cd2932..95ba81ecf 100644 --- a/internal/codexauth/transport.go +++ b/internal/codexauth/transport.go @@ -29,6 +29,14 @@ var refreshLocks struct { byPath map[string]*sync.Mutex } +const ( + refreshExchangeTimeout = 30 * time.Second + refreshLockTimeout = 60 * time.Second + refreshLockCadence = 25 * time.Millisecond +) + +var errRefreshAlreadyRunning = errors.New("another codeaf is refreshing the codex sign-in and has not finished") + // Client returns the refreshing and translating client for one profile. func Client(profileDir string) *http.Client { return ClientWithOptions(profileDir, Options{}) } @@ -342,9 +350,13 @@ func lockFor(path string) *sync.Mutex { func (t *transport) fresh(ctx context.Context, force bool, rejected string) (Tokens, error) { mutex := lockFor(Path(t.profileDir)) - mutex.Lock() + lockCtx, cancelLock := context.WithTimeout(ctx, refreshLockTimeout) + defer cancelLock() + if err := lockRefreshMutex(lockCtx, mutex); err != nil { + return Tokens{}, err + } defer mutex.Unlock() - lock, err := lockTokenFile(t.profileDir) + lock, err := lockTokenFile(lockCtx, t.profileDir) if err != nil { return Tokens{}, err } @@ -374,7 +386,9 @@ func (t *transport) fresh(ctx context.Context, force bool, rejected string) (Tok "refresh_token": {tokens.RefreshToken}, "client_id": {ClientID}, } - request, err := http.NewRequestWithContext(ctx, http.MethodPost, t.options.issuer()+tokenPath, strings.NewReader(form.Encode())) + refreshCtx, cancelRefresh := context.WithTimeout(ctx, refreshExchangeTimeout) + defer cancelRefresh() + request, err := http.NewRequestWithContext(refreshCtx, http.MethodPost, t.options.issuer()+tokenPath, strings.NewReader(form.Encode())) if err != nil { return Tokens{}, err } @@ -430,11 +444,38 @@ func (t *transport) fresh(ctx context.Context, force bool, rejected string) (Tok return Load(t.profileDir) } +// lockRefreshMutex gives callers in this process the same cancellation and +// patience as the file lock below. A plain sync.Mutex would strand an already +// cancelled turn behind the network request that currently owns the refresh. +func lockRefreshMutex(ctx context.Context, mutex *sync.Mutex) error { + ticker := time.NewTicker(refreshLockCadence) + defer ticker.Stop() + for { + if ctx.Err() != nil { + return errRefreshAlreadyRunning + } + if mutex.TryLock() { + if ctx.Err() == nil { + return nil + } + mutex.Unlock() + return errRefreshAlreadyRunning + } + select { + case <-ctx.Done(): + return errRefreshAlreadyRunning + case <-ticker.C: + } + } +} + // lockTokenFile extends the in-process refresh mutex across codeaf processes. // The sidecar is never removed: the operating system owns the live lock, and a // process exit releases it without a stale-file protocol. The token file is -// re-read only after this returns, so a waiter sees whichever refresh won. -func lockTokenFile(profileDir string) (*os.File, error) { +// re-read only after this returns, so a waiter sees whichever refresh won. A +// non-blocking attempt keeps cancellation observable while another process is +// inside its issuer round trip. +func lockTokenFile(ctx context.Context, profileDir string) (*os.File, error) { path := Path(profileDir) if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return nil, fmt.Errorf("refresh codex sign-in: make profile directory: %w", err) @@ -443,11 +484,37 @@ func lockTokenFile(profileDir string) (*os.File, error) { if err != nil { return nil, fmt.Errorf("refresh codex sign-in: open token lock: %w", err) } - if err := filelock.Lock(lock, true, false); err != nil { + if err := lock.Chmod(0o600); err != nil { _ = lock.Close() - return nil, fmt.Errorf("refresh codex sign-in: lock tokens: %w", err) + return nil, fmt.Errorf("refresh codex sign-in: protect token lock: %w", err) + } + ticker := time.NewTicker(refreshLockCadence) + defer ticker.Stop() + for { + if ctx.Err() != nil { + _ = lock.Close() + return nil, errRefreshAlreadyRunning + } + err := filelock.Lock(lock, true, true) + if err == nil { + if ctx.Err() == nil { + return lock, nil + } + _ = filelock.Unlock(lock) + _ = lock.Close() + return nil, errRefreshAlreadyRunning + } + if !filelock.IsBusy(err) { + _ = lock.Close() + return nil, fmt.Errorf("refresh codex sign-in: lock tokens: %w", err) + } + select { + case <-ctx.Done(): + _ = lock.Close() + return nil, errRefreshAlreadyRunning + case <-ticker.C: + } } - return lock, nil } func translateRequest(profileDir string, raw []byte) ([]byte, bool, error) { diff --git a/internal/codexauth/transport_test.go b/internal/codexauth/transport_test.go index 0a5198505..f6f6daa01 100644 --- a/internal/codexauth/transport_test.go +++ b/internal/codexauth/transport_test.go @@ -451,7 +451,7 @@ func TestC16RotatingRefreshIsSafeAcrossProcesses(t *testing.T) { })) defer server.Close() - gate, err := lockTokenFile(dir) + gate, err := lockTokenFile(context.Background(), dir) if err != nil { t.Fatal(err) } @@ -503,6 +503,95 @@ func TestC16RotatingRefreshIsSafeAcrossProcesses(t *testing.T) { } } +func TestC16HungRefreshEndsAtTheRequestDeadlineWithoutChangingTokens(t *testing.T) { + now := time.Now() + dir := t.TempDir() + tokens := validTokens(now) + tokens.ExpiresAt = now.Add(-time.Minute) + if err := Save(dir, tokens); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(Path(dir)) + if err != nil { + t.Fatal(err) + } + issuer := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + time.Sleep(250 * time.Millisecond) + })) + defer issuer.Close() + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + started := time.Now() + _, err = List(ctx, dir, Options{ + Issuer: issuer.URL, Backend: issuer.URL, HTTPClient: issuer.Client(), + Now: func() time.Time { return now }, + }) + if err == nil || !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("hung issuer error = %v, want the request deadline", err) + } + if elapsed := time.Since(started); elapsed > 500*time.Millisecond { + t.Fatalf("hung issuer returned after %s, want a bounded refresh", elapsed) + } + after, err := os.ReadFile(Path(dir)) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(after, before) { + t.Fatalf("hung issuer changed token bytes:\nbefore=%s\nafter=%s", before, after) + } +} + +func TestC16CancelledRefreshWaiterLeavesPromptlyAndTheLockIsOwnerOnly(t *testing.T) { + dir := t.TempDir() + lockPath := Path(dir) + ".lock" + if err := os.WriteFile(lockPath, nil, 0o666); err != nil { + t.Fatal(err) + } + if err := os.Chmod(lockPath, 0o666); err != nil { + t.Fatal(err) + } + owner, err := lockTokenFile(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + defer func() { + _ = filelock.Unlock(owner) + _ = owner.Close() + }() + info, err := owner.Stat() + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("refresh lock mode = %04o, want 0600", got) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + type result struct { + file *os.File + err error + } + done := make(chan result, 1) + go func() { + file, waitErr := lockTokenFile(ctx, dir) + done <- result{file: file, err: waitErr} + }() + select { + case answer := <-done: + if answer.file != nil { + _ = filelock.Unlock(answer.file) + _ = answer.file.Close() + t.Fatal("an already-cancelled waiter acquired the refresh lock") + } + if answer.err == nil || answer.err.Error() != errRefreshAlreadyRunning.Error() { + t.Fatalf("cancelled waiter error = %v, want %q", answer.err, errRefreshAlreadyRunning) + } + case <-time.After(500 * time.Millisecond): + t.Fatal("an already-cancelled waiter remained behind the refresh owner") + } +} + func waitForRefreshSignal(t *testing.T, path string) { t.Helper() deadline := time.Now().Add(30 * time.Second) From 2d03ecbbe1cb493bb3e7ad5e8650f8145765fe8c Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 21 Sep 2026 18:51:56 -0400 Subject: [PATCH 15/18] provider, session, taxonomy: exact ingress scrubbing, expiry accounted once, sinks scrub assembled text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three boundary gaps from the second review. Provider's error ingress ran the broad shape scrub on every body, so a diagnostic that merely resembled a key changed bytes and a large media error paid for it: it now removes only the credentials this process registered, byte-preserving when none are, and only on bodies up to eight megabytes; the shape scrub stays at the sinks people share. An expired Codex sign-in short-circuited the turn loop and skipped the taxonomy row and the wire tally: the taxonomy now carries a terminal-transport fact that reports once, the shortcut is gone, and one attempt, one journal line, one row and one tally are asserted. The transcript journal and the error event scrub their assembled text at write time, so a credential that crossed the wire in pieces cannot be recreated in a file — the journal removes only registered credentials, because a key a person pasted into their own message is theirs to keep and must replay on resume exactly as written. Co-Authored-By: Claude Fable 5.1 --- internal/provider/apierror_test.go | 36 ++++++ internal/provider/client.go | 17 ++- internal/provider/refusalobject.go | 2 +- internal/session/agent.go | 27 +++++ internal/session/codex_client_test.go | 158 ++++++++++++++++++++++++-- internal/session/loop.go | 7 -- internal/session/sessionfile.go | 10 ++ internal/taxonomy/policy.go | 8 ++ internal/taxonomy/taxonomy.go | 8 +- internal/trace/scrub.go | 24 +++- 10 files changed, 269 insertions(+), 28 deletions(-) diff --git a/internal/provider/apierror_test.go b/internal/provider/apierror_test.go index 3fbd506ac..01d3bfdcc 100644 --- a/internal/provider/apierror_test.go +++ b/internal/provider/apierror_test.go @@ -1,9 +1,13 @@ package provider import ( + "bytes" "errors" "fmt" + "strings" "testing" + + "github.com/Agent-Field/codeaf/internal/trace" ) // A refusal is a value now, and the two things about it that are facts rather @@ -79,3 +83,35 @@ func TestAPIErrorKeepsAnUndecodableBodyWhole(t *testing.T) { t.Fatalf("body = %q", refused.Body) } } + +func TestAPIErrorIngressScrubsOnlyRegisteredSecretsWithinItsBodyBound(t *testing.T) { + shaped := []byte(`{"error":{"message":"sk-diagnostic-shape-not-a-secret and Bearer diagnostic-token"}}`) + var refused *APIError + if err := apiError(400, shaped); !errors.As(err, &refused) { + t.Fatal("a provider refusal is not recoverable as a value") + } + if refused.Body != string(shaped) { + t.Fatalf("an empty secret registry changed ingress bytes:\ngot %q\nwant %q", refused.Body, shaped) + } + + const secret = "registered-provider-secret-4096" + trace.Secret(secret) + registered := []byte(`{"error":{"message":"vendor echoed ` + secret + `"}}`) + refused = nil + if err := apiError(400, registered); !errors.As(err, &refused) { + t.Fatal("a provider refusal is not recoverable as a value") + } + if strings.Contains(refused.Body, secret) || !strings.Contains(refused.Body, "[redacted]") { + t.Fatalf("registered secret reached APIError.Body: %q", refused.Body) + } + + large := bytes.Repeat([]byte{'x'}, 9<<20) + copy(large[1024:], secret) + refused = nil + if err := apiError(502, large); !errors.As(err, &refused) { + t.Fatal("a provider refusal is not recoverable as a value") + } + if !bytes.Equal([]byte(refused.Body), large) { + t.Fatal("a 9 MiB media body was changed at API error ingress") + } +} diff --git a/internal/provider/client.go b/internal/provider/client.go index cb9c4344b..a37086ea6 100644 --- a/internal/provider/client.go +++ b/internal/provider/client.go @@ -2652,6 +2652,11 @@ type errorBody struct { // of any real provider error and is bounded enough to sit on every failed call. const maxRawClip = 2 << 10 +// maxAPIErrorScrubBody bounds exact-secret work at semantic ingress. The Codex +// transport's error bodies are small; larger bodies belong to media roads and +// are scrubbed by each output sink if somebody elects to write them. +const maxAPIErrorScrubBody = 8 << 20 + // maxSentenceClip bounds the ONE SENTENCE a person is shown. It is a line in a // terminal beside a status code, not a report. const maxSentenceClip = 160 @@ -2662,11 +2667,13 @@ const maxSentenceClip = 160 // nothing until it says which provider and what they said, and both are in the // metadata OpenRouter already sends (see [APIError]). func apiError(status int, payload []byte) error { - // A PROVIDER'S ERROR BODY IS AN OBSERVABLE SINK. Connected transports - // register every credential they hold, and an upstream is free to echo a - // bearer inside this payload; scrub it before Body, Message or Raw can reach - // the journal, the call log, diagnostics, or a surface. - payload = trace.Scrub(payload) + // SEMANTIC INGRESS REMOVES ONLY CREDENTIALS THIS PROCESS KNOWS. A diagnostic + // that happens to resemble an sk-key or a bearer remains byte-for-byte what + // the provider said; the call log, debug record and other output sinks keep + // the broader shape scrub that protects files a person may share. + if len(payload) <= maxAPIErrorScrubBody { + payload = trace.ScrubRegistered(payload) + } failure := &APIError{Status: status, Body: string(payload)} var decoded errorBody if err := json.Unmarshal(payload, &decoded); err == nil { diff --git a/internal/provider/refusalobject.go b/internal/provider/refusalobject.go index 5d9116e56..8a2ca3c01 100644 --- a/internal/provider/refusalobject.go +++ b/internal/provider/refusalobject.go @@ -504,7 +504,7 @@ func (c *Client) refuseServing(model string, refusal laneRefusal) { // a stream guard cut, how many attempts this model has had, whether the caller // has another model — and those are the caller's to add before it classifies. func Evidence(err error) taxonomy.Evidence { - evidence := taxonomy.Evidence{} + evidence := taxonomy.Evidence{TerminalTransport: terminalTransportFailureFrom(err)} if err == nil { return evidence } diff --git a/internal/session/agent.go b/internal/session/agent.go index f58af9432..a66a8d56b 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -17,6 +17,7 @@ import ( "github.com/Agent-Field/codeaf/internal/modelsource" "github.com/Agent-Field/codeaf/internal/provider" "github.com/Agent-Field/codeaf/internal/roles" + "github.com/Agent-Field/codeaf/internal/trace" ) // defaultContextWindow is the window assumed when Config.ContextWindow is @@ -4241,6 +4242,9 @@ func newEventStream() *eventStream { } func (s *eventStream) send(event Event) { + if event.Kind == EventError && event.Err != nil { + event.Err = scrubEventError(event.Err) + } s.mu.Lock() // A STREAM THE READER LEFT IS NOT QUEUED INTO. Dropping here is the point of // leaving: everything this fan-out is careful never to drop is careful on @@ -4252,6 +4256,29 @@ func (s *eventStream) send(event Event) { s.mu.Unlock() } +type scrubbedEventError struct { + cause error + text string +} + +func (e *scrubbedEventError) Error() string { return e.text } +func (e *scrubbedEventError) Unwrap() error { return e.cause } + +// scrubEventError is the last boundary before an EventError becomes visible. +// It preserves the typed cause for readers using errors.Is or errors.As while +// ensuring the sentence a surface receives contains no assembled credential. +func scrubEventError(err error) error { + if err == nil { + return nil + } + said := err.Error() + clean := string(trace.Scrub([]byte(said))) + if clean == said { + return err + } + return &scrubbedEventError{cause: err, text: clean} +} + func (s *eventStream) close() { s.mu.Lock() s.closed = true diff --git a/internal/session/codex_client_test.go b/internal/session/codex_client_test.go index 6e709ff09..469ade031 100644 --- a/internal/session/codex_client_test.go +++ b/internal/session/codex_client_test.go @@ -19,6 +19,7 @@ import ( "github.com/Agent-Field/codeaf/internal/calllog" "github.com/Agent-Field/codeaf/internal/codexauth" account "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/taxonomy" "github.com/Agent-Field/codeaf/internal/trace" ) @@ -123,18 +124,20 @@ func TestCodexExpiredOrRemovedSignInEndsARealTurnWithTheRecoverySentence(t *test } { t.Run(testCase.name, func(t *testing.T) { var issuerCalls atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - if request.URL.Path == "/oauth/token" { - issuerCalls.Add(1) - writer.WriteHeader(http.StatusUnauthorized) - _, _ = fmt.Fprintln(writer, `{"error":"invalid_grant"}`) - return - } - t.Fatalf("expired sign-in reached backend path %q", request.URL.Path) + issuer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + issuerCalls.Add(1) + writer.WriteHeader(http.StatusUnauthorized) + _, _ = fmt.Fprintln(writer, `{"error":"invalid_grant"}`) + })) + defer issuer.Close() + var backendCalls atomic.Int32 + backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + backendCalls.Add(1) + http.Error(writer, "an expired sign-in must not reach the backend", http.StatusInternalServerError) })) - defer server.Close() - t.Setenv("CODEAF_CODEX_ISSUER", server.URL) - t.Setenv("CODEAF_CODEX_BACKEND", server.URL) + defer backend.Close() + t.Setenv("CODEAF_CODEX_ISSUER", issuer.URL) + t.Setenv("CODEAF_CODEX_BACKEND", backend.URL) profile := t.TempDir() expires := time.Now().Add(-time.Minute) if testCase.remove { @@ -152,14 +155,20 @@ func TestCodexExpiredOrRemovedSignInEndsARealTurnWithTheRecoverySentence(t *test }}); err != nil { t.Fatal(err) } + journal := filepath.Join(t.TempDir(), "session.jsonl") + tally := &taxonomy.Tally{} agent, err := New(Config{ Workspace: t.TempDir(), Model: "codex/gpt-5.5", - Sources: account.ResolveSources(profile, "", account.DefaultBaseURL), + Sources: account.ResolveSources(profile, "", account.DefaultBaseURL), SessionFile: journal, + failures: tally, }) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = agent.Close() }) + if !agent.setTitleIfUnnamed("expiry accounting test") { + t.Fatal("fresh session already had a title") + } if testCase.remove { if err := os.Remove(codexauth.Path(profile)); err != nil { t.Fatal(err) @@ -176,6 +185,20 @@ func TestCodexExpiredOrRemovedSignInEndsARealTurnWithTheRecoverySentence(t *test if issuerCalls.Load() != wantIssuerCalls { t.Fatalf("issuer calls = %d, want %d", issuerCalls.Load(), wantIssuerCalls) } + if backendCalls.Load() != 0 { + t.Fatalf("expired sign-in reached the backend %d times, want none", backendCalls.Load()) + } + if rows := journaledEntries(t, journal, "error"); len(rows) != 1 { + t.Fatalf("provider attempts in the journal = %d, want one", len(rows)) + } + rows := journaledFailures(t, journal) + if len(rows) != 1 || rows[0].Class != string(taxonomy.Transport) || rows[0].Action != string(taxonomy.ActionReport) { + t.Fatalf("expiry taxonomy rows = %+v, want one terminal transport report", rows) + } + wire, semantic, tainted := tally.Counts() + if wire != 1 || semantic != 0 || tainted != 0 { + t.Fatalf("expiry failure tally = wire %d semantic %d tainted %d, want 1/0/0", wire, semantic, tainted) + } }) } } @@ -260,6 +283,117 @@ func TestCodexEchoedBearerNeverReachesAnyObservableFailureSink(t *testing.T) { } } +func TestCodexAccessTokenSplitAcrossTextDeltasNeverReachesAnyCompletedSink(t *testing.T) { + // G6: per-event redaction cannot see this token. The completed transcript, + // EventError, call log, and debug record each scrub the text they actually + // write after the provider has assembled it. + const access = "codex-split-sink-access-secret" + var backendCalls atomic.Int32 + backend := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Header.Get("Authorization") != "Bearer "+access { + t.Errorf("backend bearer = %q", request.Header.Get("Authorization")) + } + writer.Header().Set("Content-Type", "text/event-stream") + if backendCalls.Add(1) > 1 { + failed, _ := json.Marshal(map[string]any{ + "type": "response.failed", + "response": map[string]any{"error": map[string]any{ + "message": "usage_limit_reached after Bearer " + access, "type": "server_error", "code": "rate_limit_exceeded", + }}, + }) + fmt.Fprintf(writer, "data: %s\n\n", failed) + return + } + fmt.Fprintln(writer, `data: {"type":"response.created","response":{"id":"split-1","model":"gpt-5.5","created_at":1800000000}}`) + fmt.Fprintln(writer) + for _, part := range []string{access[:7], access[7:19], access[19:]} { + encoded, _ := json.Marshal(map[string]any{"type": "response.output_text.delta", "delta": part}) + fmt.Fprintf(writer, "data: %s\n\n", encoded) + } + fmt.Fprintln(writer, `data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"manual-1","name":"manual"}}`) + fmt.Fprintln(writer) + fmt.Fprintln(writer, `data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{\"query\":\"what does connect do\"}"}`) + fmt.Fprintln(writer) + fmt.Fprintln(writer, `data: {"type":"response.completed","response":{"id":"split-1","model":"gpt-5.5","usage":{"input_tokens":5,"output_tokens":3}}}`) + fmt.Fprintln(writer) + })) + defer backend.Close() + t.Setenv("CODEAF_CODEX_BACKEND", backend.URL) + profile := t.TempDir() + if err := codexauth.Save(profile, codexauth.Tokens{ + AccessToken: access, RefreshToken: "codex-split-refresh-secret", + IDToken: "codex-split-identity-secret", AccountID: "split-account", ExpiresAt: time.Now().Add(time.Hour), + }); err != nil { + t.Fatal(err) + } + listed := true + if err := account.WriteSources(profile, []account.PersistedSource{{ + ID: "codex", Written: "codex", Key: codexauth.Sentinel, Listed: &listed, + }}); err != nil { + t.Fatal(err) + } + journal := filepath.Join(t.TempDir(), "session.jsonl") + logPath := filepath.Join(t.TempDir(), "calls.jsonl") + t.Setenv(calllog.EnvVar, logPath) + t.Setenv(calllog.BodiesEnvVar, "1") + calllog.Open("") + t.Cleanup(func() { + calllog.Close() + _ = os.Setenv(calllog.EnvVar, calllog.OffValue) + calllog.Open("") + }) + ctx := trace.Begin(context.Background()) + if trace.EnableRun(ctx) == "" { + t.Fatal("debug record did not turn on") + } + agent, err := New(Config{ + Workspace: t.TempDir(), Model: "codex/gpt-5.5", SessionFile: journal, + Sources: account.ResolveSources(profile, "", account.DefaultBaseURL), + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = agent.Close() }) + if !agent.setTitleIfUnnamed("split token sink test") { + t.Fatal("fresh session already had a title") + } + events, err := agent.Submit(ctx, "stream the hostile answer") + if err != nil { + t.Fatal(err) + } + collected := collect(t, events) + var streamed strings.Builder + for _, event := range collected { + if event.Kind == EventTextDelta { + streamed.WriteString(event.Text) + } + } + if streamed.String() != access { + t.Fatalf("fake backend did not split the access token across text deltas: %q", streamed.String()) + } + if backendCalls.Load() != 2 { + t.Fatalf("backend calls = %d, want the completed tool call and terminal follow-up", backendCalls.Load()) + } + failure, ok := firstOfKind(collected, EventError) + if !ok || failure.Err == nil { + t.Fatalf("turn ended without EventError: %v", kinds(collected)) + } + agent.SettleWrites() + calllog.Close() + + sinks := map[string][]byte{ + "EventError": []byte(failure.Err.Error()), + "transcript": readSinkBytes(t, journal), + "call log": readSinkBytes(t, logPath), + "debug record": readSinkBytes(t, trace.Dir(trace.RunFrom(ctx))), + } + for name, contents := range sinks { + if bytes.Contains(contents, []byte(access)) { + t.Errorf("%s contains the access token assembled from three deltas:\n%s", name, contents) + } + } +} + func readSinkBytes(t *testing.T, path string) []byte { t.Helper() info, err := os.Stat(path) diff --git a/internal/session/loop.go b/internal/session/loop.go index 4a45afbaf..7c9559802 100644 --- a/internal/session/loop.go +++ b/internal/session/loop.go @@ -2072,13 +2072,6 @@ func (a *Agent) completeWithRetryReasoning(ctx context.Context, hub *eventHub, m // turn that stopped. It is journaled per ATTEMPT, so a ladder of three // reads as a ladder. a.journalFailedCall(ctx, model, "", err, attempt+1, a.requestEstimate()) - // A REFUSED OR REMOVED SIGN-IN CANNOT IMPROVE ON ANOTHER ATTEMPT. The - // transport has already tried the one allowed 401 refresh; asking the - // same dead credential again would turn an actionable sentence into a - // retry storm before arriving at the same answer. - if errors.Is(err, codexauth.ErrSignInExpired) { - return nil, model, endingWords(err, taxonomy.Verdict{}, a.failureServiceWord(model)) - } // A GUARD'S CUT IS A DIFFERENT KIND OF SPENDING, and it is counted apart // from the outright failures rather than answered apart from them. The // request was served and the REPLY came apart, so the loop's own attempt diff --git a/internal/session/sessionfile.go b/internal/session/sessionfile.go index b1b0db7d5..b5767cc53 100644 --- a/internal/session/sessionfile.go +++ b/internal/session/sessionfile.go @@ -23,6 +23,7 @@ import ( "github.com/Agent-Field/codeaf/internal/filelock" "github.com/Agent-Field/codeaf/internal/provider" "github.com/Agent-Field/codeaf/internal/roles" + "github.com/Agent-Field/codeaf/internal/trace" ) // The session file is JSONL: one header line, then one line per COMPLETED @@ -3140,6 +3141,15 @@ func (s *sessionFile) writeLine(entry any) bool { if err != nil { return false } + // THE JOURNAL IS AN OBSERVABLE SINK, AND IT IS ALSO THE CONVERSATION'S OWN + // MEMORY. The complete encoded entry is scrubbed here, after streamed text + // and tool arguments have been assembled, so a credential this process holds + // cannot be recreated in the file from pieces that crossed the wire apart. + // Only the REGISTERED credentials go: a key a person pasted into their own + // message is theirs to keep, and a resumed conversation must replay it to + // the model exactly as they wrote it. The broader shape scrub belongs to the + // records people share — the call log and the debug record — not here. + payload = trace.ScrubRegistered(payload) s.mu.Lock() defer s.mu.Unlock() if s.closed { diff --git a/internal/taxonomy/policy.go b/internal/taxonomy/policy.go index 4c7c02529..951ca1ac8 100644 --- a/internal/taxonomy/policy.go +++ b/internal/taxonomy/policy.go @@ -99,6 +99,12 @@ func (transportPolicy) Class() Class { return Transport } // and a model is not the last thing there is. func (transportPolicy) Decide(e Evidence, l Limits) Verdict { spent, allowed := transportBudget(e, l) + // A TERMINAL TRANSPORT HAS ALREADY TAKEN ITS ONE RECOVERY. The report keeps + // it in transport accounting while preventing an endpoint retry or model hop + // from repeating a credential failure only the person can repair. + if e.TerminalTransport { + return Verdict{Action: ActionReport, Reason: ReasonUnauthorized, Attempts: spent} + } // A PAUSED PLAN HAS NO AUTOMATIC MOVE. The dispatcher either uses the // separately authorised metered door itself or returns the typed pause; an // endpoint walk or model hop here would evade that billing decision. @@ -287,6 +293,8 @@ func transportReason(e Evidence) string { switch { case e.Withdrawn: return ReasonWithdrawn + case e.TerminalTransport: + return ReasonUnauthorized case e.Unserved: return ReasonUnauthorized case e.Spent: diff --git a/internal/taxonomy/taxonomy.go b/internal/taxonomy/taxonomy.go index 37d4ce7d2..b4e2322bc 100644 --- a/internal/taxonomy/taxonomy.go +++ b/internal/taxonomy/taxonomy.go @@ -189,6 +189,12 @@ type Evidence struct { // or hopping models would turn a spending boundary into ordinary pacing. PlanPaused bool + // TerminalTransport says the transport has already exhausted the only + // recovery it owns and the person must act. It is still transport evidence + // rather than a finding about the model or the work, but its one verdict is + // a report: another endpoint, request shape, or model cannot change it. + TerminalTransport bool + // Upstream is the provider the router NAMED as the one that refused, empty // when the router refused on its own account. The emptiness is the fact: a // 4xx that named nobody is our own bytes being read and rejected, and every @@ -583,7 +589,7 @@ func classOf(e Evidence) Class { if e.Overflow { return Shape } - if e.Empty || e.Malformed || e.Timeout || e.Idle || e.Cut || e.Degenerate || e.Wire { + if e.TerminalTransport || e.Empty || e.Malformed || e.Timeout || e.Idle || e.Cut || e.Degenerate || e.Wire { return Transport } // A WITHDRAWN MODEL IS THE WIRE WITH ONE MOVE, and it is asked before the diff --git a/internal/trace/scrub.go b/internal/trace/scrub.go index ad8301684..254a84930 100644 --- a/internal/trace/scrub.go +++ b/internal/trace/scrub.go @@ -1,6 +1,7 @@ package trace import ( + "bytes" "regexp" "strings" "sync" @@ -82,11 +83,30 @@ func Scrub(body []byte) []byte { out := headerLike.ReplaceAll(body, []byte(`"credential":"`+redacted+`"`)) out = bearerLike.ReplaceAll(out, []byte("Bearer "+redacted)) out = keyLike.ReplaceAll(out, []byte(redacted)) + return ScrubRegistered(out) +} + +// ScrubRegistered removes only the exact credential bytes handed to Secret. +// It exists beside Scrub because semantic ingress must preserve diagnostics +// that merely resemble credentials, while records and other output sinks need +// Scrub's broader defence against unregistered key and header shapes. +func ScrubRegistered(body []byte) []byte { + if len(body) == 0 { + return body + } secrets.mutex.Lock() - known := secrets.list + if len(secrets.list) == 0 { + secrets.mutex.Unlock() + return body + } + known := append([]string(nil), secrets.list...) secrets.mutex.Unlock() + out := body for _, secret := range known { - out = []byte(strings.ReplaceAll(string(out), secret, redacted)) + literal := []byte(secret) + if bytes.Contains(out, literal) { + out = bytes.ReplaceAll(out, literal, []byte(redacted)) + } } return out } From c82b8a799a77e579d3df8d935e4260db2ce6c9a8 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 21 Sep 2026 18:51:56 -0400 Subject: [PATCH 16/18] config: the catalog law refuses an assignment to Source as well as a literal, proven on a compiled fixture Co-Authored-By: Claude Fable 5.1 --- internal/config/codexclient_law_test.go | 144 +++++++++++++++++++++++- 1 file changed, 140 insertions(+), 4 deletions(-) diff --git a/internal/config/codexclient_law_test.go b/internal/config/codexclient_law_test.go index 03c614d6f..acd0d3555 100644 --- a/internal/config/codexclient_law_test.go +++ b/internal/config/codexclient_law_test.go @@ -4,8 +4,10 @@ import ( "go/ast" "go/parser" "go/token" + "go/types" "io/fs" "os" + "os/exec" "path/filepath" "strconv" "strings" @@ -69,7 +71,20 @@ func TestC12EverySourceScopedCatalogUsesTheConnectedServiceConstructor(t *testin if err != nil { t.Fatal(err) } - err = filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { + violations, err := catalogSourceViolations(root) + if err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } + for _, violation := range violations { + t.Error(violation) + } +} + +const catalogImportPath = "github.com/Agent-Field/codeaf/internal/catalog" + +func catalogSourceViolations(root string) ([]string, error) { + var violations []string + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { if walkErr != nil { return walkErr } @@ -90,7 +105,7 @@ func TestC12EverySourceScopedCatalogUsesTheConnectedServiceConstructor(t *testin catalogNames := make(map[string]bool) for _, imported := range file.Imports { value, _ := strconv.Unquote(imported.Path.Value) - if value != "github.com/Agent-Field/codeaf/internal/catalog" { + if value != catalogImportPath { continue } name := "catalog" @@ -139,13 +154,134 @@ func TestC12EverySourceScopedCatalogUsesTheConnectedServiceConstructor(t *testin } insideConstructor := constructor != nil && literal.Pos() >= constructor.Pos() && literal.End() <= constructor.End() if !insideConstructor { - t.Errorf("%s sets catalog.Options.Source outside config.CatalogOptionsFor", set.Position(literal.Pos())) + violations = append(violations, fmtCatalogSourceViolation(set.Position(literal.Pos()))) + } + return true + }) + + // A literal is not the only way to write the field. Type information is + // what distinguishes options.Source from every unrelated Source field, + // including when options was copied from a field or helper first. + info := &types.Info{ + Types: make(map[ast.Expr]types.TypeAndValue), + Defs: make(map[*ast.Ident]types.Object), + Uses: make(map[*ast.Ident]types.Object), + Selections: make(map[*ast.SelectorExpr]*types.Selection), + } + checker := types.Config{ + Importer: newCatalogLawImporter(), + Error: func(error) {}, + } + _, _ = checker.Check(file.Name.Name, set, []*ast.File{file}, info) + ast.Inspect(file, func(node ast.Node) bool { + assignment, ok := node.(*ast.AssignStmt) + if !ok { + return true + } + for _, expression := range assignment.Lhs { + selector, ok := expression.(*ast.SelectorExpr) + if !ok || selector.Sel.Name != "Source" || !isCatalogOptions(info.TypeOf(selector.X)) { + continue + } + insideConstructor := constructor != nil && selector.Pos() >= constructor.Pos() && selector.End() <= constructor.End() + if !insideConstructor { + violations = append(violations, fmtCatalogSourceViolation(set.Position(selector.Pos()))) + } } return true }) return nil }) - if err != nil && !os.IsNotExist(err) { + return violations, err +} + +func fmtCatalogSourceViolation(position token.Position) string { + return position.String() + " sets catalog.Options.Source outside config.CatalogOptionsFor" +} + +func isCatalogOptions(value types.Type) bool { + if value == nil { + return false + } + value = types.Unalias(value) + if pointer, ok := value.(*types.Pointer); ok { + value = types.Unalias(pointer.Elem()) + } + named, ok := value.(*types.Named) + if !ok || named.Obj() == nil || named.Obj().Pkg() == nil { + return false + } + return named.Obj().Name() == "Options" && named.Obj().Pkg().Path() == catalogImportPath +} + +type catalogLawImporter struct { + packages map[string]*types.Package +} + +func newCatalogLawImporter() *catalogLawImporter { + return &catalogLawImporter{packages: make(map[string]*types.Package)} +} + +func (i *catalogLawImporter) Import(path string) (*types.Package, error) { + if imported := i.packages[path]; imported != nil { + return imported, nil + } + name := path + if slash := strings.LastIndex(name, "/"); slash >= 0 { + name = name[slash+1:] + } + imported := types.NewPackage(path, name) + i.packages[path] = imported + if path == catalogImportPath { + field := types.NewField(token.NoPos, imported, "Source", types.Typ[types.String], false) + structure := types.NewStruct([]*types.Var{field}, nil) + object := types.NewTypeName(token.NoPos, imported, "Options", nil) + types.NewNamed(object, structure, nil) + imported.Scope().Insert(object) + } + imported.MarkComplete() + return imported, nil +} + +func TestC12CatalogSourceAssignmentFixtureIsRefused(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "internal", "catalog"), 0o755); err != nil { t.Fatal(err) } + if err := os.MkdirAll(filepath.Join(root, "fixture"), 0o755); err != nil { + t.Fatal(err) + } + files := map[string]string{ + "go.mod": "module github.com/Agent-Field/codeaf\n\ngo 1.26.5\n", + "internal/catalog/catalog.go": "package catalog\n\ntype Options struct { Source string }\n", + "fixture/fixture.go": `package fixture + +import "github.com/Agent-Field/codeaf/internal/catalog" + +type shelf struct { options catalog.Options } + +func (s shelf) bypass() { + options := s.options + options.Source = "codex" +} +`, + } + for name, body := range files { + if err := os.WriteFile(filepath.Join(root, filepath.FromSlash(name)), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + command := exec.Command("go", "test", "./...") + command.Dir = root + command.Env = append(os.Environ(), "GOFLAGS=-buildvcs=false") + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("the catalog assignment fixture does not compile: %v\n%s", err, output) + } + violations, err := catalogSourceViolations(root) + if err != nil { + t.Fatal(err) + } + if len(violations) != 1 || !strings.Contains(violations[0], "fixture.go") { + t.Fatalf("catalog assignment fixture violations = %v, want its Source assignment", violations) + } } From 400737faa969359b189ec4522919b417764a9dc2 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 21 Sep 2026 18:51:56 -0400 Subject: [PATCH 17/18] docs/changes, tui3: the change entry names base-to-final truths, and the opener comment says what Start does now (#1336) Co-Authored-By: Claude Fable 5.1 --- docs/changes/unreleased/1336-codex-connect.md | 4 ++-- internal/tui3/opener.go | 9 +++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/changes/unreleased/1336-codex-connect.md b/docs/changes/unreleased/1336-codex-connect.md index afd9763e2..183bf06ab 100644 --- a/docs/changes/unreleased/1336-codex-connect.md +++ b/docs/changes/unreleased/1336-codex-connect.md @@ -8,8 +8,8 @@ invalidates: - "Every non-keyless model-service row opened key entry. The Codex row now says `browser` and uses the waiting card, copy affordance, and preferred-model move." - "A headless command refused to start with no OpenRouter key even when another service was connected. It now starts when any connected service holds its credential; a call to a service without one still fails when it is made." - "Connected-service documentation covered API-key services but not a ChatGPT plan's account list, limits, expiry, or unknown price. The Codex pages now state those boundaries and token-only accounting." - - "On a WSL box without `xdg-open`, opening a link failed. It now opens through `wslview`; every other machine keeps `xdg-open`." - - "A headless run ended a plan refusal with `your key was not accepted for this model`, for Z.ai as much as for Codex, while the chat quoted the vendor's own words. Both roads now end it with the same sentence: the service, that the account cannot pay, and what the vendor said; a spent fixed-price window says when it resets; an expired Codex sign-in says how to sign in again." + - "On a WSL box without `xdg-open`, opening a link failed. A link now opens through `wslview` when it is present; Linux otherwise uses `xdg-open`, while macOS uses `open`." + - "A headless run ended a Z.ai plan refusal with `your key was not accepted for this model` while the chat quoted the vendor's words. Headless and chat runs now end a payment refusal, including Codex's, with the same sentence: the service, that the account cannot pay, and what the vendor said; a spent fixed-price window says when it resets; an expired Codex sign-in says how to sign in again." --- Codex uses the account's own visible model list and never exposes the persisted sentinel or rotating tokens to the catalog or screen. First run remains OpenRouter-only; device-code sign-in, OpenAI API-key sign-in, Codex base instructions, websockets, connector scopes, and the resident remain out of scope. diff --git a/internal/tui3/opener.go b/internal/tui3/opener.go index 1f76151c7..a43c9cf0c 100644 --- a/internal/tui3/opener.go +++ b/internal/tui3/opener.go @@ -53,10 +53,11 @@ func openerCommand() (string, []string) { // the first-run sign-in, /files — and every one of them chooses its sentence from // the error on the frame that needs it. exec.Command looks the opener up on PATH // and records a miss in command.Err WITHOUT STARTING ANYTHING, and that miss is -// the answer those doors act on: no `xdg-open` on a headless box. The fork itself -// is handed to a goroutine, so the loop never starts a process of its own -// (framedisk_law_test.go). A fork that then fails is a link that did not open, -// which is the case the link written under every handoff exists for. +// the answer those doors act on: no `xdg-open` on a headless box. Start forks +// synchronously so a browser that would not start is reported on that frame, +// then waits for the child off the loop. A child that later fails is a link that +// did not open, which is the case the link written under every handoff exists +// for. func startOpener(target string) error { return opener.Start(target) } From 03c17d67e9334b8d82c222e5105016ca465cb615 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 22 Sep 2026 12:30:10 -0400 Subject: [PATCH 18/18] chat: the ordinary launch's /connect Codex row has its browser door MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plain `codeaf` or `codeaf chat` in a set-up workspace takes the engine road: the screen is assembled by the --host builder and localDoors puts this machine's stores back. The Codex sign-in seam was handed to the in-process door alone, so on the launch a person actually makes the Codex row answered `codex did not connect · codex browser sign-in is unavailable here`, while `--no-host` and `codeaf connect codex` both worked. localDoors now hands the door over the same way it hands the accounts panel, the approvals and the model writes; --host and --at keep it absent, and the host builder's list of deliberate absences says why. The doors test asserts both sides. Co-Authored-By: Claude Fable 5.1 --- cmd/codeaf/chatv3_host.go | 6 ++++++ cmd/codeaf/chatv3_local.go | 11 +++++++++++ cmd/codeaf/chatv3_local_test.go | 4 ++-- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/cmd/codeaf/chatv3_host.go b/cmd/codeaf/chatv3_host.go index 65f442df5..3623fd9d1 100644 --- a/cmd/codeaf/chatv3_host.go +++ b/cmd/codeaf/chatv3_host.go @@ -776,6 +776,12 @@ func hostOptions(fleet *engineFleet, welcome remote.Welcome, pick bool) (tui3.Op // the wrong place, so none is handed over and /connect says so in one // sentence (internal/tui3's host.go). // + // ConnectCodex: the same split for the Codex row — the sign-in's + // callback listener would be on this loopback and its tokens in this + // profile, while the session that needs them runs over there. The row + // says the sign-in is unavailable here, and the terminal door on that + // machine (`codeaf connect codex`) is the road that works. + // // Harnesses: the registry the engine matches turns against is on the // engine's machine. Listing this machine's under /harness would be // offering to run harnesses that are not there. diff --git a/cmd/codeaf/chatv3_local.go b/cmd/codeaf/chatv3_local.go index 862996d44..754314623 100644 --- a/cmd/codeaf/chatv3_local.go +++ b/cmd/codeaf/chatv3_local.go @@ -371,6 +371,17 @@ func localDoors(options *tui3.Options, welcome remote.Welcome, settings config.C options.SaveModel = func(model string) error { return config.WriteChatModel(profileDir, model) } + // THE CODEX BROWSER DOOR IS THIS MACHINE'S TOO. The sign-in listens on this + // machine's loopback and writes its tokens into the profile named above — + // the engine's — which is exactly the pair --host cannot have. A launch that + // reaches this line is a person at a terminal: --once returned before the + // road opened a screen, and a screen is the only thing this road opens. It + // was set on the in-process door alone when /connect grew the Codex row, + // and on the ordinary launch the row answered `codex browser sign-in is + // unavailable here` — the same family as Landing above and the doors + // around it: a seam handed only to [openChatV3] is a seam the default road + // does not have. + options.ConnectCodex = v3CodexConnection(true) if profileDir == settings.ProfileDir { options.Sources = settings.Sources } else { diff --git a/cmd/codeaf/chatv3_local_test.go b/cmd/codeaf/chatv3_local_test.go index 6c6e61041..ecb3bcc15 100644 --- a/cmd/codeaf/chatv3_local_test.go +++ b/cmd/codeaf/chatv3_local_test.go @@ -176,7 +176,7 @@ func TestAPlainLaunchKeepsThisMachinesDoorsWhileAHostLaunchDoesNot(t *testing.T) if local.Connections == nil || local.Harnesses == nil || local.SaveApproval == nil || local.SaveBashApproval == nil || local.SaveModel == nil || local.Sources.Empty() || - local.ApplyModelSources == nil { + local.ApplyModelSources == nil || local.ConnectCodex == nil { t.Fatalf("the plain launch was handed incomplete local doors: %+v", local) } if local.ApplyApprovals != nil { @@ -229,7 +229,7 @@ func TestAPlainLaunchKeepsThisMachinesDoorsWhileAHostLaunchDoesNot(t *testing.T) hosted, _ := hostOptions(onePipeFleet("devbox", client), welcome, false) if hosted.Connections != nil || hosted.Harnesses != nil || hosted.SaveApproval != nil || hosted.SaveBashApproval != nil || hosted.SaveModel != nil || !hosted.Sources.Empty() || - hosted.ApplyModelSources != nil { + hosted.ApplyModelSources != nil || hosted.ConnectCodex != nil { t.Fatalf("the --host builder grew this machine's doors: %+v", hosted) } }