From 9ac8ecb63acb7e18797702e3fba62026b181dee9 Mon Sep 17 00:00:00 2001 From: yujiezhang-ops Date: Thu, 17 Sep 2026 09:51:30 +0800 Subject: [PATCH 1/2] fix: adapt DSH configuration to current protocols --- internal/app/agent.go | 8 +- internal/app/install.go | 46 ++++++- internal/app/provider.go | 7 + internal/config/write.go | 58 +++++++- internal/config/write_test.go | 40 +++++- internal/desktopapp/dsh.go | 16 ++- internal/desktopapp/dsh_mount_darwin_test.go | 131 +++++++++++++++++++ internal/desktopapp/testdata/dsh-fixture.dmg | Bin 0 -> 17478 bytes internal/platform/native_darwin.go | 28 ++++ internal/platform/native_darwin_test.go | 42 ++++++ internal/platform/native_other.go | 11 ++ internal/provider/client.go | 6 +- internal/provider/client_test.go | 20 +++ 13 files changed, 392 insertions(+), 21 deletions(-) create mode 100644 internal/desktopapp/dsh_mount_darwin_test.go create mode 100644 internal/desktopapp/testdata/dsh-fixture.dmg create mode 100644 internal/platform/native_darwin.go create mode 100644 internal/platform/native_darwin_test.go create mode 100644 internal/platform/native_other.go diff --git a/internal/app/agent.go b/internal/app/agent.go index 2efbe7f2..3003d651 100644 --- a/internal/app/agent.go +++ b/internal/app/agent.go @@ -246,7 +246,7 @@ func (u *UseCases) profileContext1M(profileID string) bool { // config shapes read off one observed version with no documented reasoning // field, and inventing keys in files those apps own risks corrupting state // they manage (see WriteZCode). -func writeManagedAgentConfig(ctx context.Context, writer configWriter.Writer, agentID string, agent catalog.Agent, path, providerID, providerName, baseURL, apiKey, model, reasoningEffort string, context1M bool) error { +func writeManagedAgentConfig(ctx context.Context, writer configWriter.Writer, agentID string, agent catalog.Agent, path, providerID, providerName, baseURL, apiKey, model, reasoningEffort string, context1M bool, selectedProtocol ...string) error { switch agent.ConfigAdapter { case "codex": return writer.WriteCodex(ctx, path, providerName, baseURL, apiKey, model, reasoningEffort) @@ -275,7 +275,11 @@ func writeManagedAgentConfig(ctx context.Context, writer configWriter.Writer, ag if providerID == "deepseek" { return writer.WriteDSHOfficial(ctx, path, apiKey, model, reasoningEffort) } - return writer.WriteDSH(ctx, path, providerName, baseURL, apiKey, model) + protocolID := provider.ProtocolOpenAI + if len(selectedProtocol) > 0 && selectedProtocol[0] != "" { + protocolID = selectedProtocol[0] + } + return writer.WriteDSHProtocol(ctx, path, providerName, baseURL, apiKey, model, protocolID) case "hermes": return writer.WriteHermes(ctx, path, baseURL, apiKey, model) case "kimi-code": diff --git a/internal/app/install.go b/internal/app/install.go index a9ca4f1d..b6c1b4a6 100644 --- a/internal/app/install.go +++ b/internal/app/install.go @@ -232,7 +232,15 @@ func (u *UseCases) probeInstallProtocols(ctx context.Context, options InstallAge } protocols := make(map[string]bool) for _, agentID := range autoAgents { - protocols[provider.ProtocolForAdapter(manifest.Agents[agentID].ConfigAdapter)] = true + agent := manifest.Agents[agentID] + protocols[provider.ProtocolForAdapter(agent.ConfigAdapter)] = true + // DSH's current pi-ai adapter supports both OpenAI wire protocols. + // Probe both because newer reasoning models (for example gpt-5.6-sol) + // may reject Chat Completions while accepting Responses. + if agent.ConfigAdapter == "dsh" { + protocols[provider.ProtocolResponses] = true + protocols[provider.ProtocolOpenAI] = true + } } ordered := make([]string, 0, len(protocols)) for protocolID := range protocols { @@ -247,6 +255,30 @@ func (u *UseCases) probeInstallProtocols(ctx context.Context, options InstallAge return nil, err } u.sharpenInstallModelDiagnosis(ctx, probes, options) + // A DSH install can use either protocol. Keep the preferred successful + // result (Responses first) and discard a failed alternative so one + // unsupported wire format does not fail an otherwise valid installation. + if slices.Contains(autoAgents, "dsh") { + nonDSHNeedsOpenAI := false + nonDSHNeedsResponses := false + for _, agentID := range autoAgents { + agent := manifest.Agents[agentID] + if agent.ConfigAdapter == "dsh" { + continue + } + switch provider.ProtocolForAdapter(agent.ConfigAdapter) { + case provider.ProtocolOpenAI: + nonDSHNeedsOpenAI = true + case provider.ProtocolResponses: + nonDSHNeedsResponses = true + } + } + if responses, ok := probes[provider.ProtocolResponses]; ok && responses.OK && !nonDSHNeedsOpenAI { + delete(probes, provider.ProtocolOpenAI) + } else if chat, ok := probes[provider.ProtocolOpenAI]; ok && chat.OK && !nonDSHNeedsResponses { + delete(probes, provider.ProtocolResponses) + } + } return probes, nil } @@ -402,6 +434,11 @@ func (r *installRun) configure(ctx context.Context, agentID string, agent catalo if r.options.Configure { r.emitPhase(agentID, "configuring") protocolID := provider.ProtocolForAdapter(agent.ConfigAdapter) + if agent.ConfigAdapter == "dsh" { + if verdict, ok := r.probes[provider.ProtocolResponses]; ok && verdict.OK { + protocolID = provider.ProtocolResponses + } + } if verdict, found := r.probes[protocolID]; found && !verdict.OK { code := pointerString(verdict.ErrorCode) if code == "" { @@ -424,7 +461,7 @@ func (r *installRun) configure(ctx context.Context, agentID string, agent catalo // launched with is the only carrier. reasoningEffort := r.core.profileReasoningEffort(r.options.ProfileID) context1M := r.core.profileContext1M(r.options.ProfileID) - if err := writeManagedAgentConfig(ctx, writer, agentID, agent, configPathValue, dshRouteProviderID(target, r.options.APIBaseURL), r.providerName, configBase, r.options.APIKey, r.options.Model, reasoningEffort, context1M); err != nil { + if err := writeManagedAgentConfig(ctx, writer, agentID, agent, configPathValue, dshRouteProviderID(target, r.options.APIBaseURL), r.providerName, configBase, r.options.APIKey, r.options.Model, reasoningEffort, context1M, protocolID); err != nil { return err } if _, err := r.core.profiles.WriteAgentBinding(ctx, agentID, profileStore.BindingWriteRequest{ @@ -579,6 +616,11 @@ func (r *installRun) finish(ctx context.Context, baseURL string) InstallAgentsRe for _, agentID := range r.options.Agents { if agent, ok := r.manifest.Agents[agentID]; ok && agent.ConfigMode == "auto" { profileProtocol = provider.ProtocolForAdapter(agent.ConfigAdapter) + if agent.ConfigAdapter == "dsh" { + if verdict, ok := r.probes[provider.ProtocolResponses]; ok && verdict.OK { + profileProtocol = provider.ProtocolResponses + } + } break } } diff --git a/internal/app/provider.go b/internal/app/provider.go index 948ba4cb..befa00e6 100644 --- a/internal/app/provider.go +++ b/internal/app/provider.go @@ -117,6 +117,13 @@ func (u *UseCases) probeProtocols(ctx context.Context, protocols []string, apiKe }(protocolID) } group.Wait() + // Callers may probe alternative protocols (notably DSH, which supports both + // Chat Completions and Responses). A transport failure for one alternative + // must not discard a successful result for another; only fail when every + // requested probe failed before producing a verdict. + if len(results) > 0 { + return results, nil + } for _, protocolID := range protocols { if err := errorsByProtocol[protocolID]; err != nil { return results, err diff --git a/internal/config/write.go b/internal/config/write.go index eef3d30d..cbee327b 100644 --- a/internal/config/write.go +++ b/internal/config/write.go @@ -512,6 +512,14 @@ func (w Writer) WriteOpenClaw(ctx context.Context, path, providerName, baseURL, // The endpoint goes in with OpenAIBaseURL's /v1 rather than bare, because the // adapter appends only the operation path to whatever it is given. func (w Writer) WriteDSH(ctx context.Context, path, providerName, baseURL, apiKey, model string) error { + return w.WriteDSHProtocol(ctx, path, providerName, baseURL, apiKey, model, provider.ProtocolOpenAI) +} + +// WriteDSHProtocol writes a hand-declared pi-ai route using the protocol +// accepted by the selected upstream model. DSH supports both OpenAI Chat +// Completions and Responses; the install flow probes and passes the one that +// actually works. +func (w Writer) WriteDSHProtocol(ctx context.Context, path, providerName, baseURL, apiKey, model, protocolID string) error { // The credential lands first: a route pointing at a provider dsh cannot // authenticate is worse than an unreferenced key. if err := w.writeDSHCredential(ctx, filepath.Join(filepath.Dir(path), ".credentials.yaml"), dshCredentialReference, apiKey); err != nil { @@ -530,10 +538,14 @@ func (w Writer) WriteDSH(ctx context.Context, path, providerName, baseURL, apiKe return configError("Existing llm-pi-ai providers must contain an object: %s", path) } route := &yaml.Node{Kind: yaml.MappingNode} + apiName := "openai-completions" + if protocolID == provider.ProtocolResponses { + apiName = "openai-responses" + } for _, item := range []struct{ key, value string }{ {"displayName", providerName}, {"apiKeyEnv", dshCredentialReference}, - {"api", "openai-completions"}, + {"api", apiName}, {"baseURL", provider.OpenAIBaseURL(baseURL)}, } { yamlSet(route, item.key, item.value) @@ -636,16 +648,41 @@ func (w Writer) WriteDSHOfficial(ctx context.Context, path, apiKey, model, reaso // the given reference, keeping every other credential the user stored from // dsh's Models page. // -// The document is a strict credential-to-value mapping: dsh rejects a non-string -// value, an empty string, or a key that is not a POSIX identifier, and fails loud -// rather than skipping the entry. So this writes one identifier and nothing else -// -- no wrapper level, no version field. +// The document is dsh's strict version-1 credential store: references live +// under refs and must contain non-empty strings keyed by POSIX identifiers. +// Legacy flat files are migrated on the first write so the current Web app can +// load them without losing existing credentials. func (w Writer) writeDSHCredential(ctx context.Context, path, reference, apiKey string) error { root, err := yamlDocument(path, "DeepSeek Harness credentials") if err != nil { return err } - yamlSet(root.Content[0], reference, apiKey) + // dsh 0.1.5 and newer use the versioned credential document. Older + // releases accepted a flat map, so migrate that shape in memory while + // preserving every existing reference before writing the new document. + version := yamlLookup(root.Content[0], "version") + refs := yamlChild(root.Content[0], "refs") + if version == nil && refs == nil { + legacy := append([]*yaml.Node(nil), root.Content[0].Content...) + root.Content[0].Content = nil + root.Content[0].Content = append(root.Content[0].Content, + &yaml.Node{Kind: yaml.ScalarNode, Value: "version"}, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!int", Value: "1"}) + refs = &yaml.Node{Kind: yaml.MappingNode} + root.Content[0].Content = append(root.Content[0].Content, + &yaml.Node{Kind: yaml.ScalarNode, Value: "refs"}, refs) + for index := 0; index+1 < len(legacy); index += 2 { + refs.Content = append(refs.Content, legacy[index], legacy[index+1]) + } + } else { + if version == nil || version.Value != "1" { + return configError("DeepSeek Harness credentials must use version: 1: %s", path) + } + if refs == nil || refs.Kind != yaml.MappingNode { + return configError("DeepSeek Harness credentials refs must be an object: %s", path) + } + } + yamlSet(refs, reference, apiKey) data, err := yaml.Marshal(root) if err != nil { return configError("Cannot encode YAML credentials %s: %v", path, err) @@ -895,6 +932,15 @@ func yamlChild(parent *yaml.Node, key string) *yaml.Node { return nil } +func yamlLookup(parent *yaml.Node, key string) *yaml.Node { + for index := 0; index+1 < len(parent.Content); index += 2 { + if parent.Content[index].Value == key { + return parent.Content[index+1] + } + } + return nil +} + // yamlDelete removes one key and its value from a mapping; an absent key is a // no-op. func yamlDelete(parent *yaml.Node, key string) { diff --git a/internal/config/write_test.go b/internal/config/write_test.go index 2c50f9e0..21fcecb9 100644 --- a/internal/config/write_test.go +++ b/internal/config/write_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/MaimoryLab/BootAgent/internal/provider" "github.com/MaimoryLab/BootAgent/internal/securefs" "gopkg.in/yaml.v3" ) @@ -855,20 +856,26 @@ func dshSettings(t *testing.T, path string) map[string]map[string]any { return parsed.PiAI.Providers } -// dshCredentials returns the credential document as the strict mapping dsh -// requires it to be: any other shape fails on dsh's side rather than being -// skipped, so the test asserts the shape too. +// dshCredentials returns the refs from the versioned credential document dsh +// requires. Legacy flat files are accepted as input by the migration test +// path, but writes must publish version: 1 with a refs mapping. func dshCredentials(t *testing.T, path string) map[string]string { t.Helper() data, err := os.ReadFile(path) if err != nil { t.Fatal(err) } - parsed := map[string]string{} - if err := yaml.Unmarshal(data, &parsed); err != nil { - t.Fatalf("credentials are not a string mapping: %v\n%s", err, data) + var document struct { + Version int `yaml:"version"` + Refs map[string]string `yaml:"refs"` + } + if err := yaml.Unmarshal(data, &document); err != nil { + t.Fatalf("credentials are not a versioned document: %v\n%s", err, data) } - return parsed + if document.Version != 1 { + t.Fatalf("credential version = %d, want 1\n%s", document.Version, data) + } + return document.Refs } // Both files BootAgent writes for dsh are the user's, shared with dsh's own @@ -1018,6 +1025,25 @@ func TestWriteDSHIsIdempotent(t *testing.T) { } } +func TestWriteDSHProtocolUsesResponsesForModelsThatRequireIt(t *testing.T) { + home := t.TempDir() + path := filepath.Join(home, ".dsh", "settings.yaml") + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + writer := testWriter(t, home, "linux") + if err := writer.WriteDSHProtocol(context.Background(), path, "OpenAI", "https://api.example", "sk-x", "gpt-5.6-sol", provider.ProtocolResponses); err != nil { + t.Fatal(err) + } + route := dshSettings(t, path)["bootagent"] + if route["api"] != "openai-responses" { + t.Fatalf("route api = %v, want openai-responses", route["api"]) + } + if route["baseURL"] != "https://api.example/v1" { + t.Fatalf("route baseURL = %v, want /v1", route["baseURL"]) + } +} + // The route is BootAgent's own, so redefining it must not leave a field from the // activation before it behind -- a stale compat block or a narrower model list // would keep serving under a route the user believes was just repointed. diff --git a/internal/desktopapp/dsh.go b/internal/desktopapp/dsh.go index e96b9a49..d7f06021 100644 --- a/internal/desktopapp/dsh.go +++ b/internal/desktopapp/dsh.go @@ -180,11 +180,20 @@ func installDSH(ctx context.Context, options Options) (ActionResult, error) { if err := downloadDSH(ctx, options, url, name); err != nil { return ActionResult{}, err } - mount := filepath.Dir(name) + "/mount" - if err := os.MkdirAll(mount, 0o700); err != nil { + mount, err := os.MkdirTemp(filepath.Dir(name), "dsh-mount-") + if err != nil { return ActionResult{}, err } - defer os.RemoveAll(mount) + mounted := false + defer func() { + if mounted { + // Cleanup must run even when the install context is cancelled. The + // image is read-only, so detaching it is safe and prevents a leaked + // volume from blocking later installs or temporary-directory removal. + _, _ = run(options, context.Background(), []string{"/usr/bin/hdiutil", "detach", mount}, installTimeout) + } + _ = os.RemoveAll(mount) + }() result, err := run(options, ctx, []string{"/usr/bin/hdiutil", "attach", name, "-nobrowse", "-readonly", "-mountpoint", mount}, installTimeout) if err != nil { return ActionResult{}, fmt.Errorf("mount %s installer: %w", DSHDesktopName, err) @@ -192,6 +201,7 @@ func installDSH(ctx context.Context, options Options) (ActionResult, error) { if result.ExitCode != 0 { return ActionResult{}, commandFailure("mount "+DSHDesktopName+" installer", result) } + mounted = true app := filepath.Join(mount, "DSH Desktop.app") if _, err := os.Stat(app); err != nil { return ActionResult{}, errors.New("DSH Desktop.app not found in installer") diff --git a/internal/desktopapp/dsh_mount_darwin_test.go b/internal/desktopapp/dsh_mount_darwin_test.go new file mode 100644 index 00000000..8be4c063 --- /dev/null +++ b/internal/desktopapp/dsh_mount_darwin_test.go @@ -0,0 +1,131 @@ +//go:build darwin + +package desktopapp + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MaimoryLab/BootAgent/internal/platform" + "github.com/MaimoryLab/BootAgent/internal/process" +) + +// hdiutilRunner runs hdiutil for real and stubs everything else. The mount is the +// behavior under test, so faking hdiutil would test the fake. +type hdiutilRunner struct { + mountPoints []string + calls [][]string +} + +func (r *hdiutilRunner) LookPath(string) (string, bool) { return "", false } + +func (r *hdiutilRunner) Start([]string, map[string]string) error { return nil } + +func (r *hdiutilRunner) Run(ctx context.Context, argv []string, _ map[string]string, _ time.Duration) (process.Result, error) { + r.calls = append(r.calls, append([]string(nil), argv...)) + if len(argv) > 0 && argv[0] == "/usr/bin/hdiutil" { + if argv[1] == "attach" { + for index, value := range argv { + if value == "-mountpoint" && index+1 < len(argv) { + r.mountPoints = append(r.mountPoints, argv[index+1]) + } + } + } + output, err := exec.CommandContext(ctx, argv[0], argv[1:]...).CombinedOutput() + result := process.Result{Args: argv, Stdout: string(output)} + if err != nil { + result.ExitCode = 1 + result.Stderr = string(output) + } + return result, nil + } + // codesign, spctl and ditto all report success; the install then completes + // without copying anything into a real Applications directory. + return process.Result{Args: argv, ExitCode: 0}, nil +} + +func mountedAt(t *testing.T, path string) bool { + t.Helper() + output, err := exec.Command("/sbin/mount").Output() + if err != nil { + t.Fatalf("read mount table: %v", err) + } + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + // Gone from disk entirely, so it cannot be mounted. + return false + } + return strings.Contains(string(output), resolved) +} + +// installDSH used to attach the image and never detach it, at a mountpoint fixed +// at $TMPDIR/mount that every attempt shared. The mount outlived the process, and +// the deferred RemoveAll could not delete a mounted volume. Two installs in a row +// is what makes the leak visible: the second used to stack another mount on the +// same path. +func TestInstallDSHDetachesTheImageAndReusesNoMountpoint(t *testing.T) { + image, err := os.ReadFile("testdata/dsh-fixture.dmg") + if err != nil { + t.Skipf("mount fixture unavailable: %v", err) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = w.Write(image) + })) + defer server.Close() + + var seen []string + for attempt := range 2 { + runner := &hdiutilRunner{} + options := Options{ + Home: t.TempDir(), + Platform: platform.For("macos", "arm64"), + Runner: runner, + // PreferMirror keeps dshURL from reaching the release API; the injected + // client then answers the mirror URL from the fixture server, so the + // host allowlist is still what judges the URL. + PreferMirror: true, + Downloader: dmgClient{base: server.URL}, + ApplicationDirs: []string{t.TempDir()}, + SearchRoots: []string{t.TempDir()}, + } + if _, err := installDSH(context.Background(), options); err != nil { + t.Fatalf("attempt %d: installDSH: %v", attempt+1, err) + } + if len(runner.mountPoints) != 1 { + t.Fatalf("attempt %d: mountpoints used = %v, want exactly one", attempt+1, runner.mountPoints) + } + mount := runner.mountPoints[0] + if mountedAt(t, mount) { + // Leave nothing behind for the next test even when this one fails. + _, _ = exec.Command("/usr/bin/hdiutil", "detach", mount, "-force").CombinedOutput() + t.Fatalf("attempt %d: %s is still mounted after installDSH", attempt+1, mount) + } + if _, err := os.Stat(mount); !os.IsNotExist(err) { + t.Errorf("attempt %d: temporary directory survived: stat %s = %v", attempt+1, mount, err) + } + seen = append(seen, mount) + } + if seen[0] == seen[1] { + t.Errorf("both installs used the same mountpoint %q; it must be per-install", seen[0]) + } +} + +// dmgClient serves the fixture for the download while leaving the release API +// unused: the test supplies DownloadURL instead. +type dmgClient struct{ base string } + +func (c dmgClient) Do(request *http.Request) (*http.Response, error) { + redirected, err := http.NewRequestWithContext(request.Context(), request.Method, c.base, nil) + if err != nil { + return nil, err + } + return http.DefaultClient.Do(redirected) +} diff --git a/internal/desktopapp/testdata/dsh-fixture.dmg b/internal/desktopapp/testdata/dsh-fixture.dmg new file mode 100644 index 0000000000000000000000000000000000000000..f7634531e81835dec9436f1033c87afbbf4ea8c6 GIT binary patch literal 17478 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 Ok)g-eR>xtgAO8aieTMS@ literal 0 HcmV?d00001 diff --git a/internal/platform/native_darwin.go b/internal/platform/native_darwin.go new file mode 100644 index 00000000..94bd2c23 --- /dev/null +++ b/internal/platform/native_darwin.go @@ -0,0 +1,28 @@ +//go:build darwin + +package platform + +import "syscall" + +// nativeArch reports the architecture of the machine rather than of this +// process. +// +// runtime.GOARCH names the binary, and the two disagree under Rosetta 2: the +// amd64 build of BootAgent installed on an Apple Silicon Mac reports "amd64" on +// hardware that is arm64. Everything downstream then selects x64 packages -- +// ZCode installs its x64 build, and DSH Desktop's macOS lookup refuses to +// resolve at all -- on a machine whose native packages exist and are faster. +// +// sysctl.proc_translated answers this directly and is readable from the +// translated process itself. uname/hw.machine is not usable here: it is +// translated too, and reports x86_64. Rosetta 2 only ever translates x86_64 on +// arm64 hardware, so a translated process is proof of an arm64 machine. +func nativeArch(goarch string) string { + if goarch != "amd64" { + return goarch + } + if translated, err := syscall.SysctlUint32("sysctl.proc_translated"); err == nil && translated == 1 { + return "arm64" + } + return goarch +} diff --git a/internal/platform/native_darwin_test.go b/internal/platform/native_darwin_test.go new file mode 100644 index 00000000..fc60da15 --- /dev/null +++ b/internal/platform/native_darwin_test.go @@ -0,0 +1,42 @@ +//go:build darwin + +package platform + +import ( + "os/exec" + "runtime" + "strings" + "testing" +) + +// nativeArch must not alter anything but a translated amd64 process: arm64 is +// already native, and on an Intel Mac amd64 is the truth. +func TestNativeArchLeavesUntranslatedValuesAlone(t *testing.T) { + if got := nativeArch("arm64"); got != "arm64" { + t.Errorf("nativeArch(arm64) = %q, want arm64", got) + } + if got := nativeArch("386"); got != "386" { + t.Errorf("nativeArch(386) = %q, want 386", got) + } +} + +// The correction is checked against the machine rather than against a stub, so +// this asserts the real relationship: whatever sysctl reports, Current() must +// name the hardware. On an arm64 Mac the arm64 test binary is untranslated and +// the amd64 one is, and both have to arrive at arm64. +func TestCurrentReportsHardwareArchNotBinaryArch(t *testing.T) { + out, err := exec.Command("/usr/sbin/sysctl", "-n", "hw.optional.arm64").Output() + if err != nil { + // Absent on Intel Macs, where GOARCH is already the hardware. + if got := Current().Arch; got != "x64" { + t.Errorf("Current().Arch on an Intel Mac = %q, want x64", got) + } + return + } + if strings.TrimSpace(string(out)) != "1" { + t.Skip("hw.optional.arm64 is present but not 1") + } + if got := Current().Arch; got != "arm64" { + t.Errorf("Current().Arch on arm64 hardware = %q (GOARCH=%s), want arm64", got, runtime.GOARCH) + } +} diff --git a/internal/platform/native_other.go b/internal/platform/native_other.go new file mode 100644 index 00000000..7450b742 --- /dev/null +++ b/internal/platform/native_other.go @@ -0,0 +1,11 @@ +//go:build !darwin + +package platform + +// nativeArch is the identity outside macOS. Windows on ARM also runs x64 +// processes under emulation, but its x64 packages are the supported way to +// install there, so correcting the value would select packages the vendors do +// not publish. +func nativeArch(goarch string) string { + return goarch +} diff --git a/internal/provider/client.go b/internal/provider/client.go index 7f7c9e35..5ca41985 100644 --- a/internal/provider/client.go +++ b/internal/provider/client.go @@ -97,7 +97,11 @@ func (c *Client) Probe(ctx context.Context, protocol, providerID, apiKey, model, // stream an unbounded response into the process. _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, c.maxBody)) return ProbeResult{ - OK: response.StatusCode == http.StatusOK || response.StatusCode == http.StatusNoContent, + // Every 2xx response proves that the endpoint accepted the protocol + // request. Restricting this to 200/204 produced a contradictory result + // for proxies returning 201/202: the message said "connection test + // passed" while OK=false made the UI render a failure state. + OK: response.StatusCode >= http.StatusOK && response.StatusCode < http.StatusMultipleChoices, Reachable: true, Status: response.StatusCode, Message: fmt.Sprintf("%s connection test passed.", ProtocolLabel(protocol)), diff --git a/internal/provider/client_test.go b/internal/provider/client_test.go index 53111459..39b3c41f 100644 --- a/internal/provider/client_test.go +++ b/internal/provider/client_test.go @@ -91,6 +91,26 @@ func TestProbeBuildsProtocolSpecificRequests(t *testing.T) { } } +func TestProbeTreatsEverySuccessfulHTTPStatusAsPassed(t *testing.T) { + for _, status := range []int{http.StatusOK, http.StatusCreated, http.StatusAccepted, http.StatusNoContent, http.StatusPartialContent, 299} { + t.Run(http.StatusText(status), func(t *testing.T) { + client := NewClient(fakeDoer(func(*http.Request) (*http.Response, error) { + return fakeResponse(status, `{}`), nil + })) + result, err := client.Probe(context.Background(), ProtocolAnthropic, "custom", "key", "model-a", "https://proxy.test/v1") + if err != nil { + t.Fatal(err) + } + if !result.OK { + t.Fatalf("HTTP %d was reported as failed: %#v", status, result) + } + if !strings.Contains(result.Message, "connection test passed") { + t.Fatalf("HTTP %d message = %q, want passed message", status, result.Message) + } + }) + } +} + func TestProbeClassifiesUnsupportedAndTransientResponses(t *testing.T) { unsupported := NewClient(fakeDoer(func(*http.Request) (*http.Response, error) { return fakeResponse(http.StatusBadRequest, `{"message":"model does not support endpoint"}`), nil From 37ebebea9a52b6a2114e11207a573179a7ea73d2 Mon Sep 17 00:00:00 2001 From: yujiezhang-ops Date: Thu, 17 Sep 2026 10:02:59 +0800 Subject: [PATCH 2/2] fix: use native architecture in platform detection --- internal/platform/platform.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/platform/platform.go b/internal/platform/platform.go index 0d3f2cd5..c899124a 100644 --- a/internal/platform/platform.go +++ b/internal/platform/platform.go @@ -16,7 +16,7 @@ type Info struct { } func Current() Info { - return For(runtime.GOOS, runtime.GOARCH) + return For(runtime.GOOS, nativeArch(runtime.GOARCH)) } func For(goos, goarch string) Info {