From 9ac8ecb63acb7e18797702e3fba62026b181dee9 Mon Sep 17 00:00:00 2001 From: yujiezhang-ops Date: Thu, 17 Sep 2026 09:51:30 +0800 Subject: [PATCH 1/8] 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/8] 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 { From ce9c7bd9d34b1f3af88e1951894c22531df0d6b8 Mon Sep 17 00:00:00 2001 From: yujiezhang-ops Date: Sat, 26 Sep 2026 16:46:55 +0800 Subject: [PATCH 3/8] feat: write dsh configuration into the 0.1.7 profile patch DeepSeek Harness 0.1.7 removed $DSH_HOME/settings.yaml. Live configuration now lives in $DSH_HOME/profiles//cordis.patch.yml, a top-level sequence of loader patch rows where each row replaces one plugin entry's whole config. dsh imports the legacy file once on first start and renames it settings.yaml.imported; agent-default-model has no import mapping, so a write into the legacy file after that loses the default selection. Resolve the target per profile: the patch when its profile directory exists, the legacy file otherwise, so the npm `latest` CLI (0.1.5) keeps working while the desktop app and `next` (0.1.7) read what BootAgent writes. The CLI Agent uses the `web` profile, the desktop Agent `desktop`. Writes into the patch keep every other provider in the llm-pi-ai row and round-trip the bundle's `!!js` tagged scalars untouched; the credential store stays at the harness home and is located from either layout. ReadDSHConfig tells the two layouts apart by the document's root kind. A fixture with the account-platform `records` the desktop app keeps beside `refs` proves the credential merge leaves them intact. Co-Authored-By: Claude Fable 5 --- internal/app/desktopapp.go | 15 +- internal/app/status.go | 7 + internal/config/discovery.go | 73 ++++-- internal/config/dsh_profile.go | 225 +++++++++++++++++ internal/config/dsh_profile_test.go | 370 ++++++++++++++++++++++++++++ internal/config/write.go | 10 +- manifests/agents.lock.json | 2 +- 7 files changed, 679 insertions(+), 23 deletions(-) create mode 100644 internal/config/dsh_profile.go create mode 100644 internal/config/dsh_profile_test.go diff --git a/internal/app/desktopapp.go b/internal/app/desktopapp.go index e075f3d7..05bad3f4 100644 --- a/internal/app/desktopapp.go +++ b/internal/app/desktopapp.go @@ -257,7 +257,7 @@ func (u *UseCases) writeDesktopAgentConfig(ctx context.Context, definition deskt if strings.TrimSpace(definition.ConfigPath) == "" { return "", false, nil } - path := filepath.Join(u.status.Home, filepath.FromSlash(definition.ConfigPath)) + path := u.desktopAgentConfigPath(definition) if err := writeManagedAgentConfig(ctx, writer, definition.ID, catalog.Agent{ ConfigAdapter: definition.ConfigAdapter, }, path, dshRouteProviderID(target, ""), target.Name, target.BaseFor(protocol), target.APIKey, model, "", false); err != nil { @@ -318,11 +318,22 @@ func (u *UseCases) publicDesktopAgentStatus(value desktopapp.Status) DesktopAgen status.Protocol = provider.ProtocolForAdapter(shared.ConfigAdapter) } } else if definition.ConfigPath != "" && value.Supported { - status.ConfigPath = filepath.Join(u.status.Home, filepath.FromSlash(definition.ConfigPath)) + status.ConfigPath = u.desktopAgentConfigPath(definition) } return status } +// desktopAgentConfigPath is the document a desktop Agent's configuration is +// written to. For DeepSeek Harness that is the patch of the profile the Electron +// shell owns, once the shell has created it; the definition's ConfigPath is the +// legacy fallback an older harness still reads. +func (u *UseCases) desktopAgentConfigPath(definition desktopapp.Definition) string { + if definition.ConfigAdapter == desktopapp.ConfigAdapterDSH { + return configWriter.ResolveDSHConfigPath(u.status.Home, configWriter.DSHDesktopProfile) + } + return filepath.Join(u.status.Home, filepath.FromSlash(definition.ConfigPath)) +} + func knownDesktopAgentID(value string) (string, error) { value = strings.TrimSpace(value) if _, ok := desktopapp.DefinitionFor(value); ok { diff --git a/internal/app/status.go b/internal/app/status.go index bb1b84a2..0af5f269 100644 --- a/internal/app/status.go +++ b/internal/app/status.go @@ -823,6 +823,13 @@ func configPath(home, osID string, agent catalog.Agent) string { if agent.ConfigPath == "" { return "" } + // dsh moved its live configuration into the profile `dsh web` boots between + // 0.1.5 and 0.1.7. The manifest keeps the legacy path as the default; on a + // machine where the new release has already created its profile, that + // profile's patch is the document that is actually read. + if agent.ConfigAdapter == "dsh" { + return configWriter.ResolveDSHConfigPath(home, configWriter.DSHWebProfile) + } relative := agent.ConfigPath if osID == "windows" && agent.WindowsConfigPath != "" { relative = agent.WindowsConfigPath diff --git a/internal/config/discovery.go b/internal/config/discovery.go index 669ba8ec..1eefa1a0 100644 --- a/internal/config/discovery.go +++ b/internal/config/discovery.go @@ -193,26 +193,49 @@ func ReadAiderConfig(text string) Detected { // an endpoint -- the shipped route's endpoint is dsh's own fact, not something // this file records. func ReadDSHConfig(text string) Detected { - var parsed struct { - PiAI struct { - Providers map[string]struct { - BaseURL string `yaml:"baseURL"` - Models []struct { - ID string `yaml:"id"` - } `yaml:"models"` - } `yaml:"providers"` - } `yaml:"llm-pi-ai"` - Selection struct { - Provider string `yaml:"provider"` - Model string `yaml:"model"` - } `yaml:"agent-default-model"` - } - if err := yaml.Unmarshal([]byte(text), &parsed); err != nil { + // Both documents carry the same two sections; only the container differs. + // The 0.1.7 profile patch is a sequence of rows keyed by entry id, the + // legacy settings.yaml a mapping keyed by section name. The root node's kind + // says which, without guessing from the path. + var root yaml.Node + if err := yaml.Unmarshal([]byte(text), &root); err != nil { return unreadable(fmt.Sprintf("YAML 无法解析:%v", err)) } - route, managed := parsed.PiAI.Providers[dshOwnedRoute] - if !managed && parsed.Selection.Provider == dshOfficialRoute { - return Detected{Model: parsed.Selection.Model} + var piAI dshPiAISection + var selection dshDefaultModelSection + if len(root.Content) == 1 && root.Content[0].Kind == yaml.SequenceNode { + for _, row := range root.Content[0].Content { + var entry struct { + ID string `yaml:"id"` + Config yaml.Node `yaml:"config"` + } + if row.Decode(&entry) != nil { + continue + } + switch entry.ID { + case dshPiAIEntryID: + if entry.Config.Decode(&piAI) != nil { + return unreadable("llm-pi-ai 配置无法解析") + } + case dshDefaultModelEntryID: + if entry.Config.Decode(&selection) != nil { + return unreadable("agent-default-model 配置无法解析") + } + } + } + } else { + var parsed struct { + PiAI dshPiAISection `yaml:"llm-pi-ai"` + Selection dshDefaultModelSection `yaml:"agent-default-model"` + } + if err := root.Decode(&parsed); err != nil { + return unreadable(fmt.Sprintf("YAML 无法解析:%v", err)) + } + piAI, selection = parsed.PiAI, parsed.Selection + } + route, managed := piAI.Providers[dshOwnedRoute] + if !managed && selection.Provider == dshOfficialRoute { + return Detected{Model: selection.Model} } model := "" // The first entry, not a search for a match: the route's catalog is ordered @@ -224,6 +247,20 @@ func ReadDSHConfig(text string) Detected { return Detected{BaseURL: route.BaseURL, Model: model, ManagedByBootAgent: managed} } +type dshPiAISection struct { + Providers map[string]struct { + BaseURL string `yaml:"baseURL"` + Models []struct { + ID string `yaml:"id"` + } `yaml:"models"` + } `yaml:"providers"` +} + +type dshDefaultModelSection struct { + Provider string `yaml:"provider"` + Model string `yaml:"model"` +} + func ReadHermesConfig(text string) Detected { var parsed struct { Model struct { diff --git a/internal/config/dsh_profile.go b/internal/config/dsh_profile.go new file mode 100644 index 00000000..dd0d1749 --- /dev/null +++ b/internal/config/dsh_profile.go @@ -0,0 +1,225 @@ +package config + +import ( + "context" + "os" + "path/filepath" + "strings" + + "github.com/MaimoryLab/BootAgent/internal/provider" + "gopkg.in/yaml.v3" +) + +// DeepSeek Harness 0.1.7 removed $DSH_HOME/settings.yaml. Live configuration now +// lives in the profile the launcher boots: $DSH_HOME/profiles// +// cordis.patch.yml, a top-level YAML sequence of loader patch rows. Each row +// addresses one plugin entry by id and replaces that entry's whole config, so a +// write that touches llm-pi-ai has to carry every provider the user declared +// there, not just the one BootAgent owns. +// +// The old document is imported once by dsh itself -- on first start it copies +// each section into the profile patch and renames the file settings.yaml.imported +// -- after which nothing reads settings.yaml again. A BootAgent write into it +// would be re-imported on the next launch, but agent-default-model has no import +// mapping, so the selection would be lost. The profile patch is therefore the +// only layout that works once the new release has run. +// +// Both layouts stay supported: the npm `latest` tag still ships 0.1.5, which +// reads settings.yaml, while the desktop application and `next` ship 0.1.7. +const ( + DSHProfilePatchName = "cordis.patch.yml" + DSHLegacySettings = "settings.yaml" + // DSHWebProfile is what `dsh web` boots; DSHDesktopProfile is what the + // Electron shell owns. They share $DSH_HOME but not configuration. + DSHWebProfile = "web" + DSHDesktopProfile = "desktop" + + dshPiAIEntryID = "llm-pi-ai" + dshPiAIEntryName = "@deepseek-ai/dsh-llm-pi-ai" + dshDefaultModelEntryID = "agent-default-model" + dshDefaultModelEntryName = "@deepseek-ai/dsh-agent-default-model" +) + +// ResolveDSHConfigPath picks the document BootAgent should write for one dsh +// profile. The profile patch wins whenever its directory exists: dsh creates it +// on first boot of that profile, and once it does the legacy file is either gone +// or renamed. Without it the legacy settings.yaml is returned so an older CLI +// keeps working -- the patch is never created speculatively, because a +// profiles/ directory BootAgent invented would not be one dsh boots. +func ResolveDSHConfigPath(home, profile string) string { + patch := filepath.Join(home, ".dsh", "profiles", profile, DSHProfilePatchName) + if info, err := os.Stat(filepath.Dir(patch)); err == nil && info.IsDir() { + return patch + } + return filepath.Join(home, ".dsh", DSHLegacySettings) +} + +// dshUsesProfilePatch reports whether path names the 0.1.7 profile layout. +func dshUsesProfilePatch(path string) bool { + return filepath.Base(path) == DSHProfilePatchName +} + +// dshCredentialsPath locates $DSH_HOME/.credentials.yaml from either config +// document. The credential store did not move with the settings: it stays at the +// harness home and is shared by every profile, so from a profile patch it is two +// directories up. +func dshCredentialsPath(configPath string) string { + home := filepath.Dir(configPath) + if dshUsesProfilePatch(configPath) { + home = filepath.Dir(filepath.Dir(home)) + } + return filepath.Join(home, ".credentials.yaml") +} + +func (w Writer) writeDSHProfileRoute(ctx context.Context, path, providerName, baseURL, apiKey, model, protocolID string) error { + root, err := yamlSequenceDocument(path, "DeepSeek Harness profile patch") + if err != nil { + return err + } + piAI := dshPatchRow(root.Content[0], dshPiAIEntryID, dshPiAIEntryName) + config := yamlMappingChild(piAI, "config") + providers := yamlMappingChild(config, "providers") + + 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", apiName}, + {"baseURL", provider.OpenAIBaseURL(baseURL)}, + } { + yamlSet(route, item.key, item.value) + } + entry := &yaml.Node{Kind: yaml.MappingNode} + yamlSet(entry, "id", model) + route.Content = append(route.Content, + &yaml.Node{Kind: yaml.ScalarNode, Value: "models"}, + &yaml.Node{Kind: yaml.SequenceNode, Content: []*yaml.Node{entry}}, + ) + yamlReplace(providers, dshOwnedRoute, route) + + selection := &yaml.Node{Kind: yaml.MappingNode} + yamlSet(selection, "provider", dshOwnedRoute) + yamlSet(selection, "model", model) + yamlReplace(dshPatchRow(root.Content[0], dshDefaultModelEntryID, dshDefaultModelEntryName), "config", selection) + + data, err := yaml.Marshal(root) + if err != nil { + return configError("Cannot encode YAML configuration %s: %v", path, err) + } + return w.write(ctx, path, data, false) +} + +func (w Writer) writeDSHProfileOfficial(ctx context.Context, path, model, reasoningEffort string) error { + root, err := yamlSequenceDocument(path, "DeepSeek Harness profile patch") + if err != nil { + return err + } + // Lookup without creating, for the same reason as the legacy writer: when no + // bootagent route exists there is nothing to clean up, and the check must + // not leave an empty llm-pi-ai row behind. + if piAI := dshFindPatchRow(root.Content[0], dshPiAIEntryID); piAI != nil { + if config := yamlChild(piAI, "config"); config != nil { + if providers := yamlChild(config, "providers"); providers != nil { + yamlDelete(providers, dshOwnedRoute) + } + } + } + selection := &yaml.Node{Kind: yaml.MappingNode} + yamlSet(selection, "provider", dshOfficialRoute) + yamlSet(selection, "model", model) + if reasoningEffort != "" { + if err := ValidateDSHOfficialReasoningEffort(reasoningEffort); err != nil { + return err + } + yamlSet(selection, "reasoningEffort", reasoningEffort) + } + yamlReplace(dshPatchRow(root.Content[0], dshDefaultModelEntryID, dshDefaultModelEntryName), "config", selection) + + data, err := yaml.Marshal(root) + if err != nil { + return configError("Cannot encode YAML configuration %s: %v", path, err) + } + return w.write(ctx, path, data, false) +} + +// dshFindPatchRow returns the row addressing entry id, or nil. +func dshFindPatchRow(sequence *yaml.Node, id string) *yaml.Node { + for _, row := range sequence.Content { + if row.Kind != yaml.MappingNode { + continue + } + if value := yamlLookup(row, "id"); value != nil && value.Value == id { + return row + } + } + return nil +} + +// dshPatchRow returns the row addressing entry id, appending one when absent. +// A new row names the plugin package too: the bundle already mounts the entry, +// so the name is redundant there, but dsh's own Models page writes it and a row +// that carries it stays readable to someone editing the file by hand. +func dshPatchRow(sequence *yaml.Node, id, name string) *yaml.Node { + if row := dshFindPatchRow(sequence, id); row != nil { + return row + } + row := &yaml.Node{Kind: yaml.MappingNode} + yamlSet(row, "id", id) + yamlSet(row, "name", name) + sequence.Content = append(sequence.Content, row) + return row +} + +// yamlMappingChild returns the mapping under key, replacing a non-mapping value +// and creating the entry when absent. Unlike yamlMapping it does not fail on a +// scalar: inside a patch row a stray `config:` with no value is a null the user +// left behind, and the write is about to give it content. +func yamlMappingChild(parent *yaml.Node, key string) *yaml.Node { + if child := yamlChild(parent, key); child != nil { + return child + } + child := &yaml.Node{Kind: yaml.MappingNode} + yamlReplace(parent, key, child) + return child +} + +// yamlSequenceDocument parses a file whose document is a top-level sequence, +// the shape of a cordis.patch.yml. An absent or blank file starts an empty +// sequence. The bundle patches use `!!js` tagged scalars; yaml.v3 keeps unknown +// tags on the node, so they round-trip through this without being evaluated or +// rewritten. +func yamlSequenceDocument(path, label string) (*yaml.Node, error) { + text, err := readText(path) + if err != nil { + return nil, configError("Cannot read existing %s %s: %v", label, path, err) + } + if isBlankYAML(text) { + return &yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{{Kind: yaml.SequenceNode}}}, nil + } + root := &yaml.Node{} + if err := yaml.Unmarshal([]byte(text), root); err != nil { + return nil, configError("Existing %s is invalid: %s: %v", label, path, err) + } + if len(root.Content) != 1 || root.Content[0].Kind != yaml.SequenceNode { + return nil, configError("Existing %s must contain a list: %s", label, path) + } + return root, nil +} + +// isBlankYAML is true for a file with no content besides comments, which is what +// dsh's scaffold leaves in a fresh cordis.patch.yml: a header comment and +// nothing else. yaml.v3 parses that as an empty document, so it has to be +// caught before Unmarshal, which would otherwise report no sequence. +func isBlankYAML(text string) bool { + for line := range strings.SplitSeq(text, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed != "" && !strings.HasPrefix(trimmed, "#") { + return false + } + } + return true +} diff --git a/internal/config/dsh_profile_test.go b/internal/config/dsh_profile_test.go new file mode 100644 index 00000000..cf9599d4 --- /dev/null +++ b/internal/config/dsh_profile_test.go @@ -0,0 +1,370 @@ +package config + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +// The shape dsh 0.1.7 leaves after importing a legacy settings.yaml into the +// desktop profile: a header comment, one row per imported section, the user's +// own pi-ai route, and the bundle's `!!js` tags on rows the import copied. +const dshProfilePatchFixture = `# Your patch layer for this dsh profile, applied after every bundle layer: +# a top-level YAML array of loader patch entries. +- id: ui-settings-general + name: "@deepseek-ai/dsh-client-ui-settings-general" + config: + welcomeNoticeVersion: 2026-08-13.1 +- id: llm-pi-ai + name: "@deepseek-ai/dsh-llm-pi-ai" + config: + providers: + paigod: + displayName: paigod + apiKeyEnv: PAIGOD_API_KEY + api: openai-completions + baseURL: https://apiproxy.paigod.work/v1 + models: + - id: gpt-5.4 +- id: session-persistence-jsonl + config: + root: !!js dshHomePath('sessions') +- id: agent-default-model + name: "@deepseek-ai/dsh-agent-default-model" + config: + provider: deepseek-official + model: deepseek-v4-flash + reasoningEffort: high +` + +// The credential store as the desktop application leaves it after an account +// login: refs beside records the Models page never touches. Values are shaped +// like the real ones without being real. +const dshCredentialsWithRecordsFixture = `version: 1 +refs: + PAIGOD_API_KEY: sk-users-own +records: + client-connection/browser-session: + kind: grant + payload: + version: 1 + secret: browser-session-secret + deepseek-account-platform/device: + kind: grant + payload: + id: 00000000-0000-0000-0000-000000000000 + deepseek-account-platform/default: + kind: grant + payload: + version: 1 + token: account-token + issuer: https://platform.deepseek.com +` + +func dshProfileHome(t *testing.T, profile, patch, credentials string) (home, patchPath, credentialsPath string) { + t.Helper() + home = t.TempDir() + patchPath = filepath.Join(home, ".dsh", "profiles", profile, DSHProfilePatchName) + credentialsPath = filepath.Join(home, ".dsh", ".credentials.yaml") + if err := os.MkdirAll(filepath.Dir(patchPath), 0o700); err != nil { + t.Fatal(err) + } + if patch != "" { + if err := os.WriteFile(patchPath, []byte(patch), 0o600); err != nil { + t.Fatal(err) + } + } + if credentials != "" { + if err := os.WriteFile(credentialsPath, []byte(credentials), 0o600); err != nil { + t.Fatal(err) + } + } + return home, patchPath, credentialsPath +} + +// dshPatchRows reads a profile patch back as id → config, so a test can check +// the row it cares about without restating the file. +func dshPatchRows(t *testing.T, path string) map[string]map[string]any { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var rows []struct { + ID string `yaml:"id"` + Config map[string]any `yaml:"config"` + } + if err := yaml.Unmarshal(data, &rows); err != nil { + t.Fatalf("profile patch is not a YAML list: %v\n%s", err, data) + } + result := make(map[string]map[string]any, len(rows)) + for _, row := range rows { + if _, duplicate := result[row.ID]; duplicate { + t.Fatalf("row %q appears twice:\n%s", row.ID, data) + } + result[row.ID] = row.Config + } + return result +} + +func TestResolveDSHConfigPathPrefersTheProfilePatchOnceTheProfileExists(t *testing.T) { + home := t.TempDir() + legacy := filepath.Join(home, ".dsh", DSHLegacySettings) + // Nothing on disk yet: an older CLI reads settings.yaml, so that is what + // gets written. The profile directory is never invented. + if got := ResolveDSHConfigPath(home, DSHWebProfile); got != legacy { + t.Fatalf("fresh home resolves to %q, want %q", got, legacy) + } + // dsh 0.1.7 has booted the desktop profile. Only that profile switches; the + // web profile it has not created still resolves to the legacy file. + desktop := filepath.Join(home, ".dsh", "profiles", DSHDesktopProfile) + if err := os.MkdirAll(desktop, 0o700); err != nil { + t.Fatal(err) + } + if got := ResolveDSHConfigPath(home, DSHDesktopProfile); got != filepath.Join(desktop, DSHProfilePatchName) { + t.Fatalf("desktop profile resolves to %q", got) + } + if got := ResolveDSHConfigPath(home, DSHWebProfile); got != legacy { + t.Fatalf("web profile resolves to %q, want the legacy file", got) + } +} + +func TestDSHCredentialsPathIsTheHarnessHomeFromEitherLayout(t *testing.T) { + want := filepath.Join("home", ".dsh", ".credentials.yaml") + for _, path := range []string{ + filepath.Join("home", ".dsh", DSHLegacySettings), + filepath.Join("home", ".dsh", "profiles", "desktop", DSHProfilePatchName), + } { + if got := dshCredentialsPath(path); got != want { + t.Errorf("dshCredentialsPath(%q) = %q, want %q", path, got, want) + } + } +} + +func TestWriteDSHProfileRegistersARouteBesideTheUsersOwn(t *testing.T) { + home, path, credentials := dshProfileHome(t, DSHDesktopProfile, dshProfilePatchFixture, dshCredentialsWithRecordsFixture) + if err := testWriter(t, home, "linux").WriteDSH(context.Background(), path, "PPIO", "https://api.example/openai", "sk-new", "deepseek-v4-pro"); err != nil { + t.Fatal(err) + } + + rows := dshPatchRows(t, path) + providers, _ := rows["llm-pi-ai"]["providers"].(map[string]any) + if _, ok := providers["paigod"]; !ok { + t.Errorf("the user's own route was lost: %v", providers) + } + route, _ := providers["bootagent"].(map[string]any) + if route == nil { + t.Fatalf("no bootagent route was written: %v", providers) + } + for key, want := range map[string]any{ + "displayName": "PPIO", + "apiKeyEnv": "BOOTAGENT_API_KEY", + "api": "openai-completions", + "baseURL": "https://api.example/openai/v1", + } { + if route[key] != want { + t.Errorf("route %s = %v, want %v", key, route[key], want) + } + } + models, _ := route["models"].([]any) + if len(models) != 1 { + t.Fatalf("route models = %v, want exactly the resolved model", route["models"]) + } + if entry, _ := models[0].(map[string]any); entry["id"] != "deepseek-v4-pro" { + t.Errorf("seeded model = %v, want deepseek-v4-pro", models[0]) + } + + // The default selection moves to the route, and the effort the replaced + // DeepSeek model carried does not ride along. + selection := rows["agent-default-model"] + if selection["provider"] != "bootagent" || selection["model"] != "deepseek-v4-pro" { + t.Errorf("default selection = %v, want the bootagent route", selection) + } + if _, stale := selection["reasoningEffort"]; stale { + t.Errorf("stale reasoningEffort survived: %v", selection) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + // Rows BootAgent does not own keep their place, and the bundle's tagged + // scalar survives untouched rather than being evaluated or quoted away. + for _, want := range []string{"ui-settings-general", "welcomeNoticeVersion", "!!js dshHomePath('sessions')"} { + if !strings.Contains(string(data), want) { + t.Errorf("unrelated row content %q was lost:\n%s", want, data) + } + } + // The header comment is the user's orientation in a file dsh tells them to + // edit by hand. + if !strings.HasPrefix(string(data), "# Your patch layer") { + t.Errorf("header comment was lost:\n%s", data) + } + + // The credential lands in the shared store two directories up, beside the + // user's own reference -- and beside the account grants the desktop + // application keeps in the same document, which must come through intact. + stored := dshCredentials(t, credentials) + if stored["BOOTAGENT_API_KEY"] != "sk-new" || stored["PAIGOD_API_KEY"] != "sk-users-own" { + t.Errorf("credentials = %v", stored) + } + var document struct { + Records map[string]struct { + Kind string `yaml:"kind"` + Payload map[string]any `yaml:"payload"` + } `yaml:"records"` + } + raw, err := os.ReadFile(credentials) + if err != nil { + t.Fatal(err) + } + if err := yaml.Unmarshal(raw, &document); err != nil { + t.Fatal(err) + } + if len(document.Records) != 3 { + t.Fatalf("account records = %d, want all 3 preserved:\n%s", len(document.Records), raw) + } + if grant := document.Records["deepseek-account-platform/default"]; grant.Kind != "grant" || grant.Payload["token"] != "account-token" || grant.Payload["issuer"] != "https://platform.deepseek.com" { + t.Errorf("account grant was disturbed: %+v", grant) + } + + detected := ReadDSHConfig(string(data)) + if detected.BaseURL != "https://api.example/openai/v1" || detected.Model != "deepseek-v4-pro" || !detected.ManagedByBootAgent { + t.Fatalf("profile round-trip = %#v", detected) + } + for _, target := range []string{path, credentials} { + info, err := os.Stat(target) + if err != nil || info.Mode().Perm() != 0o600 { + t.Fatalf("%s mode = %v, err=%v", target, info.Mode().Perm(), err) + } + } +} + +// A fresh profile patch is a header comment and nothing else; yaml.v3 reads that +// as an empty document, which must start a sequence rather than fail. +func TestWriteDSHProfileStartsFromACommentOnlyPatch(t *testing.T) { + home, path, _ := dshProfileHome(t, DSHDesktopProfile, "# Your patch layer for this dsh profile.\n# Edit freely.\n", "") + if err := testWriter(t, home, "linux").WriteDSH(context.Background(), path, "PPIO", "https://api.example", "sk", "m"); err != nil { + t.Fatal(err) + } + rows := dshPatchRows(t, path) + if len(rows) != 2 { + t.Fatalf("rows = %v, want llm-pi-ai and agent-default-model only", rows) + } + data, _ := os.ReadFile(path) + // A row BootAgent creates names its plugin, as dsh's own Models page does. + if !strings.Contains(string(data), dshPiAIEntryName) || !strings.Contains(string(data), dshDefaultModelEntryName) { + t.Errorf("new rows do not name their plugins:\n%s", data) + } +} + +func TestWriteDSHProfileIsIdempotent(t *testing.T) { + home, path, credentials := dshProfileHome(t, DSHDesktopProfile, "", "") + writer := testWriter(t, home, "linux") + for range 3 { + if err := writer.WriteDSH(context.Background(), path, "PPIO", "https://api.example", "sk", "m"); err != nil { + t.Fatal(err) + } + } + rows := dshPatchRows(t, path) + providers, _ := rows["llm-pi-ai"]["providers"].(map[string]any) + if len(providers) != 1 { + t.Errorf("providers = %v, want exactly one after repeated writes", providers) + } + route, _ := providers["bootagent"].(map[string]any) + if models, _ := route["models"].([]any); len(models) != 1 { + t.Errorf("models accumulated: %v", route["models"]) + } + data, _ := os.ReadFile(credentials) + if strings.Count(string(data), "BOOTAGENT_API_KEY") != 1 { + t.Errorf("credential entries accumulated:\n%s", data) + } +} + +func TestWriteDSHProfileOfficialUsesTheShippedRouteAndCleansUp(t *testing.T) { + home, path, credentials := dshProfileHome(t, DSHDesktopProfile, dshProfilePatchFixture, dshCredentialsWithRecordsFixture) + writer := testWriter(t, home, "linux") + // An earlier activation against a gateway left a bootagent route behind. + if err := writer.WriteDSH(context.Background(), path, "PPIO", "https://api.example", "sk-gateway", "m"); err != nil { + t.Fatal(err) + } + if err := writer.WriteDSHOfficial(context.Background(), path, "sk-deepseek", "deepseek-v4-pro", "max"); err != nil { + t.Fatal(err) + } + + rows := dshPatchRows(t, path) + providers, _ := rows["llm-pi-ai"]["providers"].(map[string]any) + if _, stale := providers["bootagent"]; stale { + t.Errorf("stale bootagent route survived the official activation: %v", providers) + } + if _, ok := providers["paigod"]; !ok { + t.Errorf("the user's own route was lost: %v", providers) + } + selection := rows["agent-default-model"] + if selection["provider"] != "deepseek-official" || selection["model"] != "deepseek-v4-pro" || selection["reasoningEffort"] != "max" { + t.Errorf("default selection = %v", selection) + } + + stored := dshCredentials(t, credentials) + if stored["DEEPSEEK_API_KEY"] != "sk-deepseek" { + t.Errorf("official credential = %q", stored["DEEPSEEK_API_KEY"]) + } + // The unreferenced bootagent key is left alone: this write touches only the + // entry it has to. + if stored["BOOTAGENT_API_KEY"] != "sk-gateway" { + t.Errorf("bootagent credential = %q, want untouched", stored["BOOTAGENT_API_KEY"]) + } + + data, _ := os.ReadFile(path) + detected := ReadDSHConfig(string(data)) + if detected.Model != "deepseek-v4-pro" || detected.BaseURL != "" || detected.ManagedByBootAgent { + t.Fatalf("official round-trip = %#v", detected) + } +} + +// An official activation on a patch with no llm-pi-ai row has nothing to clean +// up and must not create an empty row as a side effect. +func TestWriteDSHProfileOfficialLeavesNoEmptyPiAIRow(t *testing.T) { + home, path, _ := dshProfileHome(t, DSHDesktopProfile, "", "") + if err := testWriter(t, home, "linux").WriteDSHOfficial(context.Background(), path, "sk", "deepseek-v4-flash", ""); err != nil { + t.Fatal(err) + } + rows := dshPatchRows(t, path) + if _, created := rows["llm-pi-ai"]; created { + t.Errorf("an empty llm-pi-ai row was created: %v", rows) + } + if _, stale := rows["agent-default-model"]["reasoningEffort"]; stale { + t.Errorf("an empty reasoningEffort was written: %v", rows["agent-default-model"]) + } +} + +func TestWriteDSHProfileRefusesANonListDocument(t *testing.T) { + home, path, _ := dshProfileHome(t, DSHDesktopProfile, "llm-pi-ai:\n providers: {}\n", "") + err := testWriter(t, home, "linux").WriteDSH(context.Background(), path, "PPIO", "https://api.example", "sk", "m") + if err == nil || !strings.Contains(err.Error(), "must contain a list") { + t.Fatalf("mapping-shaped patch was accepted: %v", err) + } +} + +// Both layouts read back through one function; the container is told apart by +// the document's root kind, not the path. +func TestReadDSHConfigReadsBothLayouts(t *testing.T) { + legacy := "llm-pi-ai:\n providers:\n bootagent:\n baseURL: https://legacy.example/v1\n models:\n - id: legacy-model\n" + if got := ReadDSHConfig(legacy); got.BaseURL != "https://legacy.example/v1" || got.Model != "legacy-model" || !got.ManagedByBootAgent { + t.Errorf("legacy layout = %#v", got) + } + patch := "- id: llm-pi-ai\n config:\n providers:\n bootagent:\n baseURL: https://patch.example/v1\n models:\n - id: patch-model\n" + if got := ReadDSHConfig(patch); got.BaseURL != "https://patch.example/v1" || got.Model != "patch-model" || !got.ManagedByBootAgent { + t.Errorf("profile layout = %#v", got) + } + // A patch that only selects the shipped route reports the model without + // claiming management, as the legacy reader does. + official := "- id: agent-default-model\n config:\n provider: deepseek-official\n model: deepseek-v4-pro\n" + if got := ReadDSHConfig(official); got.Model != "deepseek-v4-pro" || got.ManagedByBootAgent || got.BaseURL != "" { + t.Errorf("official-only layout = %#v", got) + } +} diff --git a/internal/config/write.go b/internal/config/write.go index cbee327b..bb497999 100644 --- a/internal/config/write.go +++ b/internal/config/write.go @@ -522,9 +522,12 @@ func (w Writer) WriteDSH(ctx context.Context, path, providerName, baseURL, apiKe 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 { + if err := w.writeDSHCredential(ctx, dshCredentialsPath(path), dshCredentialReference, apiKey); err != nil { return err } + if dshUsesProfilePatch(path) { + return w.writeDSHProfileRoute(ctx, path, providerName, baseURL, apiKey, model, protocolID) + } root, err := yamlDocument(path, "DeepSeek Harness settings") if err != nil { return err @@ -611,9 +614,12 @@ func (w Writer) WriteDSHProtocol(ctx context.Context, path, providerName, baseUR func (w Writer) WriteDSHOfficial(ctx context.Context, path, apiKey, model, reasoningEffort string) error { // The credential lands first: a selection pointing at a route dsh cannot // authenticate is worse than an unreferenced key. - if err := w.writeDSHCredential(ctx, filepath.Join(filepath.Dir(path), ".credentials.yaml"), dshOfficialCredential, apiKey); err != nil { + if err := w.writeDSHCredential(ctx, dshCredentialsPath(path), dshOfficialCredential, apiKey); err != nil { return err } + if dshUsesProfilePatch(path) { + return w.writeDSHProfileOfficial(ctx, path, model, reasoningEffort) + } root, err := yamlDocument(path, "DeepSeek Harness settings") if err != nil { return err diff --git a/manifests/agents.lock.json b/manifests/agents.lock.json index 66fd48bc..9495a7df 100644 --- a/manifests/agents.lock.json +++ b/manifests/agents.lock.json @@ -301,7 +301,7 @@ "linux", "windows" ], - "guide": "当供应商为 DeepSeek 官方时,BootAgent 配置 ~/.dsh/settings.yaml 使用内置的 deepseek-official 路由,API Key 存入 ~/.dsh/.credentials.yaml 的 DEEPSEEK_API_KEY;当供应商为网关或其他自定义服务时,BootAgent 注册一个名为 bootagent 的自定义供应商并设为默认模型。它的界面是本地 Web 应用而非命令行:安装后运行 dsh web,在浏览器打开 http://127.0.0.1:3080。在 Settings - Models 里可以给该供应商补充更多模型或改回其他供应商。改动无需重启,dsh 会热加载。当前为开发者预览版,配置格式仍可能变动。", + "guide": "当供应商为 DeepSeek 官方时,BootAgent 把默认模型指向内置的 deepseek-official 路由,API Key 存入 ~/.dsh/.credentials.yaml 的 DEEPSEEK_API_KEY;当供应商为网关或其他自定义服务时,BootAgent 注册一个名为 bootagent 的自定义供应商并设为默认模型。它的界面是本地 Web 应用而非命令行:安装后运行 dsh web,在浏览器打开 http://127.0.0.1:3080。在 Settings - Models 里可以给该供应商补充更多模型或改回其他供应商。改动无需重启,dsh 会热加载。配置写入位置随 dsh 版本而异:0.1.7 起写入 ~/.dsh/profiles/web/cordis.patch.yml(桌面版为 profiles/desktop/),更早版本写入 ~/.dsh/settings.yaml,BootAgent 按本机已有的目录自动选择。当前为开发者预览版,配置格式仍可能变动。", "rank": 1 } } From 08f6013bd614b2014296fe3f6ac99b4ddd0c8d0d Mon Sep 17 00:00:00 2001 From: yujiezhang-ops Date: Sat, 26 Sep 2026 16:47:11 +0800 Subject: [PATCH 4/8] feat: install DeepSeek's own desktop app instead of the third-party build The dsh-desktop entry installed anywhere-labs' "DSH Desktop". DeepSeek now publishes its own desktop shell, "DeepSeek Harness", signed under Developer ID team NAN929V4UM with bundle id com.deepseek.dsh and served from download.deepseek.com with an electron-updater feed per target. Keep the Agent id so existing bindings resolve; point everything it names at the vendor's product. Resolve the package from the feed (dsh-desk/feeds/mac-arm64/nightly-mac.yml, dsh-desk/feeds/win-x64/nightly.yml), verify the served bytes against its sha512, and pin the signature to the vendor: bundle id and Team ID plus notarization on macOS, publisher on Windows. The previous checks accepted any valid codesign or Authenticode signature. The host allowlist is reduced to download.deepseek.com; the GitHub release lookup and dshdesktop.cn mirror go. Only mac-arm64 and win-x64 are published, so Intel macOS is refused up front rather than by a 404. Detection matches on the bundle identifier and reports the version from the bundle on disk, since the app updates itself. Drop the Unofficial mark and its disclaimer: the row is the vendor's own app. The disclaimer path is still exercised on a synthetic entry. Co-Authored-By: Claude Fable 5 --- README.md | 2 +- README_ZH.md | 2 +- frontend/src/components/icons/agents.test.tsx | 4 +- frontend/src/components/icons/agents.tsx | 14 +- .../src/pages/AgentSelectionPage.test.tsx | 58 +- .../testdata/status-empty-linux-arm64.json | 5 +- internal/desktopapp/desktopapp_test.go | 23 +- internal/desktopapp/download_client.go | 20 +- internal/desktopapp/download_client_test.go | 72 +-- internal/desktopapp/dsh.go | 548 +++++++++++++----- .../desktopapp/dsh_extract_darwin_test.go | 82 +++ internal/desktopapp/dsh_mount_darwin_test.go | 131 ----- internal/desktopapp/dsh_test.go | 323 +++++++++++ internal/desktopapp/macos_verify.go | 8 + internal/desktopapp/registry.go | 9 +- internal/desktopapp/testdata/dsh-fixture.dmg | Bin 17478 -> 0 bytes internal/desktopapp/testdata/dsh-fixture.zip | Bin 0 -> 1026 bytes internal/desktopapp/windows_verify.go | 19 +- 18 files changed, 875 insertions(+), 445 deletions(-) create mode 100644 internal/desktopapp/dsh_extract_darwin_test.go delete mode 100644 internal/desktopapp/dsh_mount_darwin_test.go create mode 100644 internal/desktopapp/dsh_test.go delete mode 100644 internal/desktopapp/testdata/dsh-fixture.dmg create mode 100644 internal/desktopapp/testdata/dsh-fixture.zip diff --git a/README.md b/README.md index 2b984f50..9d883083 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ BootAgent supports detection, configuration, launch, or installation guidance ac | CLI and local Agents | Desktop Agents | | --- | --- | -| Codex · Claude Code · OpenCode | DSH Desktop · Claude Desktop | +| Codex · Claude Code · OpenCode | DeepSeek Harness · Claude Desktop | | Kilo CLI · Aider · OpenClaw | ChatGPT Desktop · WorkBuddy | | Hermes Agent · Kimi Code · Pi | WorkBuddy AI · ZCode | | DeepSeek Harness (local web app) | | diff --git a/README_ZH.md b/README_ZH.md index 2dcf32a3..ddd04d66 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -93,7 +93,7 @@ BootAgent 会根据每个 Agent 的官方约定提供检测、配置、启动、 | CLI 和本地 Agent | 桌面 Agent | | --- | --- | -| Codex · Claude Code · OpenCode | DSH Desktop · Claude Desktop | +| Codex · Claude Code · OpenCode | DeepSeek Harness · Claude Desktop | | Kilo CLI · Aider · OpenClaw | ChatGPT Desktop · WorkBuddy | | Hermes Agent · Kimi Code · Pi | WorkBuddy AI · ZCode | | DeepSeek Harness(本地 Web 应用) | | diff --git a/frontend/src/components/icons/agents.test.tsx b/frontend/src/components/icons/agents.test.tsx index 1e810701..d85a1798 100644 --- a/frontend/src/components/icons/agents.test.tsx +++ b/frontend/src/components/icons/agents.test.tsx @@ -31,8 +31,8 @@ describe("AgentIcon", () => { const assetIds = AGENT_ICON_IDS.filter((id) => agentMarkKind(id) === "asset"); // chatgpt-desktop is a desktop Agent rather than a CLI, and it reuses the // OpenAI mark because it is OpenAI's own product sharing Codex's config. - // dsh-desktop reuses the DeepSeek mark for the same reason: it drives - // DeepSeek, though anywhere-labs rather than DeepSeek publishes it. + // dsh-desktop reuses the DeepSeek mark for the same reason: it is + // DeepSeek's own desktop shell around the dsh CLI. expect(assetIds.sort()).toEqual(["chatgpt-desktop", "claude-code", "claude-desktop", "codex", "dsh", "dsh-desktop", "hermes", "kilo-cli", "kimi-code", "openclaw", "opencode", "pi"]); for (const id of assetIds) { const rights = agentMarkRights(id); diff --git a/frontend/src/components/icons/agents.tsx b/frontend/src/components/icons/agents.tsx index 02e0db6c..3d26efde 100644 --- a/frontend/src/components/icons/agents.tsx +++ b/frontend/src/components/icons/agents.tsx @@ -138,15 +138,11 @@ const MARKS: Record = { source: assetRightsManifest.assets.dsh.source, rights: assetRightsManifest.assets.dsh, }, - // DSH Desktop reuses the CLI Harness mark: both drive DeepSeek, and the whale - // is what identifies whose model is behind them. Keyed by desktop Agent id - // because the desktop card looks itself up by id, as with chatgpt-desktop - // above -- without this entry the card fell back to the generic Bot glyph. - // - // Unlike chatgpt-desktop, this is not the vendor's own app: anywhere-labs - // publishes it. The mark still says DeepSeek because that is the model it - // talks to, so the Definition carries Unofficial and the UI states the - // publisher rather than letting the mark imply it. + // DeepSeek Harness (the desktop shell) reuses the CLI Harness mark: it is + // DeepSeek's own application around the same dsh, so the whale is the right + // identity for both. Keyed by desktop Agent id because the desktop card looks + // itself up by id, as with chatgpt-desktop above -- without this entry the + // card fell back to the generic Bot glyph. "dsh-desktop": { kind: "asset", markup: deepseekMark, diff --git a/frontend/src/pages/AgentSelectionPage.test.tsx b/frontend/src/pages/AgentSelectionPage.test.tsx index d4446ab6..6d0082fc 100644 --- a/frontend/src/pages/AgentSelectionPage.test.tsx +++ b/frontend/src/pages/AgentSelectionPage.test.tsx @@ -2,7 +2,7 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { describe, expect, it, vi } from "vitest"; -import type { AgentCatalogItem, StatusResponse } from "../types/api"; +import type { AgentCatalogItem, DesktopAgentStatus, StatusResponse } from "../types/api"; import { AgentSelectionPage } from "./AgentSelectionPage"; const dispatch = vi.fn(); @@ -38,15 +38,14 @@ const CATALOG: AgentCatalogItem[] = [ })); /** - * Three desktop Agents: a third-party build, one with a registered image mark, - * and one with a vendor bitmap. DSH Desktop leads, matching the order - * desktopapp.Definitions() returns. + * Three desktop Agents: two with registered image marks and one with a vendor + * bitmap. DeepSeek Harness leads, matching the order desktopapp.Definitions() + * returns. */ -const DESKTOP_AGENTS = [ +const DESKTOP_AGENTS: DesktopAgentStatus[] = [ { - id: "dsh-desktop", name: "DSH Desktop", installed: false, supported: true, + id: "dsh-desktop", name: "DeepSeek Harness", installed: false, supported: true, source: "unknown", version: null, profileAgentId: "dsh", profileId: null, protocol: "openai", - unofficial: true, }, { id: "chatgpt-desktop", name: "ChatGPT Desktop", installed: false, supported: true, @@ -58,7 +57,7 @@ const DESKTOP_AGENTS = [ }, ]; -function renderPage({ desktop = false }: { desktop?: boolean } = {}) { +function renderPage({ desktop = false, desktopAgents = DESKTOP_AGENTS }: { desktop?: boolean; desktopAgents?: DesktopAgentStatus[] } = {}) { dispatch.mockClear(); mockState = { status: { @@ -93,7 +92,7 @@ function renderPage({ desktop = false }: { desktop?: boolean } = {}) { backups: {}, environment: null, environmentError: null, - desktopAgents: desktop ? DESKTOP_AGENTS : [], + desktopAgents: desktop ? desktopAgents : [], profiles: [], activeProfile: null, firstRun: false, @@ -160,36 +159,47 @@ describe("AgentSelectionPage", () => { }; }); // Every row must resolve through the icon registry rather than a literal: - // DSH Desktop and ChatGPT Desktop to licensed vectors, ZCode to Z.ai's own - // bitmap. DSH Desktop had no registry entry of its own and fell back to the - // generic Bot glyph, which is what "asset" here guards against. + // DeepSeek Harness and ChatGPT Desktop to licensed vectors, ZCode to Z.ai's + // own bitmap. The dsh-desktop id once had no registry entry of its own and + // fell back to the generic Bot glyph, which is what "asset" here guards + // against. expect(marks).toEqual([ - { name: "选择 DSH Desktop", kind: "asset" }, + { name: "选择 DeepSeek Harness", kind: "asset" }, { name: "选择 ChatGPT Desktop", kind: "asset" }, { name: "选择 ZCode", kind: "raster" }, ]); }); it("does not advertise a third-party desktop build as the official application", () => { - // The mark is DeepSeek's because that is the model the app drives, but - // anywhere-labs publishes it. Without the disclaimer the row pairs a vendor - // mark with "install the official desktop application", which together read - // as a vendor download. - renderPage({ desktop: true }); - const row = screen.getByLabelText("选择 DSH Desktop").closest(".agent-row"); + // A build the backend marks unofficial must not pair a vendor mark with + // "install the official desktop application", which together read as a + // vendor download. None of the shipped entries carry the flag today -- the + // dsh-desktop row is DeepSeek's own app since the switch from the + // anywhere-labs build -- so the flag is exercised on a synthetic row. + renderPage({ + desktop: true, + desktopAgents: [ + { ...DESKTOP_AGENTS[0], id: "third-party-desktop", name: "Third Party", profileAgentId: "third-party", unofficial: true }, + ...DESKTOP_AGENTS, + ], + }); + const row = screen.getByLabelText("选择 Third Party").closest(".agent-row"); expect(row?.textContent).toContain("第三方桌面应用,非官方出品"); expect(row?.textContent).not.toContain("安装官方桌面应用"); // The vendors' own apps must not pick up the disclaimer. - const official = screen.getByLabelText("选择 ChatGPT Desktop").closest(".agent-row"); - expect(official?.textContent).toContain("安装官方桌面应用"); + for (const name of ["DeepSeek Harness", "ChatGPT Desktop"]) { + const official = screen.getByLabelText(`选择 ${name}`).closest(".agent-row"); + expect(official?.textContent).toContain("安装官方桌面应用"); + expect(official?.textContent).not.toContain("第三方桌面应用,非官方出品"); + } }); it("puts the desktop downloads in the order the backend returns", () => { - // The page must not re-sort: DSH Desktop leads because Definitions() puts it - // first, and a client-side sort here would silently override that. + // The page must not re-sort: DeepSeek Harness leads because Definitions() + // puts it first, and a client-side sort here would silently override that. renderPage({ desktop: true }); expect(screen.getAllByLabelText(/^选择 /).map((radio) => radio.getAttribute("aria-label"))).toEqual([ - "选择 DSH Desktop", + "选择 DeepSeek Harness", "选择 ChatGPT Desktop", "选择 ZCode", ]); diff --git a/internal/app/testdata/status-empty-linux-arm64.json b/internal/app/testdata/status-empty-linux-arm64.json index 3cdf69f6..634fe519 100644 --- a/internal/app/testdata/status-empty-linux-arm64.json +++ b/internal/app/testdata/status-empty-linux-arm64.json @@ -553,16 +553,15 @@ "environmentError": null, "desktopAgents": [ { - "home": "https://dshdesktop.cn", + "home": "https://www.deepseek.com/harness/", "id": "dsh-desktop", "installed": false, - "name": "DSH Desktop", + "name": "DeepSeek Harness", "profileAgentId": "dsh", "profileId": null, "protocol": "openai", "source": "unknown", "supported": false, - "unofficial": true, "version": null }, { diff --git a/internal/desktopapp/desktopapp_test.go b/internal/desktopapp/desktopapp_test.go index d37c8d7e..3a7fb433 100644 --- a/internal/desktopapp/desktopapp_test.go +++ b/internal/desktopapp/desktopapp_test.go @@ -186,30 +186,17 @@ func TestDesktopLifecycleRequiresAnExplicitKnownAgent(t *testing.T) { } } -func TestDSHURLUsesTheNPMMirrorPreference(t *testing.T) { - mac := Options{Platform: platform.For("macos", "arm64")} - mac.PreferMirror = true - got, err := dshURL(context.Background(), mac) - if err != nil || got != DSHDesktopMacMirrorURL { - t.Fatalf("mirror dsh URL = %q, %v", got, err) - } - win := Options{Platform: platform.For("windows", "amd64"), PreferMirror: true} - got, err = dshURL(context.Background(), win) - if err != nil || got != DSHDesktopWinMirrorURL { - t.Fatalf("windows mirror dsh URL = %q, %v", got, err) - } -} - func TestDesktopDefinitionsExposeIndependentProducts(t *testing.T) { definitions := Definitions() - // DSH Desktop leads the list because the UI renders it in this order. + // DeepSeek Harness leads the list because the UI renders it in this order. if len(definitions) < 4 || definitions[0].ID != DSHDesktopID || definitions[1].ID != ClaudeDesktopID || definitions[2].ID != ChatGPTDesktopID || definitions[3].ID != WorkBuddyID { t.Fatalf("desktop definitions = %#v", definitions) } - // Only the third-party build carries the flag; claiming it for a vendor's own - // app would put a false disclaimer on the row. + // DeepSeek publishes this build itself. The flag belonged to the third-party + // app this entry used to install; carrying it forward would put a false + // disclaimer on the vendor's own row. dsh, ok := DefinitionFor(DSHDesktopID) - if !ok || !dsh.Unofficial { + if !ok || dsh.Unofficial || dsh.Name != "DeepSeek Harness" || dsh.ProfileAgentID != "dsh" || dsh.Home != DSHDesktopHome { t.Fatalf("DSH definition = %#v, found=%v", dsh, ok) } if chatGPT, ok := DefinitionFor(ChatGPTDesktopID); !ok || chatGPT.Unofficial { diff --git a/internal/desktopapp/download_client.go b/internal/desktopapp/download_client.go index 827eb9e2..4a548774 100644 --- a/internal/desktopapp/download_client.go +++ b/internal/desktopapp/download_client.go @@ -13,19 +13,13 @@ const maxDownloadRedirects = 10 // downloadRedirectClient follows redirects the way an installer download needs. // -// The mirror for DSH Desktop redirects to ModelScope, which redirects again to a -// presigned CDN URL whose query carries the asset's own file name: -// -// ?filename=DSH Desktop-2.0.1-universal.dmg&...&auth_key=... -// -// That space is unencoded in the Location header. net/http keeps URL.RawQuery -// verbatim when it writes the request target, so the space goes out inside the -// request line, where HTTP has no way to read it as anything but the end of the -// target. The CDN's Tengine answered "400 Bad Request" and every mirror install -// failed at the last hop -- reported as "download returned HTTP 400", which -// looked like a network or region problem and so survived being tried with and -// without a VPN. curl escapes the space before sending, which is why the same -// URL reproduced fine by hand. +// Some CDNs redirect to a presigned URL whose query carries the asset's own file +// name with an unencoded space (a ModelScope-backed mirror once used for a +// desktop download did exactly this: ?filename=Some App-2.0.1.dmg&auth_key=...). +// net/http keeps URL.RawQuery verbatim when it writes the request target, so the +// space goes out inside the request line, where HTTP has no way to read it as +// anything but the end of the target, and the origin answers 400. curl escapes +// the space before sending, which is why such a URL reproduces fine by hand. // // Repairing the redirect target rather than the parsed URL keeps this to the one // thing that is wrong: the path already survives, because URL.String escapes it. diff --git a/internal/desktopapp/download_client_test.go b/internal/desktopapp/download_client_test.go index 48f8ba8e..0831a108 100644 --- a/internal/desktopapp/download_client_test.go +++ b/internal/desktopapp/download_client_test.go @@ -8,14 +8,11 @@ import ( "path/filepath" "strings" "testing" - - "github.com/MaimoryLab/BootAgent/internal/platform" ) -// The mirror's last hop is a presigned CDN URL whose query holds the asset file -// name with a raw space in it. Left alone, net/http writes that space into the -// request line and the CDN answers 400, which is the failure users hit on every -// mirror install. +// A CDN's last hop can be a presigned URL whose query holds the asset file name +// with a raw space in it. Left alone, net/http writes that space into the request +// line and the CDN answers 400. func TestDownloadFollowsRedirectWithUnencodedSpaceInQuery(t *testing.T) { var servedTarget string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -23,7 +20,7 @@ func TestDownloadFollowsRedirectWithUnencodedSpaceInQuery(t *testing.T) { case "/api/downloads/mac": // Written straight to the header so the space survives, exactly as // the real redirect delivers it. - w.Header()["Location"] = []string{"/cdn/object?filename=DSH Desktop-2.0.1-universal.dmg&auth_key=abc"} + w.Header()["Location"] = []string{"/cdn/object?filename=Some App-2.0.1-universal.dmg&auth_key=abc"} w.WriteHeader(http.StatusFound) case "/cdn/object": servedTarget = r.RequestURI @@ -66,64 +63,3 @@ func TestEncodeQuerySpacesLeavesSignedQueriesAlone(t *testing.T) { t.Errorf("encodeQuerySpaces = %q, want %q", got, want) } } - -// macOS ships one universal .dmg. Requiring "arm64" in the name matched no -// release that has ever been published, so the official GitHub route was dead on -// macOS and the mirror was the only way in. -func TestDSHAssetMatchesPublishedReleaseNames(t *testing.T) { - for _, test := range []struct { - name string - ext string - macOS bool - matches bool - }{ - {"DSH.Desktop-2.0.1-universal.dmg", ".dmg", true, true}, - {"DSH-Desktop-2.0.1-arm64.dmg", ".dmg", true, true}, - {"DSH-Desktop-2.0.1-x64-Setup.exe", ".exe", false, true}, - {"DSH-Desktop-2.0.1-x64-Setup.exe", ".dmg", true, false}, - {"DSH.Desktop-2.0.1-universal.dmg", ".exe", false, false}, - // An Intel-only build is refused; dshURL's arch guard is what keeps an - // Intel Mac from getting this far, but the name must not claim it either. - {"DSH-Desktop-2.0.1-x64.dmg", ".dmg", true, false}, - } { - if got := dshAssetMatches(test.name, test.ext, test.macOS); got != test.matches { - t.Errorf("dshAssetMatches(%q, %q, macOS=%v) = %v, want %v", test.name, test.ext, test.macOS, got, test.matches) - } - } -} - -// The official lookup has to resolve on macOS, since it is both the non-mirror -// route and the fallback a mirror failure depends on. -func TestDSHURLResolvesTheUniversalAssetFromGitHub(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(`{"assets":[ - {"name":"DSH-Desktop-2.0.1-x64-Setup.exe","browser_download_url":"https://github.com/anywhere-labs/deepseek-harness-desktop/releases/download/v2.0.1/DSH-Desktop-2.0.1-x64-Setup.exe"}, - {"name":"DSH.Desktop-2.0.1-universal.dmg","browser_download_url":"https://github.com/anywhere-labs/deepseek-harness-desktop/releases/download/v2.0.1/DSH.Desktop-2.0.1-universal.dmg"} - ]}`)) - })) - defer server.Close() - - options := Options{Platform: platform.For("macos", "arm64"), Downloader: releaseAPIClient{base: server.URL}} - got, err := dshURL(context.Background(), options) - if err != nil { - t.Fatalf("official macOS dsh URL: %v", err) - } - if !strings.HasSuffix(got, "DSH.Desktop-2.0.1-universal.dmg") { - t.Errorf("official macOS dsh URL = %q, want the universal .dmg", got) - } -} - -// releaseAPIClient answers the release API from a test server while leaving the -// host allowlist in approvedDownloadURL to judge the real asset URLs. -type releaseAPIClient struct{ base string } - -func (c releaseAPIClient) Do(request *http.Request) (*http.Response, error) { - if request.URL.String() == DSHDesktopReleaseAPI { - redirected, err := http.NewRequestWithContext(request.Context(), request.Method, c.base, nil) - if err != nil { - return nil, err - } - return http.DefaultClient.Do(redirected) - } - return http.DefaultClient.Do(request) -} diff --git a/internal/desktopapp/dsh.go b/internal/desktopapp/dsh.go index d7f06021..3559fb19 100644 --- a/internal/desktopapp/dsh.go +++ b/internal/desktopapp/dsh.go @@ -2,7 +2,9 @@ package desktopapp import ( "context" - "encoding/json" + "crypto/sha512" + "crypto/subtle" + "encoding/base64" "errors" "fmt" "io" @@ -10,242 +12,473 @@ import ( "os" "path/filepath" "strings" + + "gopkg.in/yaml.v3" ) +// DeepSeek Harness is DeepSeek's own desktop shell around dsh. It replaced the +// third-party "DSH Desktop" build (anywhere-labs) this entry used to install: the +// ID is kept so existing Agent bindings and the Profile the CLI shares keep +// resolving, while everything the ID points at -- name, publisher, download +// origin, bundle, install paths -- is now the vendor's. const ( - DSHDesktopID = "dsh-desktop" - DSHDesktopName = "DSH Desktop" - DSHDesktopHome = "https://dshdesktop.cn" - DSHDesktopReleaseAPI = "https://api.github.com/repos/anywhere-labs/deepseek-harness-desktop/releases/latest" - DSHDesktopMacMirrorURL = "https://www.dshdesktop.cn/api/downloads/mac" - DSHDesktopWinMirrorURL = "https://www.dshdesktop.cn/api/downloads/windows" + DSHDesktopID = "dsh-desktop" + DSHDesktopName = "DeepSeek Harness" + DSHDesktopHome = "https://www.deepseek.com/harness/" + + // DSHDesktopBundleID and DSHDesktopTeamID are read off the signed release: + // codesign reports Identifier=com.deepseek.dsh and TeamIdentifier=NAN929V4UM + // under "Developer ID Application: Hangzhou DeepSeek Artificial Intelligence + // Co., Ltd (NAN929V4UM)". The Team ID is what the signature check pins; the + // authority's common name is left free so a certificate renewal that keeps + // the team does not break installs. + DSHDesktopBundleID = "com.deepseek.dsh" + DSHDesktopTeamID = "NAN929V4UM" + + // DSHDesktopWindowsPublisher is the legal entity behind the macOS Developer ID + // team, which is also what the vendor's Windows signing derives publisherName + // from (the certificate's O attribute). Read off the same release line; the + // Windows certificate itself was not inspected on this machine, so a mismatch + // here surfaces as a refused install rather than an accepted stranger. + DSHDesktopWindowsPublisher = "Hangzhou DeepSeek Artificial Intelligence Co., Ltd" + + // DSHDesktopDownloadHost is the vendor's release origin. The desktop app's own + // app-update.yml points electron-updater at dsh-desk/feeds// under it, + // and the installers live under dsh-desk/bin//. + DSHDesktopDownloadHost = "download.deepseek.com" + DSHDesktopFeedBase = "https://" + DSHDesktopDownloadHost + "/dsh-desk/feeds/" + + dshDesktopAppName = "DeepSeek Harness.app" + dshDesktopExeName = "DeepSeek Harness.exe" ) -func dshURL(ctx context.Context, options Options) (string, error) { - if strings.TrimSpace(options.DownloadURL) != "" { - return approvedDownloadURL(options.DownloadURL, "github.com", "www.dshdesktop.cn") - } - if options.Platform.OS == "macos" && options.Platform.Arch != "arm64" && options.Platform.Arch != "aarch64" { - return "", fmt.Errorf("%s has no package for %s/%s", DSHDesktopName, options.Platform.OS, options.Platform.Arch) - } - if options.Platform.OS != "macos" && options.Platform.OS != "windows" { - return "", fmt.Errorf("%s is not supported on %s", DSHDesktopName, options.Platform.OS) - } - if options.PreferMirror { - url := DSHDesktopMacMirrorURL - if options.Platform.OS == "windows" { - url = DSHDesktopWinMirrorURL +// dshFeed is the electron-updater manifest the vendor publishes per target. +// Only what BootAgent acts on is decoded. +type dshFeed struct { + Version string `yaml:"version"` + Files []dshFeedFile `yaml:"files"` +} + +type dshFeedFile struct { + URL string `yaml:"url"` + SHA512 string `yaml:"sha512"` + Size int64 `yaml:"size"` +} + +// dshFeedURL names the manifest for one target. The vendor publishes mac-arm64 +// and win-x64 only; the channel is "nightly" in the shipped app-update.yml even +// for release-candidate builds, so that is the file name electron-updater asks +// for and the one that exists. +func dshFeedURL(osID, arch string) (string, error) { + switch osID { + case "macos": + if arch != "arm64" && arch != "aarch64" { + return "", fmt.Errorf("%s has no package for %s/%s", DSHDesktopName, osID, arch) + } + return DSHDesktopFeedBase + "mac-arm64/nightly-mac.yml", nil + case "windows": + switch strings.ToLower(strings.TrimSpace(arch)) { + case "x64", "amd64", "x86_64": + return DSHDesktopFeedBase + "win-x64/nightly.yml", nil } - return approvedDownloadURL(url, "www.dshdesktop.cn") + return "", fmt.Errorf("%s has no package for %s/%s", DSHDesktopName, osID, arch) } - request, err := http.NewRequestWithContext(ctx, http.MethodGet, DSHDesktopReleaseAPI, nil) + return "", fmt.Errorf("%s is not supported on %s", DSHDesktopName, osID) +} + +// fetchDSHFeed reads the manifest with no-cache: it is the rolling pointer to +// the current build, and the CDN in front of it would otherwise be free to pin +// BootAgent to a stale one. +func fetchDSHFeed(ctx context.Context, options Options) (dshFeed, error) { + endpoint, err := dshFeedURL(options.Platform.OS, options.Platform.Arch) if err != nil { - return "", err + return dshFeed{}, err } + requestCtx, cancel := context.WithTimeout(ctx, installTimeout) + defer cancel() + request, err := http.NewRequestWithContext(requestCtx, http.MethodGet, endpoint, nil) + if err != nil { + return dshFeed{}, err + } + request.Header.Set("Cache-Control", "no-cache") client := options.Downloader if client == nil { client = http.DefaultClient } response, err := client.Do(request) if err != nil { - return "", err + return dshFeed{}, err } defer response.Body.Close() if response.StatusCode != http.StatusOK { - return "", fmt.Errorf("GitHub release request returned HTTP %d", response.StatusCode) + return dshFeed{}, fmt.Errorf("%s update request returned HTTP %d", DSHDesktopName, response.StatusCode) } - var release struct { - Assets []struct { - Name string `json:"name"` - URL string `json:"browser_download_url"` - } `json:"assets"` + var feed dshFeed + if err := yaml.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&feed); err != nil { + return dshFeed{}, fmt.Errorf("decode %s update response: %w", DSHDesktopName, err) } - if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&release); err != nil { - return "", err + return feed, nil +} + +// dshArtifact picks the file to download and returns it with its digest. +// +// On macOS this is the .zip, which is the file the feed's digest describes and +// the one electron-updater itself consumes. A .dmg is published beside it but +// carries no digest in the manifest, so preferring it would trade a verified +// download for an unverified one. +func dshArtifact(feed dshFeed, osID string) (dshFeedFile, error) { + wanted := ".exe" + if osID == "macos" { + wanted = ".zip" + } + for _, file := range feed.Files { + if !strings.EqualFold(filepath.Ext(mustParseURLPath(file.URL)), wanted) { + continue + } + approved, err := approvedDownloadURL(file.URL, DSHDesktopDownloadHost) + if err != nil { + return dshFeedFile{}, fmt.Errorf("validate %s package URL: %w", DSHDesktopName, err) + } + if strings.TrimSpace(file.SHA512) == "" { + return dshFeedFile{}, fmt.Errorf("%s update feed lists no digest for %s", DSHDesktopName, wanted) + } + file.URL = approved + return file, nil } - ext := ".dmg" - if options.Platform.OS == "windows" { - ext = ".exe" + return dshFeedFile{}, fmt.Errorf("%s update feed lists no %s package", DSHDesktopName, wanted) +} + +// dshPackage resolves what to download. DownloadURL is the test and self-hosting +// seam the other agents use; it still has to pass the host allowlist, and it +// carries no digest, so it verifies by signature alone. +func dshPackage(ctx context.Context, options Options) (dshFeed, dshFeedFile, error) { + if raw := strings.TrimSpace(options.DownloadURL); raw != "" { + approved, err := approvedDownloadURL(raw, DSHDesktopDownloadHost) + return dshFeed{}, dshFeedFile{URL: approved}, err + } + feed, err := fetchDSHFeed(ctx, options) + if err != nil { + return dshFeed{}, dshFeedFile{}, err } - for _, asset := range release.Assets { - if dshAssetMatches(asset.Name, ext, options.Platform.OS == "macos") { - return approvedDownloadURL(asset.URL, "github.com") - } + artifact, err := dshArtifact(feed, options.Platform.OS) + if err != nil { + return dshFeed{}, dshFeedFile{}, err } - return "", fmt.Errorf("GitHub release has no %s %s asset", DSHDesktopName, ext) + return feed, artifact, nil } -// dshAssetMatches picks the release asset for this platform. -// -// The extension is what identifies the platform's package; on macOS the build is -// a single universal .dmg. An earlier form of this also required "arm64" in the -// name on macOS, which no release has ever carried -- the asset is published as -// "DSH.Desktop--universal.dmg" -- so the GitHub path always ended in -// "GitHub release has no DSH Desktop .dmg asset". That left the mirror as the -// only route that could work on macOS, and hid it: the fallback in downloadDSH -// returns the mirror's error when the official lookup fails, so a mirror failure -// reported the mirror's status and never mentioned that the fallback had found -// nothing either. Only the arch guard in dshURL keeps an Intel Mac out, which is -// where that check belongs. -func dshAssetMatches(name, ext string, macOS bool) bool { - lower := strings.ToLower(name) - if !strings.HasSuffix(lower, ext) { - return false - } - if !macOS { - return true - } - return strings.Contains(lower, "universal") || strings.Contains(lower, "arm64") +// verifyDSHDigest checks the downloaded bytes against the feed. The digest is +// what authenticates the archive: the host allowlist only says who was asked. +// Size first, so a truncated transfer reports the useful error. +func verifyDSHDigest(path string, expected dshFeedFile) error { + want, err := base64.StdEncoding.DecodeString(strings.TrimSpace(expected.SHA512)) + if err != nil { + return fmt.Errorf("decode expected %s digest: %w", DSHDesktopName, err) + } + if len(want) != sha512.Size { + return fmt.Errorf("expected %s digest is not a SHA-512 value", DSHDesktopName) + } + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return err + } + if expected.Size > 0 && info.Size() != expected.Size { + return fmt.Errorf("downloaded %s package is %d bytes, expected %d", DSHDesktopName, info.Size(), expected.Size) + } + digest := sha512.New() + if _, err := io.Copy(digest, file); err != nil { + return err + } + if subtle.ConstantTimeCompare(digest.Sum(nil), want) != 1 { + return fmt.Errorf("downloaded %s package failed its SHA-512 check", DSHDesktopName) + } + return nil +} + +func baseDSHStatus(osID string) Status { + status := Status{ID: DSHDesktopID, Name: DSHDesktopName, Source: SourceUnknown} + switch osID { + case "macos": + status.Supported, status.Source = true, SourceMacOSZIP + case "windows": + status.Supported, status.Source = true, SourceWindowsInstaller + } + return status } func inspectDSH(ctx context.Context, options Options) Status { - status := Status{ID: DSHDesktopID, Name: DSHDesktopName, Supported: options.Platform.OS == "macos" || options.Platform.OS == "windows", Source: SourceUnknown} - if options.Platform.OS == "macos" { - status.Source = SourceMacOSDMG - roots := options.SearchRoots - if len(roots) == 0 { - roots = []string{"/Applications"} + status := baseDSHStatus(options.Platform.OS) + if err := contextError(ctx); err != nil { + status.InspectionUnavailable = nonEmptyPointer(err.Error()) + return status + } + switch options.Platform.OS { + case "macos": + found, err := inspectDSHMacOS(ctx, options) + if err != nil { + found.InspectionUnavailable = nonEmptyPointer(err.Error()) } - for _, root := range roots { - path := root - if !strings.HasSuffix(strings.ToLower(path), ".app") { - path = filepath.Join(root, "DSH Desktop.app") - } - if info, err := os.Stat(path); err == nil && info.IsDir() { - status.Installed, status.Path = true, path + return found + case "windows": + for _, candidate := range dshWindowsCandidates(options) { + if info, err := os.Stat(candidate); err == nil && !info.IsDir() { + status.Installed, status.Path = true, candidate return status } } - } else if options.Platform.OS == "windows" { - status.Source = SourceWindowsInstaller - for _, root := range dshWindowsCandidates(options) { - if info, err := os.Stat(root); err == nil && !info.IsDir() { - status.Installed, status.Path = true, root - return status - } + } + return status +} + +// inspectDSHMacOS reads the bundle identifier out of each candidate rather than +// trusting the directory name, and reports the installed version from the same +// plist: the app updates itself through electron-updater, so the version on +// disk is not the one BootAgent installed. +func inspectDSHMacOS(ctx context.Context, options Options) (Status, error) { + status := baseDSHStatus("macos") + roots := options.SearchRoots + if len(roots) == 0 { + roots = []string{"/Applications"} + if options.Home != "" { + roots = append(roots, filepath.Join(options.Home, "Applications")) } } - if err := contextError(ctx); err != nil { - status.InspectionUnavailable = nonEmptyPointer(err.Error()) + var lastErr error + for _, root := range roots { + candidate := root + if !strings.EqualFold(filepath.Ext(root), ".app") { + candidate = filepath.Join(root, dshDesktopAppName) + } + info, err := os.Stat(candidate) + if os.IsNotExist(err) { + continue + } + if err != nil { + lastErr = err + continue + } + if !info.IsDir() { + continue + } + metadata, err := readMacMetadata(ctx, options, candidate) + if err != nil { + lastErr = err + continue + } + if metadata.bundleID != DSHDesktopBundleID { + continue + } + status.Installed, status.Path, status.Version = true, candidate, metadata.version + return status, nil } - return status + return status, lastErr } +// dshWindowsCandidates lists where the vendor's NSIS installer places the app. +// It is configured perMachine: false with a fixed directory, which for +// electron-builder is %LOCALAPPDATA%\Programs\. Not verified on +// Windows: this machine is macOS, so the path follows electron-builder's +// documented default rather than an observed installation. func dshWindowsCandidates(options Options) []string { if len(options.SearchRoots) > 0 { - return options.SearchRoots + candidates := make([]string, 0, len(options.SearchRoots)) + for _, root := range options.SearchRoots { + if strings.EqualFold(filepath.Ext(root), ".exe") { + candidates = append(candidates, root) + continue + } + candidates = append(candidates, filepath.Join(root, dshDesktopExeName)) + } + return candidates } if options.Home == "" { return nil } - return []string{filepath.Join(options.Home, "AppData", "Local", "Programs", "DSH Desktop", "DSH Desktop.exe")} + return []string{filepath.Join(options.Home, "AppData", "Local", "Programs", "DeepSeek Harness", dshDesktopExeName)} } func installDSH(ctx context.Context, options Options) (ActionResult, error) { + if err := contextError(ctx); err != nil { + return ActionResult{}, err + } status := inspectDSH(ctx, options) if status.Installed { return ActionResult{Status: "already-installed", Message: DSHDesktopName + " is already installed", App: status}, nil } - url, err := dshURL(ctx, options) + switch options.Platform.OS { + case "macos": + return installDSHMacOS(ctx, options) + case "windows": + return installDSHWindows(ctx, options) + } + return ActionResult{}, fmt.Errorf("%s is not supported on %s", DSHDesktopName, options.Platform.OS) +} + +func installDSHMacOS(ctx context.Context, options Options) (ActionResult, error) { + feed, artifact, err := dshPackage(ctx, options) if err != nil { return ActionResult{}, err } - if options.Platform.OS == "windows" { - path, err := os.CreateTemp("", "bootagent-dsh-*.exe") - if err != nil { + tempDir, err := os.MkdirTemp("", "bootagent-dsh-") + if err != nil { + return ActionResult{}, fmt.Errorf("create temporary %s installer directory: %w", DSHDesktopName, err) + } + defer os.RemoveAll(tempDir) + archive := filepath.Join(tempDir, "DeepSeek Harness.zip") + if err := downloadFile(ctx, options, artifact.URL, archive, DSHDesktopID); err != nil { + return ActionResult{}, fmt.Errorf("download %s installer: %w", DSHDesktopName, err) + } + // Only when the feed supplied one. A DownloadURL override has no manifest to + // compare against, and the signature check below is what gates it. + if artifact.SHA512 != "" { + if err := verifyDSHDigest(archive, artifact); err != nil { return ActionResult{}, err } - name := path.Name() - _ = path.Close() - defer os.Remove(name) - if err := downloadDSH(ctx, options, url, name); err != nil { - return ActionResult{}, err + } + extracted := filepath.Join(tempDir, "extracted") + if err := os.MkdirAll(extracted, 0o700); err != nil { + return ActionResult{}, err + } + result, err := run(options, ctx, []string{"/usr/bin/ditto", "-x", "-k", archive, extracted}, installTimeout) + if err != nil { + return ActionResult{}, fmt.Errorf("extract %s installer: %w", DSHDesktopName, err) + } + if result.ExitCode != 0 { + return ActionResult{}, commandFailure("extract "+DSHDesktopName+" installer", result) + } + appPath, err := findDSHApp(extracted) + if err != nil { + return ActionResult{}, err + } + metadata, err := readMacMetadata(ctx, options, appPath) + if err != nil { + return ActionResult{}, fmt.Errorf("inspect downloaded %s app: %w", DSHDesktopName, err) + } + if metadata.bundleID != DSHDesktopBundleID { + return ActionResult{}, fmt.Errorf("downloaded app has unexpected bundle identifier %q", metadata.bundleID) + } + if err := verifyDSHMacOSApp(ctx, options, appPath); err != nil { + return ActionResult{}, fmt.Errorf("verify downloaded %s app: %w", DSHDesktopName, err) + } + var lastErr error + for _, destination := range dshDestinations(options) { + if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { + lastErr = err + continue } - if err := verifyDSHWindowsInstaller(ctx, options, name); err != nil { - return ActionResult{}, err + if _, err := os.Stat(destination); err == nil { + lastErr = fmt.Errorf("destination already exists: %s", destination) + continue + } else if !os.IsNotExist(err) { + lastErr = err + continue } - if err := start(options, []string{name}); err != nil { - return ActionResult{}, err + copied, copyErr := run(options, ctx, []string{"/usr/bin/ditto", appPath, destination}, installTimeout) + if copyErr != nil { + lastErr = copyErr + continue + } + if copied.ExitCode != 0 { + lastErr = commandFailure("copy "+DSHDesktopName+" app", copied) + continue } - status.Source = SourceWindowsInstaller - return ActionResult{Status: "installer-started", Message: "The downloaded " + DSHDesktopName + " installer was started", RefreshNeeded: true, App: status}, nil + installed := baseDSHStatus("macos") + installed.Installed, installed.Path, installed.Version = true, destination, metadata.version + if installed.Version == nil { + installed.Version = nonEmptyPointer(feed.Version) + } + return ActionResult{Status: "installed", Message: DSHDesktopName + " was installed", RefreshNeeded: true, App: installed}, nil } - tmp, err := os.CreateTemp("", "bootagent-dsh-*.dmg") - if err != nil { - return ActionResult{}, err + if lastErr == nil { + lastErr = errors.New("no writable macOS Applications directory") } - name := tmp.Name() - _ = tmp.Close() - defer os.Remove(name) - if err := downloadDSH(ctx, options, url, name); err != nil { + return ActionResult{}, lastErr +} + +// installDSHWindows starts the vendor's own installer after Authenticode passes. +// The .exe is an NSIS installer that owns its placement and shortcuts. +func installDSHWindows(ctx context.Context, options Options) (ActionResult, error) { + feed, artifact, err := dshPackage(ctx, options) + if err != nil { return ActionResult{}, err } - mount, err := os.MkdirTemp(filepath.Dir(name), "dsh-mount-") + installer, err := os.CreateTemp("", "bootagent-dsh-*.exe") if err != nil { + return ActionResult{}, fmt.Errorf("create temporary %s installer: %w", DSHDesktopName, err) + } + installerPath := installer.Name() + if err := installer.Close(); err != nil { + _ = os.Remove(installerPath) return ActionResult{}, err } - mounted := false + keep := 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) + if !keep { + _ = os.Remove(installerPath) } - _ = 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) - } - if result.ExitCode != 0 { - return ActionResult{}, commandFailure("mount "+DSHDesktopName+" installer", result) + if err := downloadFile(ctx, options, artifact.URL, installerPath, DSHDesktopID); err != nil { + return ActionResult{}, fmt.Errorf("download %s installer: %w", DSHDesktopName, err) } - 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") + if artifact.SHA512 != "" { + if err := verifyDSHDigest(installerPath, artifact); err != nil { + return ActionResult{}, err + } } - if err := runDSHMacSignatureCheck(ctx, options, app); err != nil { + if err := contextError(ctx); err != nil { return ActionResult{}, err } - dest := "/Applications/DSH Desktop.app" - if len(options.ApplicationDirs) > 0 { - dest = filepath.Join(options.ApplicationDirs[0], "DSH Desktop.app") - } - if result, err = run(options, ctx, []string{"/usr/bin/ditto", app, dest}, installTimeout); err != nil { - return ActionResult{}, fmt.Errorf("install %s: %w", DSHDesktopName, err) + if err := verifyDSHWindowsInstaller(ctx, options, installerPath); err != nil { + return ActionResult{}, fmt.Errorf("verify downloaded %s installer with Authenticode: %w", DSHDesktopName, err) } - if result.ExitCode != 0 { - return ActionResult{}, commandFailure("install "+DSHDesktopName, result) + if err := start(options, []string{installerPath}); err != nil { + return ActionResult{}, fmt.Errorf("start %s installer: %w", DSHDesktopName, err) } - status.Installed, status.Path, status.Source = true, dest, SourceMacOSDMG - return ActionResult{Status: "installed", Message: DSHDesktopName + " was installed", RefreshNeeded: true, App: status}, nil + keep = true + status := baseDSHStatus("windows") + status.Version = nonEmptyPointer(feed.Version) + return ActionResult{Status: "installer-started", Message: "The downloaded " + DSHDesktopName + " installer was started", RefreshNeeded: true, App: status}, nil } -func downloadDSH(ctx context.Context, options Options, url, destination string) error { - err := downloadFile(ctx, options, url, destination, DSHDesktopID) - if err == nil || !options.PreferMirror || !strings.Contains(err.Error(), "HTTP 400") { - return err +func dshDestinations(options Options) []string { + dirs := options.ApplicationDirs + if len(dirs) == 0 { + dirs = []string{"/Applications"} + if options.Home != "" { + dirs = append(dirs, filepath.Join(options.Home, "Applications")) + } } - official := options - official.PreferMirror = false - githubURL, resolveErr := dshURL(ctx, official) - if resolveErr != nil { - return err + result := make([]string, 0, len(dirs)) + for _, dir := range dirs { + result = append(result, filepath.Join(dir, dshDesktopAppName)) } - return downloadFile(ctx, official, githubURL, destination, DSHDesktopID) + return result } -func runDSHMacSignatureCheck(ctx context.Context, options Options, app string) error { - result, err := run(options, ctx, []string{"/usr/bin/codesign", "--verify", "--deep", "--strict", app}, installTimeout) +func findDSHApp(root string) (string, error) { + var found string + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() && strings.EqualFold(filepath.Base(path), dshDesktopAppName) { + found = path + return filepath.SkipAll + } + return nil + }) if err != nil { - return err + return "", err } - if result.ExitCode != 0 { - return commandFailure("verify DSH Desktop macOS code signature", result) + if found == "" { + return "", fmt.Errorf("downloaded %s archive contains no %s", DSHDesktopName, dshDesktopAppName) } - return nil + return found, nil } func openDSH(ctx context.Context, options Options) error { @@ -253,8 +486,11 @@ func openDSH(ctx context.Context, options Options) error { if !status.Installed { return errors.New(DSHDesktopName + " is not installed") } - if options.Platform.OS == "macos" { + switch options.Platform.OS { + case "macos": return start(options, []string{"/usr/bin/open", "-a", status.Path}) + case "windows": + return start(options, []string{status.Path}) } - return start(options, []string{status.Path}) + return fmt.Errorf("%s is not supported on %s", DSHDesktopName, options.Platform.OS) } diff --git a/internal/desktopapp/dsh_extract_darwin_test.go b/internal/desktopapp/dsh_extract_darwin_test.go new file mode 100644 index 00000000..70e2af70 --- /dev/null +++ b/internal/desktopapp/dsh_extract_darwin_test.go @@ -0,0 +1,82 @@ +//go:build darwin + +package desktopapp + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/MaimoryLab/BootAgent/internal/platform" + "github.com/MaimoryLab/BootAgent/internal/process" +) + +// realToolsRunner runs ditto and plutil for real -- the extraction and the plist +// read are the behavior under test -- and stubs the signing checks, which need a +// genuinely signed bundle to pass and are covered by their own scripted tests. +type realToolsRunner struct { + calls [][]string +} + +func (r *realToolsRunner) LookPath(string) (string, bool) { return "", false } + +func (r *realToolsRunner) Start([]string, map[string]string) error { return nil } + +func (r *realToolsRunner) Run(ctx context.Context, argv []string, _ map[string]string, _ time.Duration) (process.Result, error) { + r.calls = append(r.calls, append([]string(nil), argv...)) + switch argv[0] { + case "/usr/bin/ditto", "/usr/bin/plutil": + result, err := process.OSRunner{}.Run(ctx, argv, nil, time.Minute) + return result, err + case "/usr/bin/codesign": + if len(argv) > 1 && argv[1] == "-dv" { + return process.Result{Args: argv, ExitCode: 0, Stderr: dshCodesignIdentity}, nil + } + return process.Result{Args: argv, ExitCode: 0}, nil + case "/usr/sbin/spctl": + return process.Result{Args: argv, ExitCode: 0, Stderr: "source=Notarized Developer ID\n"}, nil + } + return process.Result{Args: argv, ExitCode: 0}, nil +} + +// The archive the vendor publishes has the .app at its root and a space in its +// name. The install has to extract it, find it, read its real Info.plist, and +// copy it under the same name. +func TestInstallDSHExtractsThePublishedArchiveLayout(t *testing.T) { + archive, err := os.ReadFile("testdata/dsh-fixture.zip") + if err != nil { + t.Skipf("zip fixture unavailable: %v", err) + } + feed := dshFeedYAML("0.1.7-rc.2", dshMacZipURL, dshDigest(archive), int64(len(archive))) + downloader := &routeDownloader{routes: map[string][]byte{dshMacFeedURL: feed, dshMacZipURL: archive}} + applications := t.TempDir() + runner := &realToolsRunner{} + result, err := installDSH(context.Background(), Options{ + Home: t.TempDir(), Platform: platform.For("macos", "arm64"), Runner: runner, Downloader: downloader, + SearchRoots: []string{t.TempDir()}, ApplicationDirs: []string{applications}, + }) + if err != nil { + t.Fatalf("installDSH: %v\ncalls: %#v", err, runner.calls) + } + want := filepath.Join(applications, dshDesktopAppName) + if result.Status != "installed" || result.App.Path != want { + t.Fatalf("installDSH = %#v", result) + } + if result.App.Version == nil || *result.App.Version != "0.1.7-rc.2" { + t.Fatalf("version read from the extracted plist = %v", result.App.Version) + } + // ditto copied the bundle for real, so it is there under the vendor's name. + if _, err := os.Stat(filepath.Join(want, "Contents", "Info.plist")); err != nil { + t.Fatalf("installed bundle is missing its plist: %v", err) + } + // And the temporary extraction directory did not survive. + for _, call := range runner.calls { + if call[0] == "/usr/bin/ditto" && call[1] == "-x" { + if _, err := os.Stat(call[4]); !os.IsNotExist(err) { + t.Errorf("extraction directory survived: stat %s = %v", call[4], err) + } + } + } +} diff --git a/internal/desktopapp/dsh_mount_darwin_test.go b/internal/desktopapp/dsh_mount_darwin_test.go deleted file mode 100644 index 8be4c063..00000000 --- a/internal/desktopapp/dsh_mount_darwin_test.go +++ /dev/null @@ -1,131 +0,0 @@ -//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/dsh_test.go b/internal/desktopapp/dsh_test.go new file mode 100644 index 00000000..f2ab0ca9 --- /dev/null +++ b/internal/desktopapp/dsh_test.go @@ -0,0 +1,323 @@ +package desktopapp + +import ( + "context" + "crypto/sha512" + "encoding/base64" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MaimoryLab/BootAgent/internal/platform" + "github.com/MaimoryLab/BootAgent/internal/process" +) + +const ( + dshMacFeedURL = DSHDesktopFeedBase + "mac-arm64/nightly-mac.yml" + dshWinFeedURL = DSHDesktopFeedBase + "win-x64/nightly.yml" + dshMacZipURL = "https://" + DSHDesktopDownloadHost + "/dsh-desk/bin/mac-arm64/deepseek-harness-0.1.7-rc.2-mac-arm64.zip" + dshWinExeURL = "https://" + DSHDesktopDownloadHost + "/dsh-desk/bin/win-x64/deepseek-harness-0.1.7-rc.2-win-x64.exe" + + // What codesign -dv reports for the shipped release, captured on this machine. + dshCodesignIdentity = "Identifier=com.deepseek.dsh\nTeamIdentifier=NAN929V4UM\nAuthority=Developer ID Application: Hangzhou DeepSeek Artificial Intelligence Co., Ltd (NAN929V4UM)\nAuthority=Developer ID Certification Authority\nAuthority=Apple Root CA\n" +) + +// dshFeedYAML mirrors the vendor's electron-updater manifest: one file entry +// with its digest, then the same file restated at the top level. +func dshFeedYAML(version, fileURL, digest string, size int64) []byte { + return fmt.Appendf(nil, `version: %s +files: + - url: >- + %s + sha512: >- + %s + size: %d +path: >- + %s +sha512: >- + %s +releaseDate: '2026-09-24T14:10:00.562Z' +`, version, fileURL, digest, size, fileURL, digest) +} + +func dshDigest(payload []byte) string { + sum := sha512.Sum512(payload) + return base64.StdEncoding.EncodeToString(sum[:]) +} + +// dshRunner replays scripted results and materializes the extracted .app on the +// ditto -x call, since the install walks the extraction directory for it. +type dshRunner struct { + results []process.Result + calls [][]string + started [][]string + t *testing.T +} + +func (r *dshRunner) LookPath(string) (string, bool) { return "", false } + +func (r *dshRunner) Run(_ context.Context, argv []string, _ map[string]string, _ time.Duration) (process.Result, error) { + r.calls = append(r.calls, append([]string(nil), argv...)) + if len(argv) >= 5 && argv[0] == "/usr/bin/ditto" && argv[1] == "-x" { + if err := os.MkdirAll(filepath.Join(argv[4], dshDesktopAppName, "Contents"), 0o755); err != nil { + r.t.Fatal(err) + } + } + if len(r.results) == 0 { + return process.Result{Args: argv, ExitCode: 0}, nil + } + result := r.results[0] + r.results = r.results[1:] + result.Args = argv + return result, nil +} + +func (r *dshRunner) Start(argv []string, _ map[string]string) error { + r.started = append(r.started, append([]string(nil), argv...)) + return nil +} + +// The vendor publishes two targets. Intel macOS and Windows on ARM have no +// package, and the feed for them does not exist, so the lookup has to refuse +// rather than request a 404. +func TestDSHFeedURLCoversOnlyThePublishedTargets(t *testing.T) { + for _, test := range []struct{ osID, arch, want string }{ + {"macos", "arm64", dshMacFeedURL}, + {"macos", "aarch64", dshMacFeedURL}, + {"windows", "x64", dshWinFeedURL}, + {"windows", "amd64", dshWinFeedURL}, + } { + got, err := dshFeedURL(test.osID, test.arch) + if err != nil || got != test.want { + t.Errorf("dshFeedURL(%q, %q) = %q, %v; want %q", test.osID, test.arch, got, err, test.want) + } + } + for _, test := range []struct{ osID, arch string }{ + {"macos", "amd64"}, {"macos", "x86_64"}, {"windows", "arm64"}, {"linux", "amd64"}, + } { + if got, err := dshFeedURL(test.osID, test.arch); err == nil { + t.Errorf("dshFeedURL(%q, %q) = %q, want an error", test.osID, test.arch, got) + } + } +} + +func TestDSHArtifactRequiresTheDigestedFileFromTheVendorHost(t *testing.T) { + feed := dshFeed{Files: []dshFeedFile{{URL: dshMacZipURL, SHA512: "emlw", Size: 1}}} + artifact, err := dshArtifact(feed, "macos") + if err != nil || artifact.URL != dshMacZipURL { + t.Fatalf("dshArtifact() = %#v, %v", artifact, err) + } + // The feed's macOS entry is the zip; a manifest that only listed the exe + // (or nothing) has no macOS package. + if _, err := dshArtifact(dshFeed{Files: []dshFeedFile{{URL: dshWinExeURL, SHA512: "ZXhl"}}}, "macos"); err == nil || !strings.Contains(err.Error(), "no .zip") { + t.Fatalf("dshArtifact() on a Windows-only feed = %v", err) + } + offHost := dshFeed{Files: []dshFeedFile{{URL: "https://github.com/anywhere-labs/x/releases/download/v2/DSH.Desktop.zip", SHA512: "emlw"}}} + if _, err := dshArtifact(offHost, "macos"); err == nil || !strings.Contains(err.Error(), "not approved") { + t.Fatalf("dshArtifact() accepted an off-host URL: %v", err) + } + noDigest := dshFeed{Files: []dshFeedFile{{URL: dshMacZipURL, Size: 1}}} + if _, err := dshArtifact(noDigest, "macos"); err == nil || !strings.Contains(err.Error(), "no digest") { + t.Fatalf("dshArtifact() accepted a file with no digest: %v", err) + } +} + +func TestVerifyDSHDigestRejectsTamperedAndTruncatedPackages(t *testing.T) { + payload := []byte("DeepSeek Harness package bytes") + path := filepath.Join(t.TempDir(), "DeepSeek Harness.zip") + if err := os.WriteFile(path, payload, 0o600); err != nil { + t.Fatal(err) + } + if err := verifyDSHDigest(path, dshFeedFile{SHA512: dshDigest(payload), Size: int64(len(payload))}); err != nil { + t.Fatalf("verifyDSHDigest() on matching bytes = %v", err) + } + if err := verifyDSHDigest(path, dshFeedFile{SHA512: dshDigest([]byte("other")), Size: int64(len(payload))}); err == nil || !strings.Contains(err.Error(), "SHA-512") { + t.Fatalf("verifyDSHDigest() on tampered bytes = %v", err) + } + if err := verifyDSHDigest(path, dshFeedFile{SHA512: dshDigest(payload), Size: int64(len(payload)) + 10}); err == nil || !strings.Contains(err.Error(), "bytes, expected") { + t.Fatalf("verifyDSHDigest() on truncated bytes = %v", err) + } +} + +// The full macOS path: feed, digest, bundle identifier, pinned Developer ID team, +// notarization, copy. Only the zip is fetched; the dmg the vendor publishes +// beside it carries no digest and is never requested. +func TestDSHMacOSInstallVerifiesDigestBundleIDAndSignature(t *testing.T) { + payload := []byte("DeepSeek Harness macOS archive") + feed := dshFeedYAML("0.1.7-rc.2", dshMacZipURL, dshDigest(payload), int64(len(payload))) + downloader := &routeDownloader{routes: map[string][]byte{dshMacFeedURL: feed, dshMacZipURL: payload}} + applications := t.TempDir() + runner := &dshRunner{t: t, results: []process.Result{ + {ExitCode: 0}, // ditto -x -k + {ExitCode: 0, Stdout: DSHDesktopBundleID + "\n"}, // plutil CFBundleIdentifier + {ExitCode: 0, Stdout: "0.1.7-rc.2\n"}, // plutil CFBundleShortVersionString + {ExitCode: 0}, // codesign --verify + {ExitCode: 0, Stdout: dshCodesignIdentity}, // codesign -dv + {ExitCode: 0, Stdout: "source=Notarized Developer ID\n"}, // spctl + {ExitCode: 0}, // ditto copy + }} + result, err := Install(context.Background(), DSHDesktopID, Options{ + Home: t.TempDir(), Platform: platform.For("macos", "arm64"), Runner: runner, Downloader: downloader, + SearchRoots: []string{t.TempDir()}, ApplicationDirs: []string{applications}, + }) + if err != nil { + t.Fatal(err) + } + if result.Status != "installed" || result.App.Path != filepath.Join(applications, dshDesktopAppName) || result.App.Version == nil || *result.App.Version != "0.1.7-rc.2" { + t.Fatalf("Install() = %#v", result) + } + if len(downloader.hits) != 2 || downloader.hits[0] != dshMacFeedURL || downloader.hits[1] != dshMacZipURL { + t.Fatalf("downloads = %#v", downloader.hits) + } + // The feed is the rolling pointer, so it must be asked for fresh. + var sawSpctl bool + for _, call := range runner.calls { + if call[0] == "/usr/sbin/spctl" { + sawSpctl = true + } + } + if !sawSpctl { + t.Fatalf("notarization was not assessed: %#v", runner.calls) + } +} + +// A validly signed bundle from another team is exactly what the old +// codesign-only check let through. +func TestDSHMacOSInstallRejectsAnotherTeamsSignature(t *testing.T) { + payload := []byte("DeepSeek Harness macOS archive") + feed := dshFeedYAML("0.1.7-rc.2", dshMacZipURL, dshDigest(payload), int64(len(payload))) + downloader := &routeDownloader{routes: map[string][]byte{dshMacFeedURL: feed, dshMacZipURL: payload}} + runner := &dshRunner{t: t, results: []process.Result{ + {ExitCode: 0}, + {ExitCode: 0, Stdout: DSHDesktopBundleID + "\n"}, + {ExitCode: 0, Stdout: "0.1.7-rc.2\n"}, + {ExitCode: 0}, + {ExitCode: 0, Stdout: "Identifier=com.deepseek.dsh\nTeamIdentifier=2DC432GLL2\nAuthority=Developer ID Application: Someone Else (2DC432GLL2)\n"}, + }} + _, err := Install(context.Background(), DSHDesktopID, Options{ + Home: t.TempDir(), Platform: platform.For("macos", "arm64"), Runner: runner, Downloader: downloader, + SearchRoots: []string{t.TempDir()}, ApplicationDirs: []string{t.TempDir()}, + }) + if err == nil || !strings.Contains(err.Error(), "TeamIdentifier="+DSHDesktopTeamID) { + t.Fatalf("Install() error = %v", err) + } + for _, call := range runner.calls { + if call[0] == "/usr/bin/ditto" && call[1] != "-x" { + t.Fatalf("app was copied despite a foreign signature: %#v", runner.calls) + } + } +} + +func TestDSHMacOSInstallStopsBeforeExtractingOnDigestMismatch(t *testing.T) { + feed := dshFeedYAML("0.1.7-rc.2", dshMacZipURL, dshDigest([]byte("expected")), 8) + downloader := &routeDownloader{routes: map[string][]byte{dshMacFeedURL: feed, dshMacZipURL: []byte("replaced")}} + runner := &dshRunner{t: t} + _, err := Install(context.Background(), DSHDesktopID, Options{ + Home: t.TempDir(), Platform: platform.For("macos", "arm64"), Runner: runner, Downloader: downloader, + SearchRoots: []string{t.TempDir()}, ApplicationDirs: []string{t.TempDir()}, + }) + if err == nil || !strings.Contains(err.Error(), "SHA-512") { + t.Fatalf("Install() error = %v", err) + } + if len(runner.calls) != 0 { + t.Fatalf("install ran commands after a digest mismatch: %#v", runner.calls) + } +} + +func TestDSHWindowsInstallVerifiesAuthenticodeBeforeStarting(t *testing.T) { + payload := []byte("DeepSeek Harness Windows installer") + feed := dshFeedYAML("0.1.7-rc.2", dshWinExeURL, dshDigest(payload), int64(len(payload))) + downloader := &routeDownloader{routes: map[string][]byte{dshWinFeedURL: feed, dshWinExeURL: payload}} + runner := &dshRunner{t: t, results: []process.Result{ + {ExitCode: 0, Stdout: `{"Status":"Valid","StatusMessage":"Signature verified.","Publisher":"Hangzhou DeepSeek Artificial Intelligence Co., Ltd","Organization":"Hangzhou DeepSeek Artificial Intelligence Co., Ltd","Subject":"CN=Hangzhou DeepSeek Artificial Intelligence Co., Ltd, O=Hangzhou DeepSeek Artificial Intelligence Co., Ltd, C=CN","Issuer":"CN=Some Code Signing CA"}`}, + }} + result, err := Install(context.Background(), DSHDesktopID, Options{ + Home: t.TempDir(), Platform: platform.For("windows", "x64"), Runner: runner, Downloader: downloader, + SearchRoots: []string{t.TempDir()}, + }) + if err != nil { + t.Fatal(err) + } + if result.Status != "installer-started" || len(runner.started) != 1 || result.App.Version == nil || *result.App.Version != "0.1.7-rc.2" { + t.Fatalf("Install() = %#v started=%#v", result, runner.started) + } + if err := os.Remove(runner.started[0][0]); err != nil { + t.Fatal(err) + } +} + +// Any valid signature used to pass here. The digest proves the bytes are what the +// feed described; the publisher check is what proves who published them. +func TestDSHWindowsInstallRejectsUnexpectedPublisher(t *testing.T) { + payload := []byte("DeepSeek Harness Windows installer") + feed := dshFeedYAML("0.1.7-rc.2", dshWinExeURL, dshDigest(payload), int64(len(payload))) + downloader := &routeDownloader{routes: map[string][]byte{dshWinFeedURL: feed, dshWinExeURL: payload}} + runner := &dshRunner{t: t, results: []process.Result{ + {ExitCode: 0, Stdout: `{"Status":"Valid","StatusMessage":"Signature verified.","Publisher":"Someone Else","Organization":"Someone Else","Subject":"CN=Someone Else, O=Someone Else","Issuer":"CN=Trusted CA"}`}, + }} + _, err := Install(context.Background(), DSHDesktopID, Options{ + Home: t.TempDir(), Platform: platform.For("windows", "x64"), Runner: runner, Downloader: downloader, + SearchRoots: []string{t.TempDir()}, + }) + if err == nil || !strings.Contains(err.Error(), "not approved") { + t.Fatalf("Install() error = %v", err) + } + if len(runner.started) != 0 { + t.Fatalf("installer started despite an unapproved publisher: %#v", runner.started) + } +} + +// Detection reads the bundle identifier, so a directory that merely carries the +// vendor's name is not reported as installed, and the version comes from the +// bundle on disk rather than anything BootAgent remembers -- the app updates +// itself. +func TestInspectDSHMacOSMatchesOnBundleIDAndReportsTheInstalledVersion(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, dshDesktopAppName, "Contents"), 0o755); err != nil { + t.Fatal(err) + } + stranger := &dshRunner{t: t, results: []process.Result{{ExitCode: 0, Stdout: "com.example.other\n"}}} + status := Inspect(context.Background(), DSHDesktopID, Options{Platform: platform.For("macos", "arm64"), Runner: stranger, SearchRoots: []string{root}}) + if status.Installed { + t.Fatalf("a foreign bundle named %s was reported installed: %#v", dshDesktopAppName, status) + } + genuine := &dshRunner{t: t, results: []process.Result{ + {ExitCode: 0, Stdout: DSHDesktopBundleID + "\n"}, + {ExitCode: 0, Stdout: "0.1.7-rc.2\n"}, + }} + status = Inspect(context.Background(), DSHDesktopID, Options{Platform: platform.For("macos", "arm64"), Runner: genuine, SearchRoots: []string{root}}) + if !status.Installed || status.Path != filepath.Join(root, dshDesktopAppName) || status.Version == nil || *status.Version != "0.1.7-rc.2" || status.Source != SourceMacOSZIP { + t.Fatalf("Inspect() = %#v", status) + } +} + +func TestInspectDSHWindowsLooksInThePerUserProgramsDirectory(t *testing.T) { + home := t.TempDir() + exe := filepath.Join(home, "AppData", "Local", "Programs", "DeepSeek Harness", dshDesktopExeName) + status := Inspect(context.Background(), DSHDesktopID, Options{Home: home, Platform: platform.For("windows", "x64")}) + if status.Installed || !status.Supported { + t.Fatalf("Inspect() before install = %#v", status) + } + if err := os.MkdirAll(filepath.Dir(exe), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(exe, []byte("MZ"), 0o600); err != nil { + t.Fatal(err) + } + status = Inspect(context.Background(), DSHDesktopID, Options{Home: home, Platform: platform.For("windows", "x64")}) + if !status.Installed || status.Path != exe { + t.Fatalf("Inspect() after install = %#v", status) + } +} + +func TestDSHIsNotOfferedOnIntelMacOrLinux(t *testing.T) { + for _, info := range []platform.Info{platform.For("macos", "amd64"), platform.For("linux", "amd64")} { + _, err := Install(context.Background(), DSHDesktopID, Options{Home: t.TempDir(), Platform: info, Runner: &dshRunner{t: t}, SearchRoots: []string{t.TempDir()}}) + if err == nil { + t.Errorf("Install() on %s/%s succeeded; the vendor ships no package there", info.OS, info.Arch) + } + } +} diff --git a/internal/desktopapp/macos_verify.go b/internal/desktopapp/macos_verify.go index defa8e4a..4ef0ad3b 100644 --- a/internal/desktopapp/macos_verify.go +++ b/internal/desktopapp/macos_verify.go @@ -31,6 +31,14 @@ func verifyZCodeMacOSApp(ctx context.Context, options Options, appPath string) e return verifyMacOSIdentity(ctx, options, appPath, ZCodeBundleID, ZCodeMacTeamID, "") } +// verifyDSHMacOSApp pins DeepSeek's Developer ID team and bundle identifier the +// same way, and requires notarization through spctl. The previous check for this +// entry ran codesign --verify alone, which any validly signed bundle passes; a +// stranger's app renamed to match would have installed. +func verifyDSHMacOSApp(ctx context.Context, options Options, appPath string) error { + return verifyMacOSIdentity(ctx, options, appPath, DSHDesktopBundleID, DSHDesktopTeamID, "") +} + func verifyMacOSIdentity(ctx context.Context, options Options, appPath, bundleID, teamID, authority string) error { result, err := run(options, ctx, []string{"/usr/bin/codesign", "--verify", "--deep", "--strict", "--verbose=2", appPath}, installTimeout) if err != nil { diff --git a/internal/desktopapp/registry.go b/internal/desktopapp/registry.go index 68258fe7..3a0f7411 100644 --- a/internal/desktopapp/registry.go +++ b/internal/desktopapp/registry.go @@ -56,16 +56,17 @@ type implementation struct { open func(context.Context, Options) error } -// First entry first in the UI: the list is rendered in this order, and DSH -// Desktop leads it. +// First entry first in the UI: the list is rendered in this order, and DeepSeek +// Harness leads it. var implementations = []implementation{ { Definition: Definition{ ID: DSHDesktopID, Name: DSHDesktopName, ProfileAgentID: "dsh", + // The legacy document. The app layer resolves the profile patch a + // 0.1.7 harness actually reads when that profile exists; this is what + // an older harness falls back to. ConfigPath: ".dsh/settings.yaml", ConfigAdapter: ConfigAdapterDSH, Protocol: "openai", Home: DSHDesktopHome, - // anywhere-labs builds this, not DeepSeek. - Unofficial: true, }, inspect: inspectDSH, install: installDSH, diff --git a/internal/desktopapp/testdata/dsh-fixture.dmg b/internal/desktopapp/testdata/dsh-fixture.dmg deleted file mode 100644 index f7634531e81835dec9436f1033c87afbbf4ea8c6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17478 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 Ok)g-eR>xtgAO8aieTMS@ diff --git a/internal/desktopapp/testdata/dsh-fixture.zip b/internal/desktopapp/testdata/dsh-fixture.zip new file mode 100644 index 0000000000000000000000000000000000000000..5ac4f495645cb6ffc0d942152ad2ba9e3ef914fd GIT binary patch literal 1026 zcmWIWW@h1H0D<%tt5`4tN{9k!m(|x%JeKNd z&J)wN(YdX8^8R&|n=G2$IqB~#i%n}PGo(MY-#W1``hw-`z^2%%+&BN~z1SA!w&cJEwl*Zx`9a!#B~#s2_rMkYCC+=&Vplt3WB@YWGTqsIm` ziQzT^l*k|eWP~x0iPZ?CB!}B9P@;nXkXiXaCVsPENf6aU^h5|U5twEe-ZpL|(nNGy qAwh^cDZ*@6(#Q+62wgWg2;u%mB+LMBRyL4MRv?@S)ECSI;sF5Jfh_3& literal 0 HcmV?d00001 diff --git a/internal/desktopapp/windows_verify.go b/internal/desktopapp/windows_verify.go index 8b380e07..f2cd06a8 100644 --- a/internal/desktopapp/windows_verify.go +++ b/internal/desktopapp/windows_verify.go @@ -34,22 +34,11 @@ func verifyWorkBuddyWindowsInstaller(ctx context.Context, edition workBuddyEditi return verifyWindowsInstallerPublisher(ctx, options, installerPath, edition.windowsSigners) } +// verifyDSHWindowsInstaller pins the vendor's publisher rather than accepting any +// valid Authenticode signature, which is what this check did for the third-party +// build it used to install. func verifyDSHWindowsInstaller(ctx context.Context, options Options, installerPath string) error { - result, err := runWithEnvironment(options, ctx, windowsAuthenticodeQuery(), map[string]string{"BOOTAGENT_VERIFY_PATH": installerPath}, installTimeout) - if err != nil { - return err - } - if result.ExitCode != 0 { - return commandFailure("run Windows Authenticode verification", result) - } - signature, err := parseWindowsAuthenticodeSignature(result.Stdout) - if err != nil { - return err - } - if !strings.EqualFold(strings.TrimSpace(signature.Status), "Valid") || strings.TrimSpace(signature.Subject) == "" || strings.TrimSpace(signature.Issuer) == "" { - return errors.New("DSH Desktop Windows installer has no valid Authenticode signature") - } - return nil + return verifyWindowsInstallerPublisher(ctx, options, installerPath, []string{DSHDesktopWindowsPublisher}) } // verifyZCodeWindowsInstaller pins the EV code-signing subject read out of the From db3eacdf8b951032530626b9407da0b2754bb219 Mon Sep 17 00:00:00 2001 From: yujiezhang-ops Date: Sat, 26 Sep 2026 17:17:26 +0800 Subject: [PATCH 5/8] fix: always target the desktop profile patch, and validate before writing The desktop Agent resolved its dsh document by whether profiles/desktop/ existed. The install flow leaves the app on disk without launching it, so at first configuration that directory is absent and the write went to the legacy settings.yaml -- a file the 0.1.7-only desktop app imports only partially on first start. Resolve the desktop profile to its patch unconditionally and let the write create the directory: dsh's initProfile fills in package.json and pnpm-workspace.yaml only where absent, so a patch that arrives first is read as-is. The web profile keeps its fallback, since npm `latest` is still 0.1.5. WriteDSHOfficial wrote the credential before validating reasoningEffort, so a rejected activation had already replaced DEEPSEEK_API_KEY and left a backup. Validate first, in both layouts. Smaller: a comment-only scaffold patch keeps its header through the first write; the patch is encoded with the two-space indent dsh uses, so untouched rows come back byte-for-byte; the reader takes the first row per id, as the writer does, instead of merging duplicates. Co-Authored-By: Claude Fable 5 --- internal/config/discovery.go | 10 ++- internal/config/dsh_profile.go | 58 ++++++++++++--- internal/config/dsh_profile_test.go | 108 ++++++++++++++++++++++++---- internal/config/write.go | 15 ++-- internal/config/write_test.go | 13 ++++ 5 files changed, 175 insertions(+), 29 deletions(-) diff --git a/internal/config/discovery.go b/internal/config/discovery.go index 1eefa1a0..8bf395c9 100644 --- a/internal/config/discovery.go +++ b/internal/config/discovery.go @@ -204,20 +204,28 @@ func ReadDSHConfig(text string) Detected { var piAI dshPiAISection var selection dshDefaultModelSection if len(root.Content) == 1 && root.Content[0].Kind == yaml.SequenceNode { + // The first row addressing an id is the one read, matching the writer, + // which edits the first row it finds. dsh's own tooling does not produce + // duplicates; if a hand edit did, decoding a later row into the same + // struct would merge the two, and the file would read as something no + // single row says. + seen := make(map[string]bool, 2) for _, row := range root.Content[0].Content { var entry struct { ID string `yaml:"id"` Config yaml.Node `yaml:"config"` } - if row.Decode(&entry) != nil { + if row.Decode(&entry) != nil || seen[entry.ID] { continue } switch entry.ID { case dshPiAIEntryID: + seen[entry.ID] = true if entry.Config.Decode(&piAI) != nil { return unreadable("llm-pi-ai 配置无法解析") } case dshDefaultModelEntryID: + seen[entry.ID] = true if entry.Config.Decode(&selection) != nil { return unreadable("agent-default-model 配置无法解析") } diff --git a/internal/config/dsh_profile.go b/internal/config/dsh_profile.go index dd0d1749..d676f7d9 100644 --- a/internal/config/dsh_profile.go +++ b/internal/config/dsh_profile.go @@ -1,6 +1,7 @@ package config import ( + "bytes" "context" "os" "path/filepath" @@ -41,13 +42,27 @@ const ( ) // ResolveDSHConfigPath picks the document BootAgent should write for one dsh -// profile. The profile patch wins whenever its directory exists: dsh creates it -// on first boot of that profile, and once it does the legacy file is either gone -// or renamed. Without it the legacy settings.yaml is returned so an older CLI -// keeps working -- the patch is never created speculatively, because a -// profiles/ directory BootAgent invented would not be one dsh boots. +// profile. +// +// The desktop profile always resolves to its patch. The Electron shell exists +// only in the 0.1.7 line, so nothing that reads settings.yaml ever boots that +// profile; and the install flow leaves the app on disk without launching it, +// so at the moment the user configures it the profile directory does not exist +// yet. Falling back to the legacy file there would write a document the app +// imports only partially on first start. Creating the patch ahead of the app is +// safe: its initProfile fills in package.json, pnpm-workspace.yaml and a +// template patch only where each file is absent, and the directory is made +// with mkdir -p semantics. +// +// The web profile is what `dsh web` boots, and which release that is depends +// on the npm tag installed: `latest` is still 0.1.5, which reads settings.yaml. +// There the patch wins only once dsh itself has created the profile directory; +// otherwise the legacy file is written so the older CLI keeps working. func ResolveDSHConfigPath(home, profile string) string { patch := filepath.Join(home, ".dsh", "profiles", profile, DSHProfilePatchName) + if profile == DSHDesktopProfile { + return patch + } if info, err := os.Stat(filepath.Dir(patch)); err == nil && info.IsDir() { return patch } @@ -106,7 +121,7 @@ func (w Writer) writeDSHProfileRoute(ctx context.Context, path, providerName, ba yamlSet(selection, "model", model) yamlReplace(dshPatchRow(root.Content[0], dshDefaultModelEntryID, dshDefaultModelEntryName), "config", selection) - data, err := yaml.Marshal(root) + data, err := encodeDSHProfilePatch(root) if err != nil { return configError("Cannot encode YAML configuration %s: %v", path, err) } @@ -131,21 +146,38 @@ func (w Writer) writeDSHProfileOfficial(ctx context.Context, path, model, reason selection := &yaml.Node{Kind: yaml.MappingNode} yamlSet(selection, "provider", dshOfficialRoute) yamlSet(selection, "model", model) + // Already validated by WriteDSHOfficial, before the credential was written. if reasoningEffort != "" { - if err := ValidateDSHOfficialReasoningEffort(reasoningEffort); err != nil { - return err - } yamlSet(selection, "reasoningEffort", reasoningEffort) } yamlReplace(dshPatchRow(root.Content[0], dshDefaultModelEntryID, dshDefaultModelEntryName), "config", selection) - data, err := yaml.Marshal(root) + data, err := encodeDSHProfilePatch(root) if err != nil { return configError("Cannot encode YAML configuration %s: %v", path, err) } return w.write(ctx, path, data, false) } +// encodeDSHProfilePatch serializes the patch with two-space indentation, which +// is what dsh's own scaffold and Models page write. yaml.Marshal defaults to +// four, so a rewrite through it would re-indent every row the user had -- a +// semantically identical file, but a noisy diff for someone who keeps the +// profile in version control, and not what "leaves the rest untouched" should +// mean. +func encodeDSHProfilePatch(root *yaml.Node) ([]byte, error) { + var buffer bytes.Buffer + encoder := yaml.NewEncoder(&buffer) + encoder.SetIndent(2) + if err := encoder.Encode(root); err != nil { + return nil, err + } + if err := encoder.Close(); err != nil { + return nil, err + } + return buffer.Bytes(), nil +} + // dshFindPatchRow returns the row addressing entry id, or nil. func dshFindPatchRow(sequence *yaml.Node, id string) *yaml.Node { for _, row := range sequence.Content { @@ -198,7 +230,11 @@ func yamlSequenceDocument(path, label string) (*yaml.Node, error) { return nil, configError("Cannot read existing %s %s: %v", label, path, err) } if isBlankYAML(text) { - return &yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{{Kind: yaml.SequenceNode}}}, nil + // dsh's scaffold writes a header comment and nothing else. yaml.v3 parses + // that as an empty document, so the comment is carried over by hand: it + // is the user's orientation in a file dsh tells them to edit directly. + sequence := &yaml.Node{Kind: yaml.SequenceNode, HeadComment: strings.TrimRight(text, "\n")} + return &yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{sequence}}, nil } root := &yaml.Node{} if err := yaml.Unmarshal([]byte(text), root); err != nil { diff --git a/internal/config/dsh_profile_test.go b/internal/config/dsh_profile_test.go index cf9599d4..d5f1f0f8 100644 --- a/internal/config/dsh_profile_test.go +++ b/internal/config/dsh_profile_test.go @@ -111,25 +111,63 @@ func dshPatchRows(t *testing.T, path string) map[string]map[string]any { return result } -func TestResolveDSHConfigPathPrefersTheProfilePatchOnceTheProfileExists(t *testing.T) { +func TestResolveDSHConfigPathFollowsWhatEachProfileReads(t *testing.T) { home := t.TempDir() legacy := filepath.Join(home, ".dsh", DSHLegacySettings) - // Nothing on disk yet: an older CLI reads settings.yaml, so that is what - // gets written. The profile directory is never invented. + desktopPatch := filepath.Join(home, ".dsh", "profiles", DSHDesktopProfile, DSHProfilePatchName) + webPatch := filepath.Join(home, ".dsh", "profiles", DSHWebProfile, DSHProfilePatchName) + + // Nothing on disk yet. The desktop app is 0.1.7-only and the install flow + // does not launch it, so this is exactly the state at first configuration: + // the patch it will read, not a legacy file it would half-import. The web + // profile may be a 0.1.5 CLI, which reads settings.yaml. + if got := ResolveDSHConfigPath(home, DSHDesktopProfile); got != desktopPatch { + t.Fatalf("fresh desktop profile resolves to %q, want %q", got, desktopPatch) + } if got := ResolveDSHConfigPath(home, DSHWebProfile); got != legacy { - t.Fatalf("fresh home resolves to %q, want %q", got, legacy) + t.Fatalf("fresh web profile resolves to %q, want %q", got, legacy) } - // dsh 0.1.7 has booted the desktop profile. Only that profile switches; the - // web profile it has not created still resolves to the legacy file. - desktop := filepath.Join(home, ".dsh", "profiles", DSHDesktopProfile) - if err := os.MkdirAll(desktop, 0o700); err != nil { + // Once `dsh web` from 0.1.7 has created its profile, the web profile + // switches too. + if err := os.MkdirAll(filepath.Dir(webPatch), 0o700); err != nil { t.Fatal(err) } - if got := ResolveDSHConfigPath(home, DSHDesktopProfile); got != filepath.Join(desktop, DSHProfilePatchName) { - t.Fatalf("desktop profile resolves to %q", got) + if got := ResolveDSHConfigPath(home, DSHWebProfile); got != webPatch { + t.Fatalf("web profile resolves to %q, want %q", got, webPatch) } - if got := ResolveDSHConfigPath(home, DSHWebProfile); got != legacy { - t.Fatalf("web profile resolves to %q, want the legacy file", got) +} + +// The desktop profile directory does not exist before the app's first launch. +// The write has to create it, and leave only the patch behind: the app's own +// initProfile fills in package.json and pnpm-workspace.yaml where absent, and +// must not find files BootAgent guessed at. +func TestWriteDSHProfileCreatesTheDesktopProfileAheadOfTheApp(t *testing.T) { + home := t.TempDir() + path := ResolveDSHConfigPath(home, DSHDesktopProfile) + if _, err := os.Stat(filepath.Dir(path)); !os.IsNotExist(err) { + t.Fatalf("profile directory exists before the write: %v", err) + } + if err := testWriter(t, home, "linux").WriteDSH(context.Background(), path, "PPIO", "https://api.example", "sk", "m"); err != nil { + t.Fatal(err) + } + entries, err := os.ReadDir(filepath.Dir(path)) + if err != nil { + t.Fatal(err) + } + names := make([]string, 0, len(entries)) + for _, entry := range entries { + names = append(names, entry.Name()) + } + if len(names) != 1 || names[0] != DSHProfilePatchName { + t.Fatalf("profile directory contains %v, want only %s", names, DSHProfilePatchName) + } + // And the legacy file was not touched: nothing reads it for this profile. + if _, err := os.Stat(filepath.Join(home, ".dsh", DSHLegacySettings)); !os.IsNotExist(err) { + t.Fatalf("legacy settings.yaml was written: %v", err) + } + rows := dshPatchRows(t, path) + if rows["agent-default-model"]["provider"] != "bootagent" { + t.Fatalf("default selection = %v", rows["agent-default-model"]) } } @@ -260,6 +298,37 @@ func TestWriteDSHProfileStartsFromACommentOnlyPatch(t *testing.T) { if !strings.Contains(string(data), dshPiAIEntryName) || !strings.Contains(string(data), dshDefaultModelEntryName) { t.Errorf("new rows do not name their plugins:\n%s", data) } + // The scaffold's header comment is the user's orientation in the file and + // has to survive the first write, which is the one that finds no rows. + if !strings.HasPrefix(string(data), "# Your patch layer for this dsh profile.\n# Edit freely.\n- id: ") { + t.Errorf("header comment was lost or displaced:\n%s", data) + } +} + +// The rest of the file is rewritten with the indentation dsh itself uses, so a +// write changes only the rows it touched. A row the write never addresses +// comes out byte-for-byte as it went in. +func TestWriteDSHProfileKeepsTheFilesIndentation(t *testing.T) { + untouched := "- id: session-persistence-jsonl\n config:\n root: !!js dshHomePath('sessions')\n- id: ui-settings-general\n name: \"@deepseek-ai/dsh-client-ui-settings-general\"\n config:\n welcomeNoticeVersion: 2026-08-13.1\n" + home, path, _ := dshProfileHome(t, DSHDesktopProfile, untouched, "") + if err := testWriter(t, home, "linux").WriteDSH(context.Background(), path, "PPIO", "https://api.example", "sk", "m"); err != nil { + t.Fatal(err) + } + data, _ := os.ReadFile(path) + if !strings.HasPrefix(string(data), untouched) { + t.Fatalf("rows the write did not address were re-indented or reordered:\nwant prefix:\n%s\ngot:\n%s", untouched, data) + } +} + +// A hand edit that repeats a row id must read as the writer would treat it: +// the first row wins. Decoding both into one struct would merge them. +func TestReadDSHConfigTakesTheFirstRowPerID(t *testing.T) { + text := "- id: llm-pi-ai\n config:\n providers:\n bootagent:\n baseURL: https://first.example/v1\n models:\n - id: first\n" + + "- id: llm-pi-ai\n config:\n providers:\n other:\n baseURL: https://second.example/v1\n" + got := ReadDSHConfig(text) + if got.BaseURL != "https://first.example/v1" || got.Model != "first" || !got.ManagedByBootAgent { + t.Fatalf("duplicate rows read as %#v, want the first row alone", got) + } } func TestWriteDSHProfileIsIdempotent(t *testing.T) { @@ -342,6 +411,21 @@ func TestWriteDSHProfileOfficialLeavesNoEmptyPiAIRow(t *testing.T) { } } +func TestWriteDSHProfileOfficialRejectsAnEffortBeforeTouchingCredentials(t *testing.T) { + home, path, credentials := dshProfileHome(t, DSHDesktopProfile, "", dshCredentialsWithRecordsFixture) + err := testWriter(t, home, "linux").WriteDSHOfficial(context.Background(), path, "sk-x", "deepseek-v4-pro", "medium") + if err == nil || !strings.Contains(err.Error(), "must be one of off, high, max") { + t.Fatalf("unsupported effort = %v", err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("a rejected effort still wrote the patch: err=%v", err) + } + after, _ := os.ReadFile(credentials) + if string(after) != dshCredentialsWithRecordsFixture { + t.Errorf("a rejected effort still rewrote the credential store:\n%s", after) + } +} + func TestWriteDSHProfileRefusesANonListDocument(t *testing.T) { home, path, _ := dshProfileHome(t, DSHDesktopProfile, "llm-pi-ai:\n providers: {}\n", "") err := testWriter(t, home, "linux").WriteDSH(context.Background(), path, "PPIO", "https://api.example", "sk", "m") diff --git a/internal/config/write.go b/internal/config/write.go index bb497999..0d683276 100644 --- a/internal/config/write.go +++ b/internal/config/write.go @@ -612,8 +612,16 @@ func (w Writer) WriteDSHProtocol(ctx context.Context, path, providerName, baseUR // as complete and would otherwise keep sending an effort this model never // declared. func (w Writer) WriteDSHOfficial(ctx context.Context, path, apiKey, model, reasoningEffort string) error { - // The credential lands first: a selection pointing at a route dsh cannot - // authenticate is worse than an unreferenced key. + // Validation before any write: an effort the shipped route cannot dispatch + // is a request error, and a request error must not leave a half-applied + // activation behind -- least of all a replaced credential. + if reasoningEffort != "" { + if err := ValidateDSHOfficialReasoningEffort(reasoningEffort); err != nil { + return err + } + } + // Then the credential, before the selection: a selection pointing at a + // route dsh cannot authenticate is worse than an unreferenced key. if err := w.writeDSHCredential(ctx, dshCredentialsPath(path), dshOfficialCredential, apiKey); err != nil { return err } @@ -636,9 +644,6 @@ func (w Writer) WriteDSHOfficial(ctx context.Context, path, apiKey, model, reaso yamlSet(selection, "provider", dshOfficialRoute) yamlSet(selection, "model", model) if reasoningEffort != "" { - if err := ValidateDSHOfficialReasoningEffort(reasoningEffort); err != nil { - return err - } yamlSet(selection, "reasoningEffort", reasoningEffort) } yamlReplace(root.Content[0], "agent-default-model", selection) diff --git a/internal/config/write_test.go b/internal/config/write_test.go index 21fcecb9..ac9f4cb5 100644 --- a/internal/config/write_test.go +++ b/internal/config/write_test.go @@ -1325,6 +1325,10 @@ func TestWriteDSHOfficialRejectsAnUnsupportedReasoningEffort(t *testing.T) { if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { t.Fatal(err) } + credentials := filepath.Join(home, ".dsh", ".credentials.yaml") + if err := os.WriteFile(credentials, []byte("version: 1\nrefs:\n DEEPSEEK_API_KEY: sk-users-own\n"), 0o600); err != nil { + t.Fatal(err) + } writer := testWriter(t, home, "linux") if err := writer.WriteDSHOfficial(context.Background(), path, "sk-x", "deepseek-v4-pro", "medium"); err == nil { t.Fatal("an effort the shipped route cannot dispatch was accepted") @@ -1332,6 +1336,15 @@ func TestWriteDSHOfficialRejectsAnUnsupportedReasoningEffort(t *testing.T) { if _, err := os.Stat(path); !os.IsNotExist(err) { t.Errorf("a rejected effort still wrote settings: err=%v", err) } + // A rejected activation must not leave a half-applied one behind. The + // credential is written before the selection, so it is the write a late + // validation would have already done. + if stored := dshCredentials(t, credentials); stored["DEEPSEEK_API_KEY"] != "sk-users-own" { + t.Errorf("a rejected effort still replaced the credential: %v", stored) + } + if backups, _ := filepath.Glob(credentials + ".backup-*"); len(backups) != 0 { + t.Errorf("a rejected effort still produced credential backups: %v", backups) + } for _, valid := range []string{"off", "high", "max"} { if err := ValidateDSHOfficialReasoningEffort(valid); err != nil { t.Errorf("valid effort %q rejected: %v", valid, err) From 3a70a656526df9e3e587085e35ca3e10bc7a82ab Mon Sep 17 00:00:00 2001 From: yujiezhang-ops Date: Sat, 26 Sep 2026 17:17:27 +0800 Subject: [PATCH 6/8] fix: report DeepSeek Harness unsupported where the vendor ships no package Supported was decided from the OS alone while the feed lookup also refused by architecture, so an Intel Mac saw an install entry that failed on every click. One function now names the vendor's target for a platform and drives both: mac-arm64 for Apple Silicon, win-x64 for every Windows -- an ARM Windows machine installs the x64 build, as with the other x64-only desktop apps -- and nothing elsewhere. The macOS inspection and install paths report through the caller's platform instead of a hardcoded arm64. The "not offered" test asserted only that Install failed; it now asserts Supported=false through Inspect too, which is what the UI reads. Co-Authored-By: Claude Fable 5 --- internal/desktopapp/dsh.go | 62 +++++++++++++++++++--------- internal/desktopapp/dsh_test.go | 73 +++++++++++++++++++++++++++------ 2 files changed, 102 insertions(+), 33 deletions(-) diff --git a/internal/desktopapp/dsh.go b/internal/desktopapp/dsh.go index 3559fb19..5f437c3a 100644 --- a/internal/desktopapp/dsh.go +++ b/internal/desktopapp/dsh.go @@ -13,6 +13,7 @@ import ( "path/filepath" "strings" + "github.com/MaimoryLab/BootAgent/internal/platform" "gopkg.in/yaml.v3" ) @@ -65,22 +66,40 @@ type dshFeedFile struct { Size int64 `yaml:"size"` } -// dshFeedURL names the manifest for one target. The vendor publishes mac-arm64 -// and win-x64 only; the channel is "nightly" in the shipped app-update.yml even -// for release-candidate builds, so that is the file name electron-updater asks -// for and the one that exists. -func dshFeedURL(osID, arch string) (string, error) { +// dshTarget names the vendor's feed directory for a platform, or "" when the +// vendor publishes nothing BootAgent can install there. This is the one place +// that decides both what Status.Supported reports and what dshFeedURL fetches, +// so the UI cannot offer an install that the lookup then refuses. +// +// The vendor publishes mac-arm64 and win-x64 only. An Intel Mac gets nothing: +// there is no x64 build and Rosetta does not run arm64 binaries. Windows on ARM +// gets the x64 installer, which is the supported way to install x64-only +// products there (see platform.nativeArch) and what the vendor's own updater +// would serve on such a machine. +func dshTarget(osID, arch string) string { switch osID { case "macos": - if arch != "arm64" && arch != "aarch64" { - return "", fmt.Errorf("%s has no package for %s/%s", DSHDesktopName, osID, arch) + if arch == "arm64" { + return "mac-arm64" } - return DSHDesktopFeedBase + "mac-arm64/nightly-mac.yml", nil case "windows": - switch strings.ToLower(strings.TrimSpace(arch)) { - case "x64", "amd64", "x86_64": - return DSHDesktopFeedBase + "win-x64/nightly.yml", nil - } + return "win-x64" + } + return "" +} + +// dshFeedURL names the manifest for one target. The channel is "nightly" in the +// shipped app-update.yml even for release-candidate builds, so that is the file +// name electron-updater asks for and the one that exists. +func dshFeedURL(osID, arch string) (string, error) { + target := dshTarget(osID, arch) + switch target { + case "mac-arm64": + return DSHDesktopFeedBase + target + "/nightly-mac.yml", nil + case "win-x64": + return DSHDesktopFeedBase + target + "/nightly.yml", nil + } + if osID == "macos" || osID == "windows" { return "", fmt.Errorf("%s has no package for %s/%s", DSHDesktopName, osID, arch) } return "", fmt.Errorf("%s is not supported on %s", DSHDesktopName, osID) @@ -200,19 +219,22 @@ func verifyDSHDigest(path string, expected dshFeedFile) error { return nil } -func baseDSHStatus(osID string) Status { +// baseDSHStatus reports Supported only for a platform the vendor publishes a +// package for. The OS alone is not enough: an Intel Mac is macOS and gets no +// package, and a Supported=true there is an install button that always fails. +func baseDSHStatus(info platform.Info) Status { status := Status{ID: DSHDesktopID, Name: DSHDesktopName, Source: SourceUnknown} - switch osID { - case "macos": + switch dshTarget(info.OS, info.Arch) { + case "mac-arm64": status.Supported, status.Source = true, SourceMacOSZIP - case "windows": + case "win-x64": status.Supported, status.Source = true, SourceWindowsInstaller } return status } func inspectDSH(ctx context.Context, options Options) Status { - status := baseDSHStatus(options.Platform.OS) + status := baseDSHStatus(options.Platform) if err := contextError(ctx); err != nil { status.InspectionUnavailable = nonEmptyPointer(err.Error()) return status @@ -240,7 +262,7 @@ func inspectDSH(ctx context.Context, options Options) Status { // plist: the app updates itself through electron-updater, so the version on // disk is not the one BootAgent installed. func inspectDSHMacOS(ctx context.Context, options Options) (Status, error) { - status := baseDSHStatus("macos") + status := baseDSHStatus(options.Platform) roots := options.SearchRoots if len(roots) == 0 { roots = []string{"/Applications"} @@ -387,7 +409,7 @@ func installDSHMacOS(ctx context.Context, options Options) (ActionResult, error) lastErr = commandFailure("copy "+DSHDesktopName+" app", copied) continue } - installed := baseDSHStatus("macos") + installed := baseDSHStatus(options.Platform) installed.Installed, installed.Path, installed.Version = true, destination, metadata.version if installed.Version == nil { installed.Version = nonEmptyPointer(feed.Version) @@ -440,7 +462,7 @@ func installDSHWindows(ctx context.Context, options Options) (ActionResult, erro return ActionResult{}, fmt.Errorf("start %s installer: %w", DSHDesktopName, err) } keep = true - status := baseDSHStatus("windows") + status := baseDSHStatus(options.Platform) status.Version = nonEmptyPointer(feed.Version) return ActionResult{Status: "installer-started", Message: "The downloaded " + DSHDesktopName + " installer was started", RefreshNeeded: true, App: status}, nil } diff --git a/internal/desktopapp/dsh_test.go b/internal/desktopapp/dsh_test.go index f2ab0ca9..625a3f6a 100644 --- a/internal/desktopapp/dsh_test.go +++ b/internal/desktopapp/dsh_test.go @@ -80,26 +80,44 @@ func (r *dshRunner) Start(argv []string, _ map[string]string) error { return nil } -// The vendor publishes two targets. Intel macOS and Windows on ARM have no -// package, and the feed for them does not exist, so the lookup has to refuse -// rather than request a 404. -func TestDSHFeedURLCoversOnlyThePublishedTargets(t *testing.T) { - for _, test := range []struct{ osID, arch, want string }{ +// The vendor publishes two targets, and Supported has to say the same thing the +// feed lookup does: a row reported Supported on a platform the lookup then +// refuses is an install button that always fails. Intel macOS gets nothing. +// Windows on ARM gets the x64 installer, which is how x64-only products are +// installed there. +func TestDSHSupportMatchesThePublishedTargets(t *testing.T) { + for _, test := range []struct { + osID, arch string + feed string + }{ {"macos", "arm64", dshMacFeedURL}, {"macos", "aarch64", dshMacFeedURL}, {"windows", "x64", dshWinFeedURL}, {"windows", "amd64", dshWinFeedURL}, + {"windows", "arm64", dshWinFeedURL}, } { - got, err := dshFeedURL(test.osID, test.arch) - if err != nil || got != test.want { - t.Errorf("dshFeedURL(%q, %q) = %q, %v; want %q", test.osID, test.arch, got, err, test.want) + info := platform.For(test.osID, test.arch) + if status := baseDSHStatus(info); !status.Supported { + t.Errorf("baseDSHStatus(%s/%s).Supported = false, want true", info.OS, info.Arch) + } + got, err := dshFeedURL(info.OS, info.Arch) + if err != nil || got != test.feed { + t.Errorf("dshFeedURL(%s/%s) = %q, %v; want %q", info.OS, info.Arch, got, err, test.feed) } } for _, test := range []struct{ osID, arch string }{ - {"macos", "amd64"}, {"macos", "x86_64"}, {"windows", "arm64"}, {"linux", "amd64"}, + {"macos", "amd64"}, {"macos", "x86_64"}, {"linux", "amd64"}, {"linux", "arm64"}, } { - if got, err := dshFeedURL(test.osID, test.arch); err == nil { - t.Errorf("dshFeedURL(%q, %q) = %q, want an error", test.osID, test.arch, got) + info := platform.For(test.osID, test.arch) + if status := baseDSHStatus(info); status.Supported { + t.Errorf("baseDSHStatus(%s/%s).Supported = true, but the vendor ships no package there", info.OS, info.Arch) + } + if got, err := dshFeedURL(info.OS, info.Arch); err == nil { + t.Errorf("dshFeedURL(%s/%s) = %q, want an error", info.OS, info.Arch, got) + } + // And Inspect, the call the UI actually makes, agrees. + if status := Inspect(context.Background(), DSHDesktopID, Options{Platform: info, SearchRoots: []string{t.TempDir()}}); status.Supported { + t.Errorf("Inspect(%s/%s).Supported = true", info.OS, info.Arch) } } } @@ -313,11 +331,40 @@ func TestInspectDSHWindowsLooksInThePerUserProgramsDirectory(t *testing.T) { } } +// Not offered means both halves: Supported is false so the UI shows no install +// entry, and Install refuses if called anyway. func TestDSHIsNotOfferedOnIntelMacOrLinux(t *testing.T) { for _, info := range []platform.Info{platform.For("macos", "amd64"), platform.For("linux", "amd64")} { - _, err := Install(context.Background(), DSHDesktopID, Options{Home: t.TempDir(), Platform: info, Runner: &dshRunner{t: t}, SearchRoots: []string{t.TempDir()}}) - if err == nil { + options := Options{Home: t.TempDir(), Platform: info, Runner: &dshRunner{t: t}, SearchRoots: []string{t.TempDir()}} + if status := Inspect(context.Background(), DSHDesktopID, options); status.Supported { + t.Errorf("Inspect(%s/%s).Supported = true; the vendor ships no package there", info.OS, info.Arch) + } + if _, err := Install(context.Background(), DSHDesktopID, options); err == nil { t.Errorf("Install() on %s/%s succeeded; the vendor ships no package there", info.OS, info.Arch) } } } + +// Windows on ARM installs the x64 build: Supported, and the feed is win-x64. +func TestDSHWindowsOnARMInstallsTheX64Build(t *testing.T) { + payload := []byte("DeepSeek Harness Windows installer") + feed := dshFeedYAML("0.1.7-rc.2", dshWinExeURL, dshDigest(payload), int64(len(payload))) + downloader := &routeDownloader{routes: map[string][]byte{dshWinFeedURL: feed, dshWinExeURL: payload}} + runner := &dshRunner{t: t, results: []process.Result{ + {ExitCode: 0, Stdout: `{"Status":"Valid","StatusMessage":"Signature verified.","Publisher":"Hangzhou DeepSeek Artificial Intelligence Co., Ltd","Organization":"Hangzhou DeepSeek Artificial Intelligence Co., Ltd","Subject":"O=Hangzhou DeepSeek Artificial Intelligence Co., Ltd","Issuer":"CN=CA"}`}, + }} + options := Options{Home: t.TempDir(), Platform: platform.For("windows", "arm64"), Runner: runner, Downloader: downloader, SearchRoots: []string{t.TempDir()}} + if status := Inspect(context.Background(), DSHDesktopID, options); !status.Supported { + t.Fatalf("Inspect(windows/arm64) = %#v, want Supported", status) + } + result, err := Install(context.Background(), DSHDesktopID, options) + if err != nil { + t.Fatal(err) + } + if result.Status != "installer-started" || downloader.hits[0] != dshWinFeedURL { + t.Fatalf("Install() = %#v hits=%v", result, downloader.hits) + } + if err := os.Remove(runner.started[0][0]); err != nil { + t.Fatal(err) + } +} From ea8ad96a59911f90a6c07b373733dc64de321a50 Mon Sep 17 00:00:00 2001 From: Elysia Date: Sat, 26 Sep 2026 17:47:49 +0800 Subject: [PATCH 7/8] fix: accept DeepSeek Harness's real Windows signature The win-x64 installer is signed "Hangzhou DeepSeek Artificial Intelligence Co., Ltd." (with the period), and .NET quotes that name in the Subject because it holds a comma. Splitting the Subject on every comma cut the organization to `"Hangzhou DeepSeek Artificial Intelligence Co.`, so the genuine installer was refused. Read O= in Go with a quote-aware parser and pin the publisher observed on 0.1.7-rc.2. Co-Authored-By: Claude Sonnet 5 --- internal/desktopapp/dsh.go | 11 +++--- internal/desktopapp/dsh_test.go | 9 +++-- internal/desktopapp/windows_verify.go | 42 ++++++++++++++++++---- internal/desktopapp/windows_verify_test.go | 21 +++++++++++ 4 files changed, 68 insertions(+), 15 deletions(-) create mode 100644 internal/desktopapp/windows_verify_test.go diff --git a/internal/desktopapp/dsh.go b/internal/desktopapp/dsh.go index 5f437c3a..be6680a7 100644 --- a/internal/desktopapp/dsh.go +++ b/internal/desktopapp/dsh.go @@ -36,12 +36,11 @@ const ( DSHDesktopBundleID = "com.deepseek.dsh" DSHDesktopTeamID = "NAN929V4UM" - // DSHDesktopWindowsPublisher is the legal entity behind the macOS Developer ID - // team, which is also what the vendor's Windows signing derives publisherName - // from (the certificate's O attribute). Read off the same release line; the - // Windows certificate itself was not inspected on this machine, so a mismatch - // here surfaces as a refused install rather than an accepted stranger. - DSHDesktopWindowsPublisher = "Hangzhou DeepSeek Artificial Intelligence Co., Ltd" + // DSHDesktopWindowsPublisher is the CN and O of the EV certificate that signs + // the win-x64 installer (issuer GlobalSign GCC R45 EV CodeSigning CA 2020), as + // Get-AuthenticodeSignature reports it on 0.1.7-rc.2. Unlike the macOS + // Developer ID name it ends in "Ltd." with the period. + DSHDesktopWindowsPublisher = "Hangzhou DeepSeek Artificial Intelligence Co., Ltd." // DSHDesktopDownloadHost is the vendor's release origin. The desktop app's own // app-update.yml points electron-updater at dsh-desk/feeds// under it, diff --git a/internal/desktopapp/dsh_test.go b/internal/desktopapp/dsh_test.go index 625a3f6a..e65b5bde 100644 --- a/internal/desktopapp/dsh_test.go +++ b/internal/desktopapp/dsh_test.go @@ -245,12 +245,17 @@ func TestDSHMacOSInstallStopsBeforeExtractingOnDigestMismatch(t *testing.T) { } } +// dshWindowsSignature is Get-AuthenticodeSignature's view of the real win-x64 +// installer. The Subject quotes each name because it contains a comma, which is +// the shape that used to cut the organization off at "Co.". +const dshWindowsSignature = `{"Status":"Valid","StatusMessage":"Signature verified.","Publisher":"Hangzhou DeepSeek Artificial Intelligence Co., Ltd.","Subject":"CN=\"Hangzhou DeepSeek Artificial Intelligence Co., Ltd.\", O=\"Hangzhou DeepSeek Artificial Intelligence Co., Ltd.\", L=Hangzhou, S=Zhejiang, C=CN, OID.1.3.6.1.4.1.311.60.2.1.1=Hangzhou, OID.1.3.6.1.4.1.311.60.2.1.2=Zhejiang, OID.1.3.6.1.4.1.311.60.2.1.3=CN, SERIALNUMBER=91330105MACPN4X08Y, OID.2.5.4.15=Private Organization","Issuer":"CN=GlobalSign GCC R45 EV CodeSigning CA 2020, O=GlobalSign nv-sa, C=BE"}` + func TestDSHWindowsInstallVerifiesAuthenticodeBeforeStarting(t *testing.T) { payload := []byte("DeepSeek Harness Windows installer") feed := dshFeedYAML("0.1.7-rc.2", dshWinExeURL, dshDigest(payload), int64(len(payload))) downloader := &routeDownloader{routes: map[string][]byte{dshWinFeedURL: feed, dshWinExeURL: payload}} runner := &dshRunner{t: t, results: []process.Result{ - {ExitCode: 0, Stdout: `{"Status":"Valid","StatusMessage":"Signature verified.","Publisher":"Hangzhou DeepSeek Artificial Intelligence Co., Ltd","Organization":"Hangzhou DeepSeek Artificial Intelligence Co., Ltd","Subject":"CN=Hangzhou DeepSeek Artificial Intelligence Co., Ltd, O=Hangzhou DeepSeek Artificial Intelligence Co., Ltd, C=CN","Issuer":"CN=Some Code Signing CA"}`}, + {ExitCode: 0, Stdout: dshWindowsSignature}, }} result, err := Install(context.Background(), DSHDesktopID, Options{ Home: t.TempDir(), Platform: platform.For("windows", "x64"), Runner: runner, Downloader: downloader, @@ -351,7 +356,7 @@ func TestDSHWindowsOnARMInstallsTheX64Build(t *testing.T) { feed := dshFeedYAML("0.1.7-rc.2", dshWinExeURL, dshDigest(payload), int64(len(payload))) downloader := &routeDownloader{routes: map[string][]byte{dshWinFeedURL: feed, dshWinExeURL: payload}} runner := &dshRunner{t: t, results: []process.Result{ - {ExitCode: 0, Stdout: `{"Status":"Valid","StatusMessage":"Signature verified.","Publisher":"Hangzhou DeepSeek Artificial Intelligence Co., Ltd","Organization":"Hangzhou DeepSeek Artificial Intelligence Co., Ltd","Subject":"O=Hangzhou DeepSeek Artificial Intelligence Co., Ltd","Issuer":"CN=CA"}`}, + {ExitCode: 0, Stdout: dshWindowsSignature}, }} options := Options{Home: t.TempDir(), Platform: platform.For("windows", "arm64"), Runner: runner, Downloader: downloader, SearchRoots: []string{t.TempDir()}} if status := Inspect(context.Background(), DSHDesktopID, options); !status.Supported { diff --git a/internal/desktopapp/windows_verify.go b/internal/desktopapp/windows_verify.go index f2cd06a8..1d1b54be 100644 --- a/internal/desktopapp/windows_verify.go +++ b/internal/desktopapp/windows_verify.go @@ -17,7 +17,6 @@ type windowsAuthenticodeSignature struct { Status string `json:"Status"` StatusMessage string `json:"StatusMessage"` Publisher string `json:"Publisher"` - Organization string `json:"Organization"` Subject string `json:"Subject"` Issuer string `json:"Issuer"` } @@ -86,13 +85,46 @@ func verifyWindowsInstallerPublisher(ctx context.Context, options Options, insta if strings.TrimSpace(signature.Subject) == "" || strings.TrimSpace(signature.Issuer) == "" { return errors.New("Windows Authenticode result has no signer certificate") } - if !approvedWindowsSigner(signature.Organization, allowed) || !approvedWindowsSigner(signature.Publisher, allowed) { - return fmt.Errorf("Windows Authenticode publisher %q (organization %q) is not approved", signature.Publisher, signature.Organization) + organization := distinguishedNameAttribute(signature.Subject, "O") + if !approvedWindowsSigner(organization, allowed) || !approvedWindowsSigner(signature.Publisher, allowed) { + return fmt.Errorf("Windows Authenticode publisher %q (organization %q) is not approved", signature.Publisher, organization) } return nil } +// distinguishedNameAttribute reads one attribute out of a certificate Subject as +// .NET formats it: a value holding a comma is wrapped in double quotes, with any +// quote inside it doubled. Splitting on every comma cuts such a value short -- +// "Co., Ltd." is exactly that case. +func distinguishedNameAttribute(subject, key string) string { + var components []string + var current strings.Builder + quoted := false + for i := 0; i < len(subject); i++ { + switch c := subject[i]; { + case c == '"' && quoted && i+1 < len(subject) && subject[i+1] == '"': + current.WriteByte('"') + i++ + case c == '"': + quoted = !quoted + case c == ',' && !quoted: + components = append(components, current.String()) + current.Reset() + default: + current.WriteByte(c) + } + } + components = append(components, current.String()) + for _, component := range components { + name, value, ok := strings.Cut(component, "=") + if ok && strings.EqualFold(strings.TrimSpace(name), key) { + return strings.TrimSpace(value) + } + } + return "" +} + func approvedWindowsSigner(value string, allowed []string) bool { value = strings.TrimSpace(value) for _, expected := range allowed { @@ -107,7 +139,6 @@ func windowsAuthenticodeQuery() []string { const script = `[Console]::OutputEncoding = [Text.Encoding]::UTF8 $signature = Get-AuthenticodeSignature -LiteralPath $env:BOOTAGENT_VERIFY_PATH $certificate = $signature.SignerCertificate -$organization = "" $publisher = "" $subject = "" $issuer = "" @@ -115,14 +146,11 @@ if ($null -ne $certificate) { $publisher = [string]$certificate.GetNameInfo([System.Security.Cryptography.X509Certificates.X509NameType]::SimpleName, $false) $subject = [string]$certificate.Subject $issuer = [string]$certificate.Issuer - $organization = ($subject -split "," | Where-Object { $_.TrimStart().StartsWith("O=") } | Select-Object -First 1) - if ($null -ne $organization) { $organization = $organization.Substring($organization.IndexOf("=") + 1).Trim() } } [pscustomobject]@{ Status = [string]$signature.Status StatusMessage = [string]$signature.StatusMessage Publisher = $publisher - Organization = $organization Subject = $subject Issuer = $issuer } | ConvertTo-Json -Compress` diff --git a/internal/desktopapp/windows_verify_test.go b/internal/desktopapp/windows_verify_test.go new file mode 100644 index 00000000..37aaee74 --- /dev/null +++ b/internal/desktopapp/windows_verify_test.go @@ -0,0 +1,21 @@ +package desktopapp + +import "testing" + +func TestDistinguishedNameAttributeKeepsQuotedCommas(t *testing.T) { + for _, tc := range []struct { + subject, key, want string + }{ + {`CN="Hangzhou DeepSeek Artificial Intelligence Co., Ltd.", O="Hangzhou DeepSeek Artificial Intelligence Co., Ltd.", L=Hangzhou, C=CN`, "O", "Hangzhou DeepSeek Artificial Intelligence Co., Ltd."}, + {`CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond`, "O", "Microsoft Corporation"}, + {`CN=北京智谱华章科技股份有限公司, O=北京智谱华章科技股份有限公司`, "o", "北京智谱华章科技股份有限公司"}, + {`CN="Say ""Hi"", Inc.", O="Say ""Hi"", Inc."`, "O", `Say "Hi", Inc.`}, + {`CN=Tencent, OU=Dev, C=CN`, "O", ""}, + {`OID.2.5.4.15=Private Organization, O=Example`, "O", "Example"}, + {``, "O", ""}, + } { + if got := distinguishedNameAttribute(tc.subject, tc.key); got != tc.want { + t.Errorf("distinguishedNameAttribute(%q, %q) = %q, want %q", tc.subject, tc.key, got, tc.want) + } + } +} From c93ff4eea609a634fc4a8fda4456a696408617cd Mon Sep 17 00:00:00 2001 From: Elysia Date: Sat, 26 Sep 2026 17:58:22 +0800 Subject: [PATCH 8/8] fix: write into a freshly installed DeepSeek Harness profile On first launch the 0.1.7-rc.2 desktop app writes a credential document with version and records but no refs, and a patch scaffold ending in a flow-style `[]`. The missing refs made WriteDSH refuse outright, and the flow style put every appended row on a single line in a file dsh tells users to edit by hand. Create refs when it is absent and emit an empty list in block style. Co-Authored-By: Claude Sonnet 5 --- internal/config/dsh_profile.go | 6 ++++ internal/config/dsh_profile_test.go | 46 +++++++++++++++++++++++++++++ internal/config/write.go | 8 ++++- 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/internal/config/dsh_profile.go b/internal/config/dsh_profile.go index d676f7d9..7c7b14ed 100644 --- a/internal/config/dsh_profile.go +++ b/internal/config/dsh_profile.go @@ -243,6 +243,12 @@ func yamlSequenceDocument(path, label string) (*yaml.Node, error) { if len(root.Content) != 1 || root.Content[0].Kind != yaml.SequenceNode { return nil, configError("Existing %s must contain a list: %s", label, path) } + // The desktop app's scaffold ends in a flow-style `[]`. Rows appended to it + // would inherit that style and land on one line in a file meant for hand + // editing, so an empty list is written back in block style. + if sequence := root.Content[0]; len(sequence.Content) == 0 { + sequence.Style &^= yaml.FlowStyle + } return root, nil } diff --git a/internal/config/dsh_profile_test.go b/internal/config/dsh_profile_test.go index d5f1f0f8..ab3e3004 100644 --- a/internal/config/dsh_profile_test.go +++ b/internal/config/dsh_profile_test.go @@ -305,6 +305,52 @@ func TestWriteDSHProfileStartsFromACommentOnlyPatch(t *testing.T) { } } +// What DeepSeek Harness 0.1.7-rc.2 leaves on Windows after its first launch and +// before the user saves any key: an empty flow sequence under the scaffold +// comment, and a credential document with records but no refs yet. +func TestWriteDSHProfileIntoAFreshDesktopInstall(t *testing.T) { + const patch = "# Your patch layer for this dsh profile, applied after every bundle layer:\n" + + "# a top-level YAML array of loader patch entries (id-targeted config\n" + + "# overrides, disables, and insert lists; `!!js` expressions allowed).\n" + + "[]\n" + const credentials = "version: 1\n" + + "records:\n" + + " client-connection/browser-session:\n" + + " kind: grant\n" + + " payload:\n" + + " version: 1\n" + + " secret: browser-session-secret\n" + home, path, credentialsPath := dshProfileHome(t, DSHDesktopProfile, patch, credentials) + if err := testWriter(t, home, "linux").WriteDSH(context.Background(), path, "PPIO", "https://api.example", "sk-new", "m"); err != nil { + t.Fatal(err) + } + if rows := dshPatchRows(t, path); rows["llm-pi-ai"] == nil || rows["agent-default-model"] == nil { + t.Fatalf("rows = %v, want llm-pi-ai and agent-default-model", rows) + } + written, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(string(written), strings.TrimSuffix(patch, "[]\n")+"- id: ") { + t.Errorf("patch was not written as a block list under the header:\n%s", written) + } + data, err := os.ReadFile(credentialsPath) + if err != nil { + t.Fatal(err) + } + var document struct { + Version int `yaml:"version"` + Refs map[string]string `yaml:"refs"` + Records map[string]map[string]any `yaml:"records"` + } + if err := yaml.Unmarshal(data, &document); err != nil { + t.Fatal(err) + } + if document.Version != 1 || document.Refs["BOOTAGENT_API_KEY"] != "sk-new" || document.Records["client-connection/browser-session"] == nil { + t.Fatalf("credentials after write:\n%s", data) + } +} + // The rest of the file is rewritten with the indentation dsh itself uses, so a // write changes only the rows it touched. A row the write never addresses // comes out byte-for-byte as it went in. diff --git a/internal/config/write.go b/internal/config/write.go index 0d683276..2dd49177 100644 --- a/internal/config/write.go +++ b/internal/config/write.go @@ -689,7 +689,13 @@ func (w Writer) writeDSHCredential(ctx context.Context, path, reference, apiKey if version == nil || version.Value != "1" { return configError("DeepSeek Harness credentials must use version: 1: %s", path) } - if refs == nil || refs.Kind != yaml.MappingNode { + // A fresh desktop install writes version and records but no refs until + // the user saves a key of their own. + if existing := yamlLookup(root.Content[0], "refs"); existing == nil || existing.Tag == "!!null" { + refs = &yaml.Node{Kind: yaml.MappingNode} + yamlReplace(root.Content[0], "refs", refs) + } + if refs == nil { return configError("DeepSeek Harness credentials refs must be an object: %s", path) } }