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/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/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/config/discovery.go b/internal/config/discovery.go index 669ba8ec..8bf395c9 100644 --- a/internal/config/discovery.go +++ b/internal/config/discovery.go @@ -193,26 +193,57 @@ 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 { + // 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 || 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 配置无法解析") + } + } + } + } 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 +255,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..7c7b14ed --- /dev/null +++ b/internal/config/dsh_profile.go @@ -0,0 +1,267 @@ +package config + +import ( + "bytes" + "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 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 + } + 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 := encodeDSHProfilePatch(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) + // Already validated by WriteDSHOfficial, before the credential was written. + if reasoningEffort != "" { + yamlSet(selection, "reasoningEffort", reasoningEffort) + } + yamlReplace(dshPatchRow(root.Content[0], dshDefaultModelEntryID, dshDefaultModelEntryName), "config", selection) + + 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 { + 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) { + // 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 { + 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) + } + // 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 +} + +// 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..ab3e3004 --- /dev/null +++ b/internal/config/dsh_profile_test.go @@ -0,0 +1,500 @@ +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 TestResolveDSHConfigPathFollowsWhatEachProfileReads(t *testing.T) { + home := t.TempDir() + legacy := filepath.Join(home, ".dsh", DSHLegacySettings) + 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 web profile resolves to %q, want %q", got, legacy) + } + // 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, DSHWebProfile); got != webPatch { + t.Fatalf("web profile resolves to %q, want %q", got, webPatch) + } +} + +// 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"]) + } +} + +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) + } + // 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) + } +} + +// 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. +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) { + 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 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") + 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..5bd467fc 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 @@ -609,11 +612,22 @@ 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. - if err := w.writeDSHCredential(ctx, filepath.Join(filepath.Dir(path), ".credentials.yaml"), dshOfficialCredential, apiKey); err != nil { + // 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 } + if dshUsesProfilePatch(path) { + return w.writeDSHProfileOfficial(ctx, path, model, reasoningEffort) + } root, err := yamlDocument(path, "DeepSeek Harness settings") if err != nil { return err @@ -630,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) @@ -678,6 +689,12 @@ 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) } + // 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 || refs.Kind != yaml.MappingNode { return configError("DeepSeek Harness credentials refs must be an object: %s", path) } 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) 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..be6680a7 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,494 @@ import ( "os" "path/filepath" "strings" + + "github.com/MaimoryLab/BootAgent/internal/platform" + "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 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, + // 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"` +} + +// 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" { + return "mac-arm64" } - return approvedDownloadURL(url, "www.dshdesktop.cn") + case "windows": + return "win-x64" } - request, err := http.NewRequestWithContext(ctx, http.MethodGet, DSHDesktopReleaseAPI, nil) + 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) +} + +// 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 +} + +// 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 dshTarget(info.OS, info.Arch) { + case "mac-arm64": + status.Supported, status.Source = true, SourceMacOSZIP + case "win-x64": + 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) + 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(options.Platform) + 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(options.Platform) + 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(options.Platform) + 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 +507,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..e65b5bde --- /dev/null +++ b/internal/desktopapp/dsh_test.go @@ -0,0 +1,375 @@ +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, 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}, + } { + 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"}, {"linux", "amd64"}, {"linux", "arm64"}, + } { + 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) + } + } +} + +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) + } +} + +// 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: dshWindowsSignature}, + }} + 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) + } +} + +// 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")} { + 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: 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 { + 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) + } +} 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 f7634531..00000000 Binary files a/internal/desktopapp/testdata/dsh-fixture.dmg and /dev/null differ diff --git a/internal/desktopapp/testdata/dsh-fixture.zip b/internal/desktopapp/testdata/dsh-fixture.zip new file mode 100644 index 00000000..5ac4f495 Binary files /dev/null and b/internal/desktopapp/testdata/dsh-fixture.zip differ diff --git a/internal/desktopapp/windows_verify.go b/internal/desktopapp/windows_verify.go index 8b380e07..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"` } @@ -34,22 +33,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 @@ -97,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 { @@ -118,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 = "" @@ -126,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) + } + } +} 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 } }