Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
40c92d8
codexauth: sign in to a ChatGPT plan the way the Codex CLI does, and …
AbirAbbas Sep 21, 2026
fd32a74
config: Codex is a model service, resolved with its home and its own …
AbirAbbas Sep 21, 2026
f536e45
opener: one platform browser opener, shared by the chat surface and t…
AbirAbbas Sep 21, 2026
083f047
codeaf connect and codeaf disconnect: connect a model service from th…
AbirAbbas Sep 21, 2026
451584c
chat: the Codex row signs in in the browser, and every listing road c…
AbirAbbas Sep 21, 2026
9bbfd05
manual, README, GUIDE: a ChatGPT plan connects as Codex, in the brows…
AbirAbbas Sep 21, 2026
844384d
docs/changes: a ChatGPT plan connects as Codex, and codeaf connect fr…
AbirAbbas Sep 21, 2026
b8d7095
codexauth, session: the transport survives other processes, and a ter…
AbirAbbas Sep 21, 2026
2ae87a6
config: one constructor for a connected service's catalog, and a law …
AbirAbbas Sep 21, 2026
53523aa
opener: a browser that would not start says so, and the terminal keep…
AbirAbbas Sep 21, 2026
5dab430
codexauth: the listener test records 1455 then 1457 rather than trust…
AbirAbbas Sep 21, 2026
0bb1aa5
tui3: first run is rendered and asserted to offer OpenRouter and no C…
AbirAbbas Sep 21, 2026
2102845
docs/changes: only base-to-final truths, plus the WSL opener and the …
AbirAbbas Sep 21, 2026
1425200
codexauth: a refresh is bounded, and a waiter can leave
AbirAbbas Sep 21, 2026
2d03ecb
provider, session, taxonomy: exact ingress scrubbing, expiry accounte…
AbirAbbas Sep 21, 2026
c82b8a7
config: the catalog law refuses an assignment to Source as well as a …
AbirAbbas Sep 21, 2026
400737f
docs/changes, tui3: the change entry names base-to-final truths, and …
AbirAbbas Sep 21, 2026
03c17d6
chat: the ordinary launch's /connect Codex row has its browser door
AbirAbbas Sep 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,9 @@ curl -fsSL https://agentfield.ai/get/codeaf | VERSION=<tag> 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

Expand Down Expand Up @@ -169,8 +170,8 @@ request goes to the provider that has been fastest for that kind of call.

<img src="assets/readme/screens/models.webp" alt="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" width="100%">

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

Expand Down
4 changes: 3 additions & 1 deletion cmd/codeaf/beltdoors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions cmd/codeaf/chatv3.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions cmd/codeaf/chatv3_host.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 11 additions & 0 deletions cmd/codeaf/chatv3_local.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions cmd/codeaf/chatv3_local_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}
Expand Down
5 changes: 1 addition & 4 deletions cmd/codeaf/chatv3_media.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
57 changes: 57 additions & 0 deletions cmd/codeaf/chatv3_media_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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 = `
Expand Down
10 changes: 6 additions & 4 deletions cmd/codeaf/chatv3_modelshelf.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -65,6 +66,10 @@ func (s *v3ModelShelf) setSources(sources modelsource.Set) {
continue
}
rows := fixedDoorModels(service)
if len(rows) == 0 && strings.EqualFold(service.Source.ID, "codex") {
remembered := catalog.Recall(config.CatalogOptionsFor(service, s.options.Dir))
rows = v3Models(remembered)
}
if len(rows) == 0 && service.Source.Listing == modelsource.ListingModels {
rows = tui3.CachedModelsFor(service.Source.ID, service.Address)
}
Expand Down Expand Up @@ -142,10 +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 := config.CatalogOptionsFor(service, s.options.Dir)
if len(seed) > 0 {
minimal := make([]catalog.Model, 0, len(seed))
for _, model := range seed {
Expand Down
50 changes: 49 additions & 1 deletion cmd/codeaf/chatv3_modelshelf_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions cmd/codeaf/chatv3_process.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions cmd/codeaf/chatv3_seams_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading