diff --git a/CHANGELOG.md b/CHANGELOG.md index 87fa621..9901918 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index c0cfb3f..11dbd41 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/codex_command.go b/codex_command.go new file mode 100644 index 0000000..32f5ed0 --- /dev/null +++ b/codex_command.go @@ -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 +} diff --git a/codex_command_test.go b/codex_command_test.go new file mode 100644 index 0000000..a02ba23 --- /dev/null +++ b/codex_command_test.go @@ -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) + } +} diff --git a/internal/api/codex_connection.go b/internal/api/codex_connection.go new file mode 100644 index 0000000..2a26234 --- /dev/null +++ b/internal/api/codex_connection.go @@ -0,0 +1,83 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/qualitymax/qmax-code/internal/security" +) + +// CodexConnection is the non-sensitive connection state returned by QualityMax. +type CodexConnection struct { + Connected bool `json:"connected"` + Status string `json:"status"` + AccountLabel string `json:"account_label,omitempty"` + LastRefresh string `json:"last_refresh,omitempty"` +} + +// HTTPStatusError preserves the response status so callers can recover from +// expired QualityMax authentication without inspecting an error string. +type HTTPStatusError struct { + StatusCode int + Message string +} + +func (e *HTTPStatusError) Error() string { + return fmt.Sprintf("HTTP %d: %s", e.StatusCode, e.Message) +} + +// ConnectCodex attaches Codex OAuth material to the authenticated QualityMax +// user. The server derives the target user from this client's bearer token. +func (c *APIClient) ConnectCodex(ctx context.Context, authJSON string) (*CodexConnection, error) { + data, err := json.Marshal(map[string]string{"auth_json": authJSON}) + if err != nil { + return nil, fmt.Errorf("encode Codex credentials: %w", err) + } + + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + c.BaseURL+"/api/integrations/codex/connect", + bytes.NewReader(data), + ) + if err != nil { + return nil, fmt.Errorf("create Codex connection request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.APIKey) + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, fmt.Errorf("connect Codex: %s", security.RedactSensitive(err.Error())) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 128*1024)) + if err != nil { + return nil, fmt.Errorf("read Codex connection response: %w", err) + } + if resp.StatusCode >= 400 { + message := http.StatusText(resp.StatusCode) + var envelope struct { + Detail string `json:"detail"` + } + if json.Unmarshal(body, &envelope) == nil && envelope.Detail != "" { + message = envelope.Detail + } + return nil, &HTTPStatusError{ + StatusCode: resp.StatusCode, + Message: security.RedactSensitive(message), + } + } + + var connection CodexConnection + if err := json.Unmarshal(body, &connection); err != nil { + return nil, fmt.Errorf("decode Codex connection response: %w", err) + } + return &connection, nil +} diff --git a/internal/api/codex_connection_test.go b/internal/api/codex_connection_test.go new file mode 100644 index 0000000..4adbae2 --- /dev/null +++ b/internal/api/codex_connection_test.go @@ -0,0 +1,58 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestConnectCodexUsesBearerAndDoesNotSendUserID(t *testing.T) { + var gotAuth string + var gotBody map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"connected":true,"status":"connected","account_label":"user@example.com"}`)) + })) + defer server.Close() + + client := &APIClient{BaseURL: server.URL, APIKey: "qm-token", HTTP: server.Client()} + connection, err := client.ConnectCodex(context.Background(), `{"tokens":{"access_token":"a","refresh_token":"r"}}`) + if err != nil { + t.Fatalf("ConnectCodex: %v", err) + } + if gotAuth != "Bearer qm-token" { + t.Fatalf("Authorization = %q", gotAuth) + } + if _, ok := gotBody["user_id"]; ok { + t.Fatal("request must not contain a target user_id") + } + if connection.AccountLabel != "user@example.com" { + t.Fatalf("AccountLabel = %q", connection.AccountLabel) + } +} + +func TestConnectCodexReturnsTypedHTTPErrorWithoutCredentialLeak(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"detail":"unauthorized"}`, http.StatusUnauthorized) + })) + defer server.Close() + + client := &APIClient{BaseURL: server.URL, APIKey: "qm-token", HTTP: server.Client()} + _, err := client.ConnectCodex(context.Background(), `{"tokens":{"refresh_token":"secret-refresh-token"}}`) + + var statusErr *HTTPStatusError + if !errors.As(err, &statusErr) || statusErr.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected HTTPStatusError(401), got %v", err) + } + if strings.Contains(err.Error(), "secret-refresh-token") { + t.Fatal("error leaked Codex credential") + } +} diff --git a/main.go b/main.go index 9bffc06..705d1ec 100644 --- a/main.go +++ b/main.go @@ -19,7 +19,7 @@ import ( ) // Version is set at build time via -ldflags "-X main.Version=x.y.z" -var Version = "1.18.0" +var Version = "1.18.1" const Name = "qmax-code" @@ -72,6 +72,15 @@ func main() { return } + // Attach a fresh local Codex login to the authenticated QualityMax user. + if len(os.Args) > 1 && os.Args[1] == "codex" { + if err := handleCodexCommand(os.Args[2:]); err != nil { + fmt.Fprintln(os.Stderr, "Error:", err) + os.Exit(1) + } + return + } + // Handle "login" subcommand before flag parsing if len(os.Args) > 1 && os.Args[1] == "login" { loginFlags := flag.NewFlagSet("login", flag.ExitOnError)