mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-12 01:24:12 +08:00
- 新增基于本机官方 CLI 登录状态的 Codex 与 Claude 订阅 Provider - 隔离 API Key、工具与项目配置,完善登录校验和错误处理 - 补充设置界面、国际化及前后端回归测试 Fixes #651
428 lines
15 KiB
Go
428 lines
15 KiB
Go
package provider
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"GoNavi-Wails/internal/ai"
|
|
)
|
|
|
|
func TestBuildCodexCLIArgsUsesIsolatedReadOnlyExecutionAndStdin(t *testing.T) {
|
|
args := buildCodexCLIArgs(ai.ProviderConfig{Model: "gpt-5-codex"})
|
|
|
|
for _, expected := range []string{
|
|
"exec",
|
|
"--ignore-user-config",
|
|
"--ignore-rules",
|
|
"--ephemeral",
|
|
"--skip-git-repo-check",
|
|
"--json",
|
|
"-",
|
|
} {
|
|
if !hasArg(args, expected) {
|
|
t.Fatalf("expected %q in Codex args, got %#v", expected, args)
|
|
}
|
|
}
|
|
if !hasArgSequence(args, "--sandbox", "read-only") {
|
|
t.Fatalf("expected read-only sandbox, got %#v", args)
|
|
}
|
|
if !hasArgSequence(args, "--disable", "shell_tool") {
|
|
t.Fatalf("expected shell tool to be disabled, got %#v", args)
|
|
}
|
|
for _, feature := range codexCLIDisabledFeatures {
|
|
if !hasArgSequence(args, "--disable", feature) {
|
|
t.Fatalf("expected feature %q to be disabled, got %#v", feature, args)
|
|
}
|
|
}
|
|
for _, criticalFeature := range []string{
|
|
"shell_tool", "hooks", "memories", "multi_agent", "apps", "enable_mcp_apps",
|
|
"plugins", "plugin_hooks", "in_app_browser", "browser_use", "browser_use_external",
|
|
"computer_use", "image_generation", "remote_control",
|
|
} {
|
|
if !hasArgSequence(args, "--disable", criticalFeature) {
|
|
t.Fatalf("expected critical capability %q to be disabled, got %#v", criticalFeature, args)
|
|
}
|
|
}
|
|
if !hasArgSequence(args, "-c", "mcp_servers={}") {
|
|
t.Fatalf("expected MCP servers to be cleared, got %#v", args)
|
|
}
|
|
for _, configOverride := range []string{"skills.include_instructions=false", "skills.bundled.enabled=false"} {
|
|
if !hasArgSequence(args, "-c", configOverride) {
|
|
t.Fatalf("expected skill isolation override %q, got %#v", configOverride, args)
|
|
}
|
|
}
|
|
if !hasArgSequence(args, "-m", "gpt-5-codex") {
|
|
t.Fatalf("expected explicit model, got %#v", args)
|
|
}
|
|
if strings.Contains(strings.Join(args, " "), "secret prompt") {
|
|
t.Fatalf("prompt must not be passed in argv: %#v", args)
|
|
}
|
|
}
|
|
|
|
func TestBuildCodexCLIArgsLeavesModelToSubscriptionDefault(t *testing.T) {
|
|
args := buildCodexCLIArgs(ai.ProviderConfig{})
|
|
if hasArg(args, "-m") {
|
|
t.Fatalf("expected no model override, got %#v", args)
|
|
}
|
|
}
|
|
|
|
func TestNewCodexCLIProviderRejectsAPIKeyAuthMode(t *testing.T) {
|
|
if _, err := NewCodexCLIProvider(ai.ProviderConfig{AuthMode: "api-key"}); err == nil || !strings.Contains(err.Error(), "local-cli") {
|
|
t.Fatalf("expected an actionable local-cli auth error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildCodexCLIEnvRemovesAPIKeyOverrides(t *testing.T) {
|
|
env := buildCodexCLIEnv([]string{
|
|
"PATH=/usr/bin",
|
|
"CODEX_HOME=/tmp/codex-home",
|
|
"CODEX_API_KEY=codex-key",
|
|
"OPENAI_API_KEY=openai-key",
|
|
"OPENAI_BASE_URL=https://example.invalid",
|
|
})
|
|
|
|
for _, key := range []string{"CODEX_API_KEY", "OPENAI_API_KEY", "OPENAI_BASE_URL"} {
|
|
if got := envValue(env, key); got != "" {
|
|
t.Fatalf("expected %s to be removed, got %q", key, got)
|
|
}
|
|
}
|
|
if got := envValue(env, "CODEX_HOME"); got != "/tmp/codex-home" {
|
|
t.Fatalf("expected login home to be preserved, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestConsumeCodexCLIEventKeepsFinalMessageAndIgnoresRetryErrorAfterSuccess(t *testing.T) {
|
|
result := codexCLIResult{}
|
|
consumeCodexCLIEvent(&result, codexCLIEvent{Type: "error", Message: "temporary network error"})
|
|
consumeCodexCLIEvent(&result, codexCLIEvent{Type: "item.completed", Item: codexCLIItem{Type: "agent_message", Text: "draft"}})
|
|
consumeCodexCLIEvent(&result, codexCLIEvent{Type: "item.completed", Item: codexCLIItem{Type: "agent_message", Text: "final answer"}})
|
|
consumeCodexCLIEvent(&result, codexCLIEvent{Type: "item.completed", Item: codexCLIItem{Type: "reasoning", Text: "summary"}})
|
|
consumeCodexCLIEvent(&result, codexCLIEvent{
|
|
Type: "turn.completed",
|
|
Usage: codexCLIUsage{InputTokens: 10, OutputTokens: 4, ReasoningOutputTokens: 2},
|
|
})
|
|
|
|
if !result.Completed || result.Content != "final answer" || result.Thinking != "summary" {
|
|
t.Fatalf("unexpected parsed result: %#v", result)
|
|
}
|
|
if result.TerminalError != "" {
|
|
t.Fatalf("retryable top-level error must not override success: %#v", result)
|
|
}
|
|
if result.Usage.CompletionTokens != 4 || result.Usage.TotalTokens != 14 {
|
|
t.Fatalf("unexpected usage: %#v", result.Usage)
|
|
}
|
|
}
|
|
|
|
func TestCodexCLIProviderChatReadsPromptFromStdinAndParsesJSONL(t *testing.T) {
|
|
restore := overrideCodexCLIForTest(t, "success")
|
|
defer restore()
|
|
|
|
provider, err := NewCodexCLIProvider(ai.ProviderConfig{AuthMode: "local-cli"})
|
|
if err != nil {
|
|
t.Fatalf("create provider: %v", err)
|
|
}
|
|
resp, err := provider.Chat(context.Background(), ai.ChatRequest{
|
|
Messages: []ai.Message{{Role: "user", Content: "hello from stdin"}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("chat failed: %v", err)
|
|
}
|
|
if resp.Content != "hello from stdin" {
|
|
t.Fatalf("expected helper to echo stdin, got %#v", resp)
|
|
}
|
|
if resp.ReasoningContent != "checked safely" {
|
|
t.Fatalf("expected reasoning summary, got %#v", resp)
|
|
}
|
|
if resp.TokensUsed.CompletionTokens != 3 || resp.TokensUsed.TotalTokens != 8 {
|
|
t.Fatalf("unexpected usage: %#v", resp.TokensUsed)
|
|
}
|
|
}
|
|
|
|
func TestCodexCLIProviderChatRejectsNonSubscriptionAuthBeforeModelRequest(t *testing.T) {
|
|
originalAuthCheck := codexCLIChatGPTAuthCheck
|
|
originalCommandContext := codexCommandContext
|
|
defer func() {
|
|
codexCLIChatGPTAuthCheck = originalAuthCheck
|
|
codexCommandContext = originalCommandContext
|
|
}()
|
|
|
|
codexCLIChatGPTAuthCheck = func(context.Context) error {
|
|
return errors.New("Codex CLI is not logged in with a ChatGPT subscription; API key login detected")
|
|
}
|
|
modelStarted := false
|
|
codexCommandContext = func(ctx context.Context, path string, args ...string) *exec.Cmd {
|
|
modelStarted = true
|
|
return originalCommandContext(ctx, path, args...)
|
|
}
|
|
|
|
provider, _ := NewCodexCLIProvider(ai.ProviderConfig{AuthMode: "local-cli"})
|
|
_, err := provider.Chat(context.Background(), ai.ChatRequest{
|
|
Messages: []ai.Message{{Role: "user", Content: "must not be sent"}},
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "ChatGPT subscription") {
|
|
t.Fatalf("expected subscription auth error, got %v", err)
|
|
}
|
|
if modelStarted {
|
|
t.Fatal("model command must not start when subscription auth validation fails")
|
|
}
|
|
}
|
|
|
|
func TestCheckCodexCLIAuthUsesLoginStatusWithoutModelRequest(t *testing.T) {
|
|
restore := overrideCodexCLIForTest(t, "login-chatgpt")
|
|
defer restore()
|
|
|
|
helperCommandContext := codexCommandContext
|
|
var gotArgs []string
|
|
codexCommandContext = func(ctx context.Context, path string, args ...string) *exec.Cmd {
|
|
gotArgs = append([]string(nil), args...)
|
|
return helperCommandContext(ctx, path, args...)
|
|
}
|
|
|
|
if err := CheckCodexCLIAuth(context.Background()); err != nil {
|
|
t.Fatalf("check Codex login: %v", err)
|
|
}
|
|
expectedArgs := []string{"login", "status", "-c", codexCLILoginConfigOverride}
|
|
if strings.Join(gotArgs, "\x00") != strings.Join(expectedArgs, "\x00") {
|
|
t.Fatalf("expected login status only, got %#v", gotArgs)
|
|
}
|
|
}
|
|
|
|
func TestCheckCodexCLIAuthRejectsAPIKeyLogin(t *testing.T) {
|
|
restore := overrideCodexCLIForTest(t, "login-api-key")
|
|
defer restore()
|
|
|
|
err := CheckCodexCLIAuth(context.Background())
|
|
if err == nil || !strings.Contains(err.Error(), "ChatGPT subscription") || !strings.Contains(err.Error(), "API key") {
|
|
t.Fatalf("expected API key login to be rejected with an actionable error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCheckCodexCLIAuthReportsMissingLogin(t *testing.T) {
|
|
restore := overrideCodexCLIForTest(t, "login-failed")
|
|
defer restore()
|
|
|
|
err := CheckCodexCLIAuth(context.Background())
|
|
if err == nil || !strings.Contains(err.Error(), "Not logged in") || !strings.Contains(err.Error(), "codex login") {
|
|
t.Fatalf("expected actionable login error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCodexCLIProviderChatStreamEmitsOneFinalAnswer(t *testing.T) {
|
|
restore := overrideCodexCLIForTest(t, "success")
|
|
defer restore()
|
|
|
|
provider, _ := NewCodexCLIProvider(ai.ProviderConfig{AuthMode: "local-cli"})
|
|
var chunks []ai.StreamChunk
|
|
err := provider.ChatStream(context.Background(), ai.ChatRequest{
|
|
Messages: []ai.Message{{Role: "user", Content: "stream answer"}},
|
|
}, func(chunk ai.StreamChunk) {
|
|
chunks = append(chunks, chunk)
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("stream failed: %v", err)
|
|
}
|
|
if len(chunks) != 3 {
|
|
t.Fatalf("expected thinking, content, done chunks, got %#v", chunks)
|
|
}
|
|
if chunks[0].Thinking != "checked safely" || chunks[1].Content != "stream answer" || !chunks[2].Done {
|
|
t.Fatalf("unexpected stream chunks: %#v", chunks)
|
|
}
|
|
}
|
|
|
|
func TestCodexCLIProviderChatStreamReportsTurnFailure(t *testing.T) {
|
|
restore := overrideCodexCLIForTest(t, "failed")
|
|
defer restore()
|
|
|
|
provider, _ := NewCodexCLIProvider(ai.ProviderConfig{AuthMode: "local-cli"})
|
|
var chunks []ai.StreamChunk
|
|
err := provider.ChatStream(context.Background(), ai.ChatRequest{
|
|
Messages: []ai.Message{{Role: "user", Content: "fail"}},
|
|
}, func(chunk ai.StreamChunk) {
|
|
chunks = append(chunks, chunk)
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected callback error convention, got %v", err)
|
|
}
|
|
if len(chunks) != 1 || !chunks[0].Done || !strings.Contains(chunks[0].Error, "not logged in") {
|
|
t.Fatalf("expected terminal authentication error chunk, got %#v", chunks)
|
|
}
|
|
}
|
|
|
|
func TestCodexCLIProviderHonorsCancellation(t *testing.T) {
|
|
restore := overrideCodexCLIForTest(t, "sleep")
|
|
defer restore()
|
|
|
|
provider, _ := NewCodexCLIProvider(ai.ProviderConfig{AuthMode: "local-cli"})
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
_, err := provider.Chat(ctx, ai.ChatRequest{Messages: []ai.Message{{Role: "user", Content: "cancel"}}})
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("expected context cancellation, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCodexCLIProviderStopsRunningProcessWhenDeadlineExpires(t *testing.T) {
|
|
restore := overrideCodexCLIForTest(t, "sleep")
|
|
defer restore()
|
|
|
|
originalTimeout := codexCLIRequestTimeout
|
|
codexCLIRequestTimeout = 200 * time.Millisecond
|
|
defer func() { codexCLIRequestTimeout = originalTimeout }()
|
|
|
|
provider, _ := NewCodexCLIProvider(ai.ProviderConfig{AuthMode: "local-cli"})
|
|
started := time.Now()
|
|
_, err := provider.Chat(context.Background(), ai.ChatRequest{
|
|
Messages: []ai.Message{{Role: "user", Content: "timeout"}},
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "timed out") {
|
|
t.Fatalf("expected timeout error, got %v", err)
|
|
}
|
|
if elapsed := time.Since(started); elapsed > 2*time.Second {
|
|
t.Fatalf("expected the native CLI process to stop promptly, took %s", elapsed)
|
|
}
|
|
}
|
|
|
|
func TestResolveCodexCLICommandUsesNPMVendorBinaryOnWindows(t *testing.T) {
|
|
cmdPath := filepath.Join("npm", "codex.cmd")
|
|
nativePath := filepath.Join(
|
|
"npm", "node_modules", "@openai", "codex", "node_modules", "@openai", "codex-win32-x64",
|
|
"vendor", "x86_64-pc-windows-msvc", "bin", "codex.exe",
|
|
)
|
|
command, err := resolveCodexCLICommand("windows", "amd64", func(name string) (string, error) {
|
|
switch name {
|
|
case "codex.cmd":
|
|
return cmdPath, nil
|
|
case "codex.exe":
|
|
return filepath.Join("windows-apps", "codex.exe"), nil
|
|
default:
|
|
return "", errors.New("not found")
|
|
}
|
|
}, func(path string) bool {
|
|
return path == cmdPath || path == nativePath || path == filepath.Join("windows-apps", "codex.exe")
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("resolve command: %v", err)
|
|
}
|
|
if command.Path != nativePath || len(command.PrefixArgs) != 0 {
|
|
t.Fatalf("unexpected command: %#v", command)
|
|
}
|
|
}
|
|
|
|
func TestCodexNPMNativeBinaryCandidatesSupportsArm64(t *testing.T) {
|
|
candidates := codexNPMNativeBinaryCandidates(filepath.Join("npm", "codex.cmd"), "windows", "arm64")
|
|
if len(candidates) == 0 || !strings.Contains(candidates[0], "codex-win32-arm64") || !strings.Contains(candidates[0], "aarch64-pc-windows-msvc") {
|
|
t.Fatalf("unexpected arm64 candidates: %#v", candidates)
|
|
}
|
|
}
|
|
|
|
func TestCodexNPMPlatformTargetSupportsOfficialPackages(t *testing.T) {
|
|
tests := []struct {
|
|
goos string
|
|
goarch string
|
|
wantPackage string
|
|
wantTriple string
|
|
wantBinary string
|
|
}{
|
|
{"linux", "amd64", "codex-linux-x64", "x86_64-unknown-linux-musl", "codex"},
|
|
{"linux", "arm64", "codex-linux-arm64", "aarch64-unknown-linux-musl", "codex"},
|
|
{"darwin", "amd64", "codex-darwin-x64", "x86_64-apple-darwin", "codex"},
|
|
{"darwin", "arm64", "codex-darwin-arm64", "aarch64-apple-darwin", "codex"},
|
|
{"windows", "amd64", "codex-win32-x64", "x86_64-pc-windows-msvc", "codex.exe"},
|
|
{"windows", "arm64", "codex-win32-arm64", "aarch64-pc-windows-msvc", "codex.exe"},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.goos+"-"+test.goarch, func(t *testing.T) {
|
|
pkg, triple, binary, ok := codexNPMPlatformTarget(test.goos, test.goarch)
|
|
if !ok || pkg != test.wantPackage || triple != test.wantTriple || binary != test.wantBinary {
|
|
t.Fatalf("unexpected target: package=%q triple=%q binary=%q ok=%v", pkg, triple, binary, ok)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCodexCLIHelperProcess(t *testing.T) {
|
|
if os.Getenv("GO_WANT_CODEX_HELPER") != "1" {
|
|
return
|
|
}
|
|
mode := os.Getenv("GO_CODEX_HELPER_MODE")
|
|
if mode == "login-chatgpt" {
|
|
_, _ = fmt.Fprintln(os.Stdout, "Logged in using ChatGPT")
|
|
return
|
|
}
|
|
if mode == "login-api-key" {
|
|
_, _ = fmt.Fprintln(os.Stdout, "Logged in using an API key")
|
|
return
|
|
}
|
|
if mode == "login-failed" {
|
|
_, _ = fmt.Fprintln(os.Stderr, "Not logged in")
|
|
os.Exit(1)
|
|
}
|
|
if mode == "sleep" {
|
|
time.Sleep(5 * time.Second)
|
|
return
|
|
}
|
|
prompt, _ := io.ReadAll(os.Stdin)
|
|
encoder := json.NewEncoder(os.Stdout)
|
|
if mode == "failed" {
|
|
_ = encoder.Encode(map[string]any{
|
|
"type": "turn.failed",
|
|
"error": map[string]string{"message": "not logged in; run codex login"},
|
|
})
|
|
return
|
|
}
|
|
_ = encoder.Encode(map[string]any{
|
|
"type": "item.completed",
|
|
"item": map[string]string{"type": "reasoning", "text": "checked safely"},
|
|
})
|
|
_ = encoder.Encode(map[string]any{
|
|
"type": "item.completed",
|
|
"item": map[string]string{"type": "agent_message", "text": strings.TrimSpace(string(prompt))},
|
|
})
|
|
_ = encoder.Encode(map[string]any{
|
|
"type": "turn.completed",
|
|
"usage": map[string]int{
|
|
"input_tokens": 5,
|
|
"cached_input_tokens": 1,
|
|
"output_tokens": 3,
|
|
"reasoning_output_tokens": 1,
|
|
},
|
|
})
|
|
}
|
|
|
|
func overrideCodexCLIForTest(t *testing.T, mode string) func() {
|
|
t.Helper()
|
|
originalLookPath := codexLookPath
|
|
originalCommandContext := codexCommandContext
|
|
originalAuthCheck := codexCLIChatGPTAuthCheck
|
|
executable, err := os.Executable()
|
|
if err != nil {
|
|
t.Fatalf("resolve test executable: %v", err)
|
|
}
|
|
codexLookPath = func(name string) (string, error) {
|
|
if name == "codex" || name == "codex.exe" {
|
|
return executable, nil
|
|
}
|
|
return "", fmt.Errorf("unexpected command lookup: %s", name)
|
|
}
|
|
codexCommandContext = func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
|
|
cmd := exec.CommandContext(ctx, executable, "-test.run=TestCodexCLIHelperProcess", "--")
|
|
cmd.Env = append(os.Environ(), "GO_WANT_CODEX_HELPER=1", "GO_CODEX_HELPER_MODE="+mode)
|
|
return cmd
|
|
}
|
|
codexCLIChatGPTAuthCheck = func(context.Context) error { return nil }
|
|
return func() {
|
|
codexLookPath = originalLookPath
|
|
codexCommandContext = originalCommandContext
|
|
codexCLIChatGPTAuthCheck = originalAuthCheck
|
|
}
|
|
}
|