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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

All notable changes to qmax-code. Versions follow [Semantic Versioning](https://semver.org/).

## [1.18.1] - 2026-06-15

### Added
- `qmax-code codex connect` performs a fresh Codex OAuth login and securely
attaches it to the authenticated QualityMax user. It reuses the existing
one-time browser login when QualityMax authentication is missing or expired,
and never accepts or sends a target user ID.

## [1.18.0] - 2026-06-04

### Added
Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,19 @@ qmax-code login
# Or use a QualityMax API key from Settings > API Keys
qmax-code login --api-key qm-YOUR-API-KEY

# 3. Start using
# 3. Attach Codex for QualityMax mobile runs (optional)
qmax-code codex connect

# 4. Start using
qmax-code
qmax-code "crawl staging.myapp.com and generate e2e tests"
qmax-code -p "run all tests for project 42"
```

`qmax-code codex connect` runs a fresh `codex login`, reuses the saved
QualityMax login (or opens the one-time browser authorization when needed), and
attaches Codex to the authenticated QualityMax user.

No qmax CLI needed. qmax-code calls the QualityMax API directly.

Get your QualityMax API key at: https://app.qualitymax.io/settings
Expand Down
125 changes: 125 additions & 0 deletions codex_command.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package main

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"

"github.com/qualitymax/qmax-code/internal/agent"
"github.com/qualitymax/qmax-code/internal/api"
"github.com/qualitymax/qmax-code/internal/setup"
)

const maxCodexAuthBytes = 64 * 1024

var (
findCodexForConnect = agent.FindCodex
runCodexLogin = func(bin string) error {
cmd := exec.Command(bin, "login")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
loadQualityMaxAuth = api.LoadAuth
loginQualityMax = setup.LoginViaBrowser
uploadCodexAuth = func(ctx context.Context, auth *api.AuthConfig, authJSON string) (*api.CodexConnection, error) {
return api.NewAPIClient(auth).ConnectCodex(ctx, authJSON)
}
)

func handleCodexCommand(args []string) error {
if len(args) != 1 || args[0] != "connect" {
return errors.New("usage: qmax-code codex connect")
}
return connectCodex(context.Background())
}

func connectCodex(ctx context.Context) error {
bin := findCodexForConnect()
if bin == "" {
return errors.New("Codex CLI not found; install it with: npm install -g @openai/codex")
}

fmt.Println("Refreshing your Codex login...")
if err := runCodexLogin(bin); err != nil {
return fmt.Errorf("codex login failed: %w", err)
}

authJSON, err := readCodexAuthFile()
if err != nil {
return err
}

auth := loadQualityMaxAuth()
if auth == nil || !auth.IsAuthenticated() {
fmt.Println("Connect this computer to QualityMax in your browser...")
auth, err = loginQualityMax()
if err != nil {
return fmt.Errorf("QualityMax login failed: %w", err)
}
}

connection, err := uploadCodexAuth(ctx, auth, authJSON)
var statusErr *api.HTTPStatusError
if errors.As(err, &statusErr) && statusErr.StatusCode == http.StatusUnauthorized {
fmt.Println("Your QualityMax login expired. Reauthorizing...")
auth, err = loginQualityMax()
if err != nil {
return fmt.Errorf("QualityMax login failed: %w", err)
}
connection, err = uploadCodexAuth(ctx, auth, authJSON)
}
if err != nil {
return fmt.Errorf("attach Codex to QualityMax: %w", err)
}

if connection.AccountLabel != "" {
fmt.Printf("Codex connected to QualityMax as %s.\n", connection.AccountLabel)
} else {
fmt.Println("Codex connected to QualityMax.")
}
return nil
}

func readCodexAuthFile() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("find home directory: %w", err)
}
path := filepath.Join(home, ".codex", "auth.json")

file, err := os.Open(path)
if err != nil {
return "", fmt.Errorf("read %s: %w", path, err)
}
defer file.Close()

data, err := io.ReadAll(io.LimitReader(file, maxCodexAuthBytes+1))
if err != nil {
return "", fmt.Errorf("read %s: %w", path, err)
}
if len(data) > maxCodexAuthBytes {
return "", fmt.Errorf("%s is larger than %d bytes", path, maxCodexAuthBytes)
}

var blob struct {
Tokens map[string]any `json:"tokens"`
}
if err := json.Unmarshal(data, &blob); err != nil {
return "", fmt.Errorf("%s is not valid JSON: %w", path, err)
}
for _, key := range []string{"access_token", "refresh_token"} {
value, ok := blob.Tokens[key].(string)
if !ok || value == "" {
return "", fmt.Errorf("%s does not contain a valid %s; run codex login again", path, key)
}
}
return string(data), nil
}
162 changes: 162 additions & 0 deletions codex_command_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
package main

import (
"context"
"errors"
"net/http"
"os"
"path/filepath"
"testing"

"github.com/qualitymax/qmax-code/internal/api"
)

func TestReadCodexAuthFileRequiresOAuthTokens(t *testing.T) {
home := withTempHome(t)
dir := filepath.Join(home, ".codex")
if err := os.MkdirAll(dir, 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(
filepath.Join(dir, "auth.json"),
[]byte(`{"tokens":{"access_token":"access","refresh_token":"refresh"}}`),
0600,
); err != nil {
t.Fatal(err)
}

got, err := readCodexAuthFile()
if err != nil {
t.Fatalf("readCodexAuthFile: %v", err)
}
if got == "" {
t.Fatal("expected auth JSON")
}
}

func TestConnectCodexRunsFreshLoginAndUsesAuthenticatedUpload(t *testing.T) {
oldFind := findCodexForConnect
oldRun := runCodexLogin
oldLoad := loadQualityMaxAuth
oldLogin := loginQualityMax
oldUpload := uploadCodexAuth
t.Cleanup(func() {
findCodexForConnect = oldFind
runCodexLogin = oldRun
loadQualityMaxAuth = oldLoad
loginQualityMax = oldLogin
uploadCodexAuth = oldUpload
})

home := withTempHome(t)
dir := filepath.Join(home, ".codex")
if err := os.MkdirAll(dir, 0700); err != nil {
t.Fatal(err)
}

loginRan := false
uploadRan := false
findCodexForConnect = func() string { return "/usr/local/bin/codex" }
runCodexLogin = func(bin string) error {
loginRan = true
return os.WriteFile(
filepath.Join(dir, "auth.json"),
[]byte(`{"tokens":{"access_token":"fresh-access","refresh_token":"fresh-refresh"}}`),
0600,
)
}
loadQualityMaxAuth = func() *api.AuthConfig {
return &api.AuthConfig{APIKey: "qm-user", Email: "user@example.com"}
}
loginQualityMax = func() (*api.AuthConfig, error) {
t.Fatal("existing QualityMax auth should be reused")
return nil, nil
}
uploadCodexAuth = func(ctx context.Context, auth *api.AuthConfig, authJSON string) (*api.CodexConnection, error) {
uploadRan = true
if auth.APIKey != "qm-user" {
t.Fatalf("APIKey = %q", auth.APIKey)
}
if authJSON == "" {
t.Fatal("missing auth JSON")
}
return &api.CodexConnection{Connected: true, Status: "connected"}, nil
}

if err := connectCodex(context.Background()); err != nil {
t.Fatalf("connectCodex: %v", err)
}
if !loginRan || !uploadRan {
t.Fatalf("loginRan=%v uploadRan=%v", loginRan, uploadRan)
}
}

func TestConnectCodexStopsWhenLoginFails(t *testing.T) {
oldFind := findCodexForConnect
oldRun := runCodexLogin
t.Cleanup(func() {
findCodexForConnect = oldFind
runCodexLogin = oldRun
})

findCodexForConnect = func() string { return "/usr/local/bin/codex" }
runCodexLogin = func(string) error { return errors.New("cancelled") }

if err := connectCodex(context.Background()); err == nil {
t.Fatal("expected login failure")
}
}

func TestConnectCodexReauthorizesQualityMaxAfterUnauthorized(t *testing.T) {
oldFind := findCodexForConnect
oldRun := runCodexLogin
oldLoad := loadQualityMaxAuth
oldLogin := loginQualityMax
oldUpload := uploadCodexAuth
t.Cleanup(func() {
findCodexForConnect = oldFind
runCodexLogin = oldRun
loadQualityMaxAuth = oldLoad
loginQualityMax = oldLogin
uploadCodexAuth = oldUpload
})

home := withTempHome(t)
dir := filepath.Join(home, ".codex")
if err := os.MkdirAll(dir, 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(
filepath.Join(dir, "auth.json"),
[]byte(`{"tokens":{"access_token":"access","refresh_token":"refresh"}}`),
0600,
); err != nil {
t.Fatal(err)
}

findCodexForConnect = func() string { return "/usr/local/bin/codex" }
runCodexLogin = func(string) error { return nil }
loadQualityMaxAuth = func() *api.AuthConfig { return &api.AuthConfig{APIKey: "expired"} }
loginQualityMax = func() (*api.AuthConfig, error) {
return &api.AuthConfig{APIKey: "fresh"}, nil
}

attempts := 0
uploadCodexAuth = func(ctx context.Context, auth *api.AuthConfig, authJSON string) (*api.CodexConnection, error) {
attempts++
if attempts == 1 {
return nil, &api.HTTPStatusError{StatusCode: http.StatusUnauthorized, Message: "expired"}
}
if auth.APIKey != "fresh" {
t.Fatalf("retry APIKey = %q", auth.APIKey)
}
return &api.CodexConnection{Connected: true}, nil
}

if err := connectCodex(context.Background()); err != nil {
t.Fatalf("connectCodex: %v", err)
}
if attempts != 2 {
t.Fatalf("upload attempts = %d", attempts)
}
}
Loading
Loading