From d9d2a173819ce8e2e7f09eb584ec6ff7b4ee3480 Mon Sep 17 00:00:00 2001 From: BaaRai <130491490+BaaRai@users.noreply.github.com> Date: Mon, 12 Oct 2026 05:06:26 +0200 Subject: [PATCH] feat(setup): register DeepSeek Harness through setup, doctor and uninstall (#1929) * feat(setup): register DeepSeek Harness through setup, doctor and uninstall DeepSeek Harness registers MCP clients through a Cordis YAML patch sequence in the home patch rather than a server table, so setup could not configure it and users had to append the documented entry by hand. Add a dsh client: the home patch $DSH_HOME/cordis.patch.yml (~/.dsh/cordis.patch.yml when unset) is parsed as a patch list, the REA entry owns the stable rea-mcp id, and setup appends or replaces exactly that entry while preserving unrelated patches and comments in place. Doctor reports the registration through the same entry, and uninstall removes it, dropping insert operations it emptied. Skill destinations follow ~/.agents/skills ($DSH_AGENTS_HOME/skills when configured), the home the rest of the client machinery already uses. Fixes #1839 * fix(setup): carry environment and identity validation into DSH registrations Address review: the DSH entry discarded discovered provider overrides; config.env is now emitted and retained like the other dialects. An entry carrying the rea-mcp id under a wrong or missing package name, or duplicated entries, reads back as absent so setup rewrites exactly one canonical entry instead of reporting unchanged. Doctor now requires serverName rea and stdio transport before calling a DSH registration aligned, and the Harness skill identifier in the docs matches the installed reverse-engineer-anything skill. * fix(setup): apply Cordis patch order and collapse DSH registrations Address review: a later direct { id, config } operation replaces the row's config under the native patch DSL, so the registration view now applies patches in order instead of only reading insert operations; doctor reports the effective command rather than the inserted one. Setup rewrites exactly one canonical entry: duplicate id-matching rows collapse to the first, direct patches to the entry are dropped from the document and from the edited YAML, and uninstall removes them with the entry. The client-location test fixture now clears DSH_HOME and DSH_AGENTS_HOME so a developer environment cannot point the suite at real Harness homes. * fix(setup): repair direct-only registrations and preserve Cordis entry state Serialize now edits the first insertion carrying rea-mcp in place, so a grouped entry keeps its scope, neighbors, and comments instead of moving to a fresh root-level insertion, and later carrying insertions and direct patches collapse away. Entry-level disabled state projects into the registration the doctor and setup compare, so a disabled row reads as stale and the repair re-enables it. A direct-only registration no longer suppresses the canonical insert append, and an update retains the registration environment. Duplicate entries collapse across operations, not only within one. * fix(setup): preserve Harness row state through identity and refresh repairs A wrong-package row still reads back as a server record carrying its environment and caller fields, projected with a package marker the alignment path treats as stale, instead of vanishing and dropping config.env from the repair. A direct Cordis patch without a preceding insertion configures nothing, so a direct-only registration reads as absent even when its config matches and the repair appends the insert. Caller-owned config fields beyond the REA-owned keys, such as the cwd the native verifier sets, survive a refresh instead of being silently dropped. * fix(setup): preserve Cordis tags and collapse aligned Harness rows A refresh rebuilt Harness config from parsed values, so a caller !!js expression was written back as a literal. Owned keys are now overlaid on the winning config node and other nodes stay in place. Two already-aligned rea-mcp insertions read as stale, so setup collapses them to one row, and the bundled skill lists dsh with the other clients. * fix(setup): drop the unused DSH configuration type Knip reports DshConfigurationFormat as an unused export. The format union does not depend on that alias. * fix(setup): keep Harness env tags and comment-only patches A refresh replaced the whole env map, so a caller !!js value was written back as a literal. Environment entries now stay in place unless the value changed. A comment-only cordis.patch.yml parses as an empty patch list and keeps those comments when the registration is appended. Docs describe plain rea uninstall, which scans supported clients and has no --client flag. * fix(setup): own Harness rows by entry id and expression state Uninstall removes a rea-mcp row even when the launcher is stale or the patch never established a server record. A non-boolean disabled value, including a Cordis expression, reads as stale. Expression text on a known environment setting is not validated as a literal path, so a refresh can keep the tag. * fix(setup): collapse aliased Harness insertions An anchored insert followed by an alias parses as two rea-mcp rows, but the YAML edit kept the alias, so readback stayed duplicated and setup restored the file. Aliases that resolve to the owned entry are now dropped, including an alias of the entry inside one insert. Aliases of other patches stay. * fix(setup): resolve aliased Harness lists and config maps An insert value that aliases another entry list still loaded a second rea-mcp row after the operation-level alias collapse. That alias is now dropped once the anchored list is rewritten. A config: *cfg map is resolved and cloned before the refresh, so a caller !!js field on the aliased map is not rewritten as a plain literal. * fix(setup): follow Harness aliases for ids, lists, and env tags An insert alias whose anchor is not another insert is copied so unrelated rows stay while the owned row is rewritten. An aliased entry id is resolved before ownership checks, and an environment alias of an unchanged !!js scalar is left in place. * fix(setup): keep aliased Harness env maps through uninstall An env alias is cloned before a refresh so tagged values are not rebuilt as literals, and expression-key scans follow aliases before path checks. Insert aliases that carry REA are recorded before uninstall empties their anchor, so the later alias is dropped instead of dangling. * fix(setup): follow root Harness aliases and mark dsh as main-only Winning-config walks resolve a root alias before patch order, so a replayed direct config's !!js env is not validated as a literal path. Root aliases that own rea-mcp are recorded before uninstall empties their anchor. An insert whose only owned member is an external alias is cloned so Cordis tags survive. Docs state dsh is not in npm 6.4.0. * fix(setup): keep rows behind inert Harness aliases A root alias whose anchor is not a loaded operation is copied and only the owned row is edited, so unrelated rows stay active. Entry id aliases become a scalar before their anchor patch is dropped. Root-alias ownership follows insert-list aliases, so a replay is removed instead of dangling after uninstall. * refactor(setup): centralize Cordis source ownership * fix(javascript): share Electron mutation proof with wrapped calls * test(mcp): align graph and catalog checks with current contracts * fix(setup): separate Cordis ownership from editability * docs: clarify ownership and interpretation boundaries * fix(setup): preserve Cordis applicability and caller metadata --------- Co-authored-by: Cursor Agent Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- .../references/connection-and-recovery.md | 2 +- docs/installation.md | 44 +- docs/testing.md | 16 +- scripts/verify-deepseek-client.mjs | 120 +- .../ClientConfigurationDocument.ts | 20 + .../ClientRegistrationEnvironment.ts | 3 + src/application/ClientRegistrationStatus.ts | 15 + src/application/DshCordisPatch.ts | 375 +++ src/application/DshCordisYaml.ts | 175 ++ src/application/SetupClientConfiguration.ts | 20 +- src/application/SupportedClients.ts | 18 + src/application/Uninstall.ts | 12 +- tests/acceptance/setup/dshClientSetup.test.ts | 232 ++ .../filesystem/clientLifecycle.test.ts | 1 + .../filesystem/dshClientConfiguration.test.ts | 2062 +++++++++++++++++ tests/fixtures/clientEnvironment.ts | 2 + 16 files changed, 3065 insertions(+), 52 deletions(-) create mode 100644 src/application/DshCordisPatch.ts create mode 100644 src/application/DshCordisYaml.ts create mode 100644 tests/acceptance/setup/dshClientSetup.test.ts create mode 100644 tests/boundary/filesystem/dshClientConfiguration.test.ts diff --git a/.agents/skills/reverse-engineer-anything/references/connection-and-recovery.md b/.agents/skills/reverse-engineer-anything/references/connection-and-recovery.md index 515a6c9d4..9b06a93fc 100644 --- a/.agents/skills/reverse-engineer-anything/references/connection-and-recovery.md +++ b/.agents/skills/reverse-engineer-anything/references/connection-and-recovery.md @@ -46,7 +46,7 @@ rea setup --client codex --dry-run --json Use the current supported client's ID: `claude_code`, `claude_desktop`, `codex`, `cursor`, `gemini_cli`, `windsurf`, `devin`, `opencode`, `antigravity`, `copilot_cli`, `commandcode`, `qwen_code`, `vscode`, `grok_build`, `omp`, `pi`, -`hermes`, or `grok_bot`. If unknown, inspect `doctor --json` before choosing a +`hermes`, `dsh`, or `grok_bot`. If unknown, inspect `doctor --json` before choosing a scope. `grok_bot` uses a chat registration step rather than local `mcp.json`. For unsupported clients, use version-pinned manual stdio registration or CLI. diff --git a/docs/installation.md b/docs/installation.md index 720f1ae45..3c12d299b 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -89,8 +89,11 @@ running server or its registration. The npm release checked on **2026-10-11** was **6.4.0**, published from [`a56f7bb6`](https://github.com/morluto/rea/commit/a56f7bb668ab23fec8d33f7af353ac7ed2fe1895). -The actual registry package accepted dry-run setup plans for all 17 client IDs -listed below, including Qwen Code, Pi and Hermes. OpenCode's plan selected +The actual registry package accepted dry-run setup plans for the 17 client IDs +in that release, including Qwen Code, Pi and Hermes. DeepSeek Harness (`dsh`) +is on main and is not in npm 6.4.0; `rea setup --client dsh` against `@latest` +reports an unsupported client until a release containing it is published. +OpenCode's plan selected `OPENCODE_CONFIG_DIR` even when that directory did not yet exist. These checks used an isolated home on macOS with Node 24; they verify planning and argument admission, without writing client configuration or establishing live integration @@ -162,6 +165,10 @@ listed after the table because its connector is not one of these files: | OMP | `omp` | | Pi | `pi` | | Hermes | `hermes` | +| DeepSeek Harness | `dsh` | + +`dsh` is main-only until a release after 6.4.0. The registry check above does +not cover it. For GitHub Copilot CLI 1.0.95, `gpt-4.1` model metadata can block ordinary chat with REA's full input-schema profile before sending a model request @@ -259,11 +266,15 @@ configuration and skill destinations; doctor and uninstall use those same paths. ### DeepSeek Harness -DeepSeek Harness uses a Cordis YAML patch sequence rather than a `mcpServers` -object. REA's `setup --client` does not edit this format. For Harness -`0.2.0-rc.2`, append the following entry to `$DSH_HOME/cordis.patch.yml` -(`~/.dsh/cordis.patch.yml` when `DSH_HOME` is unset). Preserve existing patches -and replace the executable paths with absolute paths from your installation: +DeepSeek Harness registers MCP clients through a Cordis YAML patch sequence +rather than a `mcpServers` object. `rea setup --client dsh` appends (or +replaces) the REA entry in the home patch `$DSH_HOME/cordis.patch.yml` +(`~/.dsh/cordis.patch.yml` when `DSH_HOME` is unset), preserving unrelated +patches and comments. `rea doctor --client dsh` checks that same entry id. +`rea uninstall` removes it by scanning supported clients; uninstall takes no +`--client` flag. The home patch applies to every Harness profile, so the +registration is written once and no per-profile edit is needed. The written +entry is: ```yaml - insert: @@ -274,17 +285,20 @@ and replace the executable paths with absolute paths from your installation: transport: stdio command: /absolute/path/to/node args: ["/absolute/path/to/rea/scripts/rea.mjs", mcp] - cwd: !!js process.cwd() ``` +Setup preserves caller-owned Cordis expressions and replaces launcher fields +with literal values that doctor can verify. It refuses to edit an expression +that produces the entire REA config, or a group-targeted REA insertion. Use a +literal config and a root-level insertion as shown above. + For a global npm installation, the launcher is under -`$(npm root -g)/rea-agents/scripts/rea.mjs`. Check the Node executable with -`command -v node`. Register `serverName: rea` only once in each active profile, -then restart Harness. It exposes tools as `mcp__rea__`. -Run `rea setup --skill --dry-run`, review the destination, then -`rea setup --skill --yes` to install the workflow in `~/.agents/skills`. -Harness discovers that directory, or `$DSH_AGENTS_HOME/skills` when configured; -its native `skill` tool can load `reverse-engineer-anything`. +`$(npm root -g)/rea-agents/scripts/rea.mjs`. Restart Harness after setup; it +exposes tools as `mcp__rea__`. Run `rea setup --skill --dry-run`, +review the destination, then `rea setup --skill --yes` to install the workflow +in `~/.agents/skills` (`$DSH_AGENTS_HOME/skills` when configured); Harness +discovers that directory, and its native `skill` tool can load +`reverse-engineer-anything`. REA's optional [native client verification](testing.md#end-to-end-integration-and-golden-evidence) checks headless chat, skill loading, all tool input schemas and actual analysis diff --git a/docs/testing.md b/docs/testing.md index e62d91185..92cf8dc44 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -272,9 +272,13 @@ unverified; skill discovery uses an explicit isolated custom directory. `verify:deepseek-client` requires an installed DeepSeek Harness (`dsh`; verified with `@deepseek-ai/dsh@0.2.0-rc.2`) and Git. Set `REA_VERIFY_DEEPSEEK_COMMAND` to select its executable. This optional POSIX lane -installs the skill through `rea setup --skill`, writes the native Cordis MCP -patch into an isolated `DSH_HOME`, and binds `HOME` and `USERPROFILE` to the -disposable account. The native default `~/.agents/skills` search path discovers +seeds caller model settings and a stale Cordis registration in an isolated +`DSH_HOME`, then runs public `setup --client dsh` dry-run, setup, repeated setup, +and scoped doctor. It checks the backup and unrelated patches before booting +Harness with the generated registration. After native execution it uninstalls +through the public CLI and checks that only caller patches remain. `HOME` and +`USERPROFILE` point to the disposable account. The native default +`~/.agents/skills` search path discovers the installed skill without a `DSH_AGENTS_HOME` override. A separate Git root prevents inherited project skills from masking a missing installation. The default `call` mode checks skill loading, complete catalog discovery, the actual forwarded input schemas and regexes, @@ -285,6 +289,12 @@ context capacity declared by its default DeepSeek Flash model; it does not establish live DeepSeek API or native Windows coverage. Receipts include request artifacts, result digests and owned-process lineage. +The DSH acceptance journey also exercises public setup, approval, doctor, +generated-launcher MCP startup and uninstall without an installed Harness. +That journey proves REA workflow behavior, not native Cordis expression +evaluation. Filesystem regressions retain tagged values and unrelated rows +across alias dependencies and check refusal of unsafe YAML without mutation. + Full E2E tests invoke the production command dispatcher and real providers, without fake launchers, runners or responses. `verify:keyed-archive` writes Foundation binary, XML, and integer archives using one run-owned Swift module diff --git a/scripts/verify-deepseek-client.mjs b/scripts/verify-deepseek-client.mjs index 12aed2a1e..42aae272b 100644 --- a/scripts/verify-deepseek-client.mjs +++ b/scripts/verify-deepseek-client.mjs @@ -5,11 +5,12 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { Ajv2020 } from "ajv/dist/2020.js"; +import { parse as parseYaml } from "yaml"; import { TOOL_CONTRACTS, toolContract, } from "../dist/contracts/toolContracts.js"; -import { exec } from "./lib/verify-package-core.mjs"; +import { exec, pathExists } from "./lib/verify-package-core.mjs"; import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; import { createOpenAiModelFixture } from "./verify/clients/model-fixture.mjs"; @@ -32,6 +33,7 @@ const skillPath = join( "SKILL.md", ); const cli = join(runtimeRoot, "scripts", "rea.mjs"); +const configPath = join(profile, "cordis.patch.yml"); const verifier = createVerifierRun(); await Promise.all([ mkdir(account), @@ -66,32 +68,32 @@ const clientVersion = ( timeout: 30_000, }) ).stdout.trim(); -for (const args of [ - ["setup", "--skill", "--dry-run"], - ["setup", "--skill", "--yes"], -]) { +const lifecycle = []; +const runRea = async (args, artifact) => { const result = await exec(process.execPath, [cli, ...args, "--json"], { env: environment, cwd: workspace, timeout: 60_000, }); - const parsed = JSON.parse(result.stdout); - assert.equal(parsed.status, args.includes("--dry-run") ? "planned" : "ready"); - if (args.includes("--dry-run")) { - assert.equal(parsed.plannedActions.length, 1); - assert.equal(parsed.plannedActions[0].kind, "install_skill"); - assert.equal(parsed.plannedActions[0].target, dirname(skillPath)); - } - await writeFile( - join(lab, args.includes("--dry-run") ? "setup-plan.json" : "setup.json"), - result.stdout, - ); -} -assert( - (await readFile(skillPath, "utf8")).includes( - "name: reverse-engineer-anything", - ), -); + await writeFile(join(lab, `${artifact}.json`), result.stdout); + return JSON.parse(result.stdout); +}; +const patches = (text) => + parseYaml(text, { + customTags: [ + { + tag: "tag:yaml.org,2002:js", + resolve: (source) => ({ __jsExpr: source }), + }, + ], + }); +const withoutRea = (text) => + patches(text).flatMap((op) => { + if (op.id === "rea-mcp") return []; + if (!Array.isArray(op.insert)) return [op]; + const insert = op.insert.filter((entry) => entry.id !== "rea-mcp"); + return insert.length === 0 ? [] : [{ ...op, insert }]; + }); let catalog; let patternCount = 0; @@ -247,10 +249,9 @@ const fixture = await createOpenAiModelFixture({ }); let failure; try { - // Use the client's native Cordis patch DSL; generic `rea setup --client` does not edit it. - await writeFile( - join(profile, "cordis.patch.yml"), - ` + // Seed model settings and a stale registration; the public setup workflow + // must produce the registration this native client actually boots. + const original = ` - id: llm-pi-ai config: providers: @@ -275,13 +276,59 @@ try { config: serverName: rea transport: stdio - command: ${JSON.stringify(process.execPath)} - args: ${JSON.stringify([cli, "mcp"])} + command: stale-launcher + args: [mcp] cwd: !!js process.cwd() env: REA_PROCESS_RUN_ID: ${JSON.stringify(verifier.run_id)} -`, +`; + await writeFile(configPath, original); + const planned = await runRea( + ["setup", "--client", "dsh", "--dry-run"], + "setup-plan", ); + assert.equal(planned.status, "planned"); + assert(planned.plannedActions.some((action) => action.target === configPath)); + assert( + planned.plannedActions.some( + (action) => action.target === dirname(skillPath), + ), + ); + assert( + planned.plannedActions.every( + (action) => + action.target === configPath || action.target === dirname(skillPath), + ), + ); + assert.equal(await readFile(configPath, "utf8"), original); + assert.equal(await pathExists(`${configPath}.rea.backup`), false); + lifecycle.push("dry-run"); + const setup = await runRea(["setup", "--client", "dsh", "--yes"], "setup"); + assert.equal(setup.status, "ready"); + assert.equal(await readFile(`${configPath}.rea.backup`, "utf8"), original); + const configured = await readFile(configPath, "utf8"); + assert.deepEqual(withoutRea(configured), withoutRea(original)); + assert(configured.includes("!!js process.cwd()")); + assert( + (await readFile(skillPath, "utf8")).includes( + "name: reverse-engineer-anything", + ), + ); + lifecycle.push("setup"); + const repeated = await runRea( + ["setup", "--client", "dsh", "--yes"], + "setup-repeat", + ); + assert.deepEqual(repeated.appliedActions, []); + assert.equal(await readFile(configPath, "utf8"), configured); + assert.equal(await readFile(`${configPath}.rea.backup`, "utf8"), original); + lifecycle.push("idempotent-setup"); + const doctor = await runRea( + ["doctor", "--client", "dsh", "--skill"], + "doctor", + ); + assert.equal(doctor.healthy, true); + lifecycle.push("doctor"); const execution = exec( command, [ @@ -304,6 +351,20 @@ try { assert.equal(fixture.failure, undefined); assert(finalSeen && result.stdout.includes("REA_CLIENT_COMPATIBILITY_OK")); if (mode === "call") assert(evidenceSeen && skillLoaded); + lifecycle.push(mode === "call" ? "native-tool-invocation" : "native-chat"); + const uninstall = await runRea(["uninstall"], "uninstall"); + assert.equal(uninstall.status, "complete"); + assert( + uninstall.items.some( + (item) => item.name === "dsh" && item.status === "removed", + ), + ); + assert.deepEqual( + patches(await readFile(configPath, "utf8")), + withoutRea(original), + ); + assert.equal(await pathExists(skillPath), false); + lifecycle.push("uninstall"); } catch (cause) { failure = cause; if (typeof cause?.stdout === "string") @@ -316,6 +377,7 @@ try { client: "deepseek-harness", clientVersion, mode, + lifecycle, passed: failure === undefined, artifacts: lab, runtimeRoot, diff --git a/src/application/ClientConfigurationDocument.ts b/src/application/ClientConfigurationDocument.ts index 4ad037f07..65b4ba8de 100644 --- a/src/application/ClientConfigurationDocument.ts +++ b/src/application/ClientConfigurationDocument.ts @@ -5,6 +5,13 @@ import { stringify as stringifyYaml, } from "yaml"; import { isDeepStrictEqual } from "node:util"; + +import { + parseDshCordisDocument, + serializeDshDocument, + editDshDocument, + dshClientRegistrationEntry, +} from "./DshCordisPatch.js"; import { applyEdits, findNodeAtLocation, @@ -131,6 +138,8 @@ export const parseClientConfiguration = ( ): ClientConfigurationDocument => { if (format === undefined || format === "unsupported") throw new TypeError("client does not have a supported MCP config format"); + // DeepSeek Harness stores a Cordis patch list, not a server table. + if (format === "dsh") return parseDshCordisDocument(text); const document = parseDocument(text, format); const serversKey = clientConfigurationServersKey(format); const value = document[serversKey]; @@ -184,6 +193,14 @@ export const withClientServers = ( servers: Record, removeLegacy?: string, ): Record => { + // The Harness patch list upserts or removes by entry id, not by table. + if (parsed.dialect === "dsh") + return Object.hasOwn(servers, PRODUCT_IDENTITY.mcpServerKey) + ? editDshDocument( + parsed, + objectSchema.parse(servers[PRODUCT_IDENTITY.mcpServerKey]), + ) + : editDshDocument(parsed); const [key = "", nested] = parsed.serversPath; if (nested === undefined) return { ...parsed.document, [key]: servers }; const outer = { ...objectSchema.parse(parsed.document[key]) }; @@ -1280,6 +1297,7 @@ export const serializeClientConfiguration = ( return serializeGrokConfiguration(document, originalText); if (format === "hermes") return serializeHermesConfiguration(document, originalText); + if (format === "dsh") return serializeDshDocument(document); // An empty original holds no comments or settings to preserve. Writing a // fresh document also keeps a lone BOM from shifting past the closing brace. if ( @@ -1398,6 +1416,8 @@ export const clientRegistrationEntry = ( args, ...(Object.keys(environment).length === 0 ? {} : { env: environment }), }; + case "dsh": + return dshClientRegistrationEntry(command, environment); case "hermes": // Hermes reads stdio servers from `mcp_servers.` and connects them // unless `enabled` is false, so the entry states it explicitly. diff --git a/src/application/ClientRegistrationEnvironment.ts b/src/application/ClientRegistrationEnvironment.ts index 6048c2de1..86e95404d 100644 --- a/src/application/ClientRegistrationEnvironment.ts +++ b/src/application/ClientRegistrationEnvironment.ts @@ -9,12 +9,15 @@ import { } from "./ClientConfigurationDocument.js"; import { PRODUCT_IDENTITY } from "../identity.js"; import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; +import { dshDocument } from "./DshCordisPatch.js"; /** Read explicit REA overrides without dropping peers when one value is invalid. */ export const clientRegistrationEnvironment = ( parsed: ClientConfigurationDocument | undefined, overrides: Readonly> = {}, ): Result>, ConfigurationError> => { + if (parsed?.dialect === "dsh") + return dshDocument(parsed.document).environment(overrides); const key = parsed?.dialect === "opencode" || parsed?.dialect === "opencode_v2" ? "environment" diff --git a/src/application/ClientRegistrationStatus.ts b/src/application/ClientRegistrationStatus.ts index 3b649c132..588b0f9af 100644 --- a/src/application/ClientRegistrationStatus.ts +++ b/src/application/ClientRegistrationStatus.ts @@ -26,6 +26,7 @@ import { } from "./SupportedClients.js"; import type { SetupClient } from "./SupportedClients.js"; import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; +import { dshDocument } from "./DshCordisPatch.js"; import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; interface ClientRegistrationStatusBase { @@ -66,6 +67,7 @@ const registrationSchema = z startup_timeout_sec: z.number().positive().optional(), type: z.string().optional(), transport: z.string().optional(), + serverName: z.string().optional(), tools: z.array(z.string()).optional(), disabled: z.boolean().optional(), enabled: z.boolean().optional(), @@ -178,6 +180,7 @@ export const readClientRegistrationStatuses = async ( parsed, PRODUCT_IDENTITY.mcpServerKey, ) && + (parsed.dialect !== "dsh" || dshDocument(parsed.document).ready) && policyBlock === undefined ? "aligned" : "stale", @@ -221,6 +224,13 @@ const registrationAligned = ( ) return false; if (client.format === "vscode" && registration.type !== "stdio") return false; + // A Harness registration only connects as the rea stdio server; another + // server name or transport leaves the session without REA tools. + if ( + client.format === "dsh" && + (registration.serverName !== "rea" || registration.transport !== "stdio") + ) + return false; // OMP and Pi infer stdio for a command entry without an explicit type. if ( (client.format === "omp" || client.format === "pi") && @@ -264,6 +274,11 @@ const parseRegistration = ( value: unknown, client: SetupClient, ): z.output => { + if (client.format === "dsh") + return registrationSchema + .pick({ command: true, args: true, serverName: true, transport: true }) + .strip() + .parse(value); if (client.format === "opencode") { const entry = z .object({ diff --git a/src/application/DshCordisPatch.ts b/src/application/DshCordisPatch.ts new file mode 100644 index 000000000..81f7428fb --- /dev/null +++ b/src/application/DshCordisPatch.ts @@ -0,0 +1,375 @@ +import { isMap, isSeq, type Node, type YAMLMap } from "yaml"; +import { parseEnvironment } from "../config/environment.js"; +import { ConfigurationError } from "../domain/configurationErrors.js"; +import { err, ok, type Result } from "../domain/result.js"; +import type { ClientConfigurationDocument } from "./ClientConfigurationDocument.js"; +import { CordisExpression, DshCordisYaml } from "./DshCordisYaml.js"; + +export const DSH_ENTRY_ID = "rea-mcp"; +export const DSH_CLIENT_PACKAGE = "@deepseek-ai/dsh-mcp-client"; +const LAUNCHER_KEYS = new Set(["serverName", "transport", "command", "args"]); +const isRecord = (value: unknown): value is Record => + typeof value === "object" && + value !== null && + !Array.isArray(value) && + !(value instanceof CordisExpression); + +interface PatchOperation { + readonly source: unknown; + readonly map: YAMLMap; + readonly ownedRows: readonly number[]; + readonly direct: boolean; +} + +/** One interpretation of identity, ordered patch applicability and source nodes. */ +export class DshCordisDocument { + readonly #yaml: DshCordisYaml; + readonly #operations: readonly PatchOperation[]; + readonly #fields = new Map(); + #entryNode: YAMLMap | undefined; + readonly #insertions: number; + #unknownSelector = false; + readonly config: Record | undefined; + + constructor(text: string) { + this.#yaml = new DshCordisYaml(text); + const root = this.#yaml.document.contents; + if (!isSeq(root)) + throw new ConfigurationError("Cordis patches must be a list"); + const operations: PatchOperation[] = []; + let insertions = 0; + for (const source of root.items) { + const map = this.#map(source); + if (map === undefined) continue; + const inserted = this.#yaml.resolve(this.#yaml.field(map, "insert")); + const targetId = this.#id(map); + const ownedRows = isSeq(inserted) + ? inserted.items.flatMap((entry, index) => + this.#id(entry) === DSH_ENTRY_ID ? [index] : [], + ) + : []; + const direct = !isSeq(inserted) && targetId === DSH_ENTRY_ID; + if (ownedRows.length > 0 && targetId) + throw new ConfigurationError( + "Cannot establish the target group for a Cordis REA insertion from the home patch alone", + ); + operations.push({ source, map, ownedRows, direct }); + if (isSeq(inserted)) { + for (const index of ownedRows) { + const entry = this.#map(inserted.items[index]); + if (entry === undefined) continue; + insertions += 1; + this.#entryNode = entry; + this.#fields.clear(); + this.#unknownSelector = false; + this.#readFields(entry); + } + } else if (direct) { + if (insertions === 0) continue; + // Cordis uses name as a selector, not a rename operation. + const selector = this.#yaml.value(this.#yaml.field(map, "name")); + if ( + selector instanceof CordisExpression || + (selector && this.#field("name") instanceof CordisExpression) + ) { + this.#unknownSelector = true; + continue; + } + if (selector && selector !== this.#field("name")) continue; + this.#readFields(map, new Set(["id", "name"])); + } + } + this.#operations = operations; + this.#insertions = insertions; + const config = this.#field("config"); + // Ownership survives malformed config so uninstall can remove the source row. + this.config = insertions === 0 ? undefined : isRecord(config) ? config : {}; + } + + #map(node: unknown): YAMLMap | undefined { + const map = this.#yaml.resolve(node); + return isMap(map) ? map : undefined; + } + #id(node: unknown): unknown { + const id = this.#yaml.value(this.#yaml.field(this.#map(node), "id")); + if (id instanceof CordisExpression) + throw new ConfigurationError( + "Expression-backed Cordis IDs have unknown ownership; use literal IDs before changing registrations", + ); + return id; + } + #field(key: string): unknown { + return this.#yaml.value(this.#fields.get(key)); + } + #readFields( + map: YAMLMap, + omitted: ReadonlySet = new Set(), + ): void { + for (const pair of map.items) { + const key = this.#yaml.value(pair.key); + if (typeof key === "string" && !omitted.has(key)) + this.#fields.set(key, pair.value); + } + } + get carriesEntry(): boolean { + return this.#operations.some((op) => op.direct || op.ownedRows.length > 0); + } + get ready(): boolean { + const disabled = this.#field("disabled"); + return ( + this.#insertions === 1 && + !this.#unknownSelector && + this.#field("name") === DSH_CLIENT_PACKAGE && + (disabled === undefined || disabled === false) + ); + } + + /** Validate literals without treating expression source as an environment value. */ + environment( + overrides: Readonly> = {}, + ): Result>, ConfigurationError> { + if (this.#unknownSelector) + return err( + new ConfigurationError( + "Expression-backed Cordis name selectors have unknown applicability; use literal selectors before setup", + { + settings: [ + { + setting: "name", + constraint: "Use literal selectors before setup", + }, + ], + }, + ), + ); + const config = this.#field("config"); + if (config instanceof CordisExpression) + return err( + new ConfigurationError( + "Expression-backed Cordis config cannot be safely edited", + { + settings: [ + { + setting: "config", + constraint: + "Use a literal mapping to preserve caller fields during setup", + }, + ], + }, + ), + ); + if (config !== undefined && !isRecord(config)) + return err( + new ConfigurationError("Cordis REA config must be a mapping", { + settings: [ + { + setting: "config", + constraint: "Use a literal mapping before setup", + }, + ], + }), + ); + const raw = this.config?.env; + if (raw !== undefined && !isRecord(raw)) + return err( + new ConfigurationError("Cordis server environment must be a mapping"), + ); + const literals: Record = {}; + for (const [key, value] of Object.entries(raw ?? {})) { + if (value instanceof CordisExpression) continue; + if (typeof value !== "string") + return err( + new ConfigurationError("Invalid Cordis server environment", { + settings: [ + { + setting: `env.${key}`, + constraint: + "Environment values must be strings or Cordis expressions", + }, + ], + }), + ); + literals[key] = value; + } + const environment = { ...literals, ...overrides }; + const validated = parseEnvironment(environment); + return validated.ok ? ok(environment) : validated; + } + + desired( + command: readonly string[], + overrides: Readonly>, + ): Result, ConfigurationError> { + const environment = this.environment(overrides); + if (!environment.ok) return environment; + const env = { + ...(isRecord(this.config?.env) ? this.config.env : {}), + ...overrides, + }; + const desired = { ...this.config, ...dshClientRegistrationEntry(command) }; + delete desired.env; + if (Object.keys(env).length > 0) desired.env = env; + return ok(desired); + } + + /** Plan every replacement from source snapshots before changing an output tree. */ + serialize(desired?: Record): string { + const root = this.#yaml.document.contents; + if (!isSeq(root)) + throw new ConfigurationError("Cordis patches must be a list"); + const replacements = new Map(); + const invalidated = new Set( + this.#operations.flatMap((op) => + op.direct || op.ownedRows.length > 0 + ? this.#yaml.anchors(op.source) + : [], + ), + ); + let carried = false; + for (const op of this.#operations) { + if (op.direct) { + replacements.set(op.source, undefined); + continue; + } + if (op.ownedRows.length === 0) continue; + const copy = this.#yaml.detach(op.map, invalidated); + if (!isMap(copy)) + throw new ConfigurationError("Cordis operation is not a mapping"); + const inserted = this.#yaml.detach( + this.#yaml.resolve(this.#yaml.field(op.map, "insert")), + invalidated, + ); + if (!isSeq(inserted)) + throw new ConfigurationError("Cordis insertion is not a list"); + copy.set("insert", inserted); + const owned = new Set(op.ownedRows); + inserted.items = inserted.items.flatMap((entry, index) => { + if (!owned.has(index)) return [entry]; + if (carried || desired === undefined) return []; + carried = true; + return [this.#entry(desired, invalidated)] as typeof inserted.items; + }); + replacements.set( + op.source, + inserted.items.length === 0 ? undefined : copy, + ); + } + const output = this.#yaml.document.clone(); + const outputRoot = output.contents; + if (!isSeq(outputRoot)) + throw new ConfigurationError("Cordis patches must be a list"); + outputRoot.items = root.items.flatMap((source) => { + const copy = replacements.has(source) + ? replacements.get(source) + : this.#yaml.copy(source, invalidated); + return copy === undefined ? [] : [copy]; + }) as typeof outputRoot.items; + if (!carried && desired !== undefined) + outputRoot.items.push( + output.createNode({ + insert: [ + { id: DSH_ENTRY_ID, name: DSH_CLIENT_PACKAGE, config: desired }, + ], + }) as never, + ); + return output.toString({ lineWidth: 0 }); + } + + #entry( + desired: Record, + invalidated: ReadonlySet, + ): YAMLMap { + const entry = this.#yaml.detach(this.#entryNode, invalidated); + if (!isMap(entry)) + throw new ConfigurationError("Cordis entry is not a mapping"); + this.#yaml.setValue(entry, "id", DSH_ENTRY_ID); + this.#yaml.setValue(entry, "name", DSH_CLIENT_PACKAGE); + entry.delete("disabled"); + for (const [key, value] of this.#fields) { + if (["id", "name", "config", "disabled"].includes(key)) continue; + entry.set(key, this.#yaml.detach(value, invalidated)); + } + const sourceConfig = this.#map(this.#fields.get("config")); + const config = + sourceConfig === undefined + ? this.#yaml.document.createNode({}) + : this.#yaml.detach(sourceConfig, invalidated); + if (!isMap(config)) + throw new ConfigurationError( + "Cordis registration config is not a mapping", + ); + // Caller nodes keep their tags and spelling; only literal launcher values change. + for (const key of LAUNCHER_KEYS) + this.#yaml.setValue(config, key, desired[key]); + const env = this.#map(this.#yaml.field(sourceConfig, "env")); + const desiredEnv = desired.env; + if (isRecord(desiredEnv)) { + const nextEnv = + env === undefined + ? this.#yaml.document.createNode({}) + : this.#yaml.detach(env, invalidated); + if (!isMap(nextEnv)) + throw new ConfigurationError("Cordis environment is not a mapping"); + for (const [key, value] of Object.entries(desiredEnv)) { + this.#yaml.setValue(nextEnv, key, value); + } + config.set("env", nextEnv); + } else config.delete("env"); + entry.set("config", config); + return entry; + } +} + +class DshCordisEdit { + constructor( + readonly source: DshCordisDocument, + readonly desired?: Record, + ) {} +} +export const dshDocument = ( + document: Record, +): DshCordisDocument => { + if (!(document.cordis instanceof DshCordisDocument)) + throw new ConfigurationError("Missing Cordis document owner"); + return document.cordis; +}; +export const parseDshCordisDocument = ( + text: string, +): ClientConfigurationDocument => { + const source = new DshCordisDocument(text); + return { + document: { cordis: source }, + servers: source.config === undefined ? {} : { rea: source.config }, + serversPath: [], + dialect: "dsh", + legacyServers: {}, + }; +}; +export const editDshDocument = ( + parsed: ClientConfigurationDocument, + desired?: Record, +): Record => ({ + cordis: new DshCordisEdit(dshDocument(parsed.document), desired), +}); +export const serializeDshDocument = ( + document: Record, +): string => { + if (!(document.cordis instanceof DshCordisEdit)) + throw new ConfigurationError("Missing Cordis edit plan"); + return document.cordis.source.serialize(document.cordis.desired); +}; +export const dshClientRegistrationEntry = ( + command: readonly string[], + environment: Readonly> = {}, +): Record => { + const [executable = "rea", ...args] = command; + return { + serverName: "rea", + transport: "stdio", + command: executable, + args, + ...(Object.keys(environment).length === 0 + ? {} + : { env: { ...environment } }), + }; +}; diff --git a/src/application/DshCordisYaml.ts b/src/application/DshCordisYaml.ts new file mode 100644 index 000000000..ea84e5e65 --- /dev/null +++ b/src/application/DshCordisYaml.ts @@ -0,0 +1,175 @@ +import { isDeepStrictEqual } from "node:util"; +import { + isAlias, + isMap, + isNode, + isScalar, + isSeq, + parseDocument, + visit, + type Document, + type Node, + type YAMLMap, +} from "yaml"; +import { ConfigurationError } from "../domain/configurationErrors.js"; + +/** Expression source is never a literal value and is never evaluated by REA. */ +export class CordisExpression { + readonly kind = "cordis-expression"; + constructor(readonly source: string) {} +} + +/** Immutable source graph; all alias resolution happens against this document. */ +export class DshCordisYaml { + readonly document: Document; + readonly #values = new Map(); + readonly #visiting = new Set(); + + constructor(text: string) { + this.document = parseDocument(text, { + customTags: [ + { tag: "tag:yaml.org,2002:js", resolve: (source: string) => source }, + ], + logLevel: "silent", + }); + if (this.document.errors.length > 0 || this.document.warnings.length > 0) + throw new ConfigurationError("Invalid or unsupported Cordis YAML", { + cause: this.document.errors[0] ?? this.document.warnings[0], + }); + if (this.document.contents === null) + this.document.contents = this.document.createNode([]); + if (!isSeq(this.document.contents)) + throw new ConfigurationError( + "Cordis patches must be a YAML operation list", + ); + this.value(this.document.contents); + try { + // Retain YAML's native alias-expansion admission before copying edits. + // This value is discarded; source nodes own every Cordis interpretation. + this.document.toJS(); + } catch (cause) { + throw new ConfigurationError("Unsafe Cordis YAML alias expansion", { + cause, + }); + } + } + + resolve(node: unknown): unknown { + if (!isAlias(node)) return node; + const resolved = node.resolve(this.document); + if (resolved === undefined) + throw new ConfigurationError("Cordis YAML contains an unresolved alias"); + return resolved; + } + + value(node: unknown): unknown { + const resolved = this.resolve(node); + if (!isNode(resolved)) return resolved; + if (this.#values.has(resolved)) return this.#values.get(resolved); + if (this.#visiting.has(resolved)) + throw new ConfigurationError( + "Cyclic Cordis YAML cannot be safely edited", + ); + this.#visiting.add(resolved); + let value: unknown; + if (isScalar(resolved)) + value = + resolved.tag === "tag:yaml.org,2002:js" + ? new CordisExpression(String(resolved.value)) + : resolved.value; + else if (isSeq(resolved)) + value = resolved.items.map((item) => this.value(item)); + else if (isMap(resolved)) { + const entries: [string, unknown][] = []; + for (const pair of resolved.items) { + entries.push([this.#key(pair.key), this.value(pair.value)]); + } + value = Object.fromEntries(entries); + } + this.#visiting.delete(resolved); + this.#values.set(resolved, value); + return value; + } + + #key(node: unknown): string { + const key = this.value(node); + if (typeof key === "object" && key !== null) + throw new ConfigurationError( + "Cordis YAML mapping keys must be literal scalars", + ); + // Match YAML's JS object projection while retaining the original key node. + return key === null ? "" : String(key); + } + + field(map: YAMLMap | undefined, key: string): unknown { + return map?.items.findLast((pair) => this.#key(pair.key) === key)?.value; + } + + /** Preserve unchanged nodes and carry their comments when replacing values. */ + setValue(map: YAMLMap, key: string, value: unknown): void { + const original = this.field(map, key); + if (isDeepStrictEqual(this.value(original), value)) return; + const replacement = this.document.createNode(value); + if (isNode(original) && isNode(replacement)) { + if (original.comment !== undefined) + replacement.comment = original.comment; + if (original.commentBefore !== undefined) + replacement.commentBefore = original.commentBefore; + } + map.set(key, replacement); + } + + /** Copy from the source before edits; materialized nodes own no anchors. */ + copy(node: unknown, invalidated?: ReadonlySet): unknown { + if (isAlias(node)) { + const target = this.resolve(node); + return invalidated === undefined || + (isNode(target) && invalidated.has(target)) + ? this.copy(target) + : node.clone(); + } + if (!isNode(node)) return node; + const copied = node.clone(); + if ( + (invalidated === undefined || invalidated.has(node)) && + (isScalar(copied) || isMap(copied) || isSeq(copied)) + ) + delete copied.anchor; + if (isSeq(node) && isSeq(copied)) + copied.items = node.items.map((item) => + this.copy(item, invalidated), + ) as typeof copied.items; + if (isMap(node) && isMap(copied)) + copied.items.forEach((pair, index) => { + pair.key = this.copy(node.items[index]!.key, invalidated); + pair.value = this.copy(node.items[index]!.value, invalidated); + }); + return copied; + } + + /** Replacing an anchor must not change untouched consumers of its old value. */ + detach(node: unknown, invalidated: ReadonlySet): unknown { + const copied = this.copy(node, invalidated); + if (isNode(copied)) + visit(copied, { + Pair: (_key, pair) => { + if (isAlias(pair.key)) pair.key = this.copy(this.resolve(pair.key)); + }, + Value: (_key, value) => { + delete value.anchor; + }, + }); + return copied; + } + + anchors(node: unknown): readonly Node[] { + if (!isNode(node)) return []; + const anchors: Node[] = []; + visit(node, { + Value: (_key, value) => { + if (value.anchor) anchors.push(value); + }, + }); + return anchors; + } +} diff --git a/src/application/SetupClientConfiguration.ts b/src/application/SetupClientConfiguration.ts index 3f18d5023..ac493282a 100644 --- a/src/application/SetupClientConfiguration.ts +++ b/src/application/SetupClientConfiguration.ts @@ -12,6 +12,7 @@ import { withClientServers, type ClientConfigurationDocument, } from "./ClientConfigurationDocument.js"; +import { dshDocument, DshCordisDocument } from "./DshCordisPatch.js"; import { constants as fsConstants } from "node:fs"; import { copyFile, mkdir, rm, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; @@ -89,7 +90,10 @@ const configureClientDocument = async ( let parsed: ClientConfigurationDocument; try { parsed = parseClientConfiguration( - original ?? (format === "toml" || format === "grok" ? "" : "{}"), + original ?? + (format === "toml" || format === "grok" || format === "dsh" + ? "" + : "{}"), format, ); const policy = await readClientPolicyBlock( @@ -396,6 +400,7 @@ const registrationCurrent = ( parsed: ClientConfigurationDocument, desired: unknown, ): boolean => + (parsed.dialect !== "dsh" || dshDocument(parsed.document).ready) && clientConfigurationValuesEqual( parsed.servers[PRODUCT_IDENTITY.mcpServerKey], desired, @@ -410,6 +415,17 @@ const clientConfigurationDesired = ( parsed?: ClientConfigurationDocument, ) => { const format = parsed?.dialect ?? client.format ?? "json"; + const launcher = + command.length === 0 ? [PRODUCT_IDENTITY.cliBinary, "mcp"] : command; + if (format === "dsh") { + const desired = ( + parsed === undefined + ? new DshCordisDocument("") + : dshDocument(parsed.document) + ).desired(launcher, providerEnvironment); + if (!desired.ok) throw desired.error; + return desired.value; + } // Preserve explicitly configured server settings, never the ambient process // environment. Newly discovered provider paths win only for their own keys. const environment = clientRegistrationEnvironment( @@ -419,7 +435,7 @@ const clientConfigurationDesired = ( if (!environment.ok) throw environment.error; const registration = clientRegistrationEntry( format, - command.length === 0 ? [PRODUCT_IDENTITY.cliBinary, "mcp"] : command, + launcher, environment.value, ); return { diff --git a/src/application/SupportedClients.ts b/src/application/SupportedClients.ts index c4b0bc6e9..b44e5017a 100644 --- a/src/application/SupportedClients.ts +++ b/src/application/SupportedClients.ts @@ -38,6 +38,7 @@ export interface SetupClient { | "omp" | "pi" | "hermes" + | "dsh" | "unsupported"; } @@ -129,6 +130,12 @@ const qwenCodeDirectory = ({ home, env }: ClientPathContext): string => { const grokDirectory = ({ home, env }: ClientPathContext): string => env.GROK_HOME || join(home, ".grok"); +const dshHomeDirectory = ({ home, env }: ClientPathContext): string => + env.DSH_HOME ?? join(home, ".dsh"); + +const dshPatchDirectory = (context: ClientPathContext): string => + join(dshHomeDirectory(context), "cordis.patch.yml"); + const hermesDirectory = ({ home, platform, env }: ClientPathContext): string => resolveHermesClientHome(home, platform, env); @@ -492,6 +499,17 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ markerPath: hermesDirectory, format: "hermes", }, + { + name: "dsh", + displayName: "DeepSeek Harness", + // The home Cordis patch applies to every profile; the skill directory is + // shared agent storage Harness discovers. + skillPath: ({ home, env }: ClientPathContext) => + join(env.DSH_AGENTS_HOME ?? join(home, ".agents"), "skills"), + configPath: dshPatchDirectory, + markerPath: dshHomeDirectory, + format: "dsh", + }, { name: "grok_bot", displayName: "Grok Bot", diff --git a/src/application/Uninstall.ts b/src/application/Uninstall.ts index abcb61ac3..d474419a8 100644 --- a/src/application/Uninstall.ts +++ b/src/application/Uninstall.ts @@ -22,6 +22,7 @@ import { skillDestinations, } from "./SetupSkill.js"; import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.js"; +import { dshDocument } from "./DshCordisPatch.js"; import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js"; import { readRegularFileText } from "./RegularFileRead.js"; import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; @@ -305,10 +306,15 @@ const removeClient = async ( // Beside a native OpenCode V2 table, OpenCode also loads a V1 `mcp.rea`. const legacyPath = legacyClientServerPath(parsed, name); const hasNative = Object.hasOwn(parsed.servers, name); - if (!hasNative && legacyPath === undefined) + // Harness ownership is the stable entry id. A stale or malformed launcher + // still belongs to that id, and so does a direct patch that projects no row. + const carriesDshEntry = + parsed.dialect === "dsh" && dshDocument(parsed.document).carriesEntry; + if (!hasNative && legacyPath === undefined && !carriesDshEntry) return item(client.name, "skipped", "REA registration is absent."); const removeNative = - hasNative && isOwnedRegistration(parsed.servers[name], client); + carriesDshEntry || + (hasNative && isOwnedRegistration(parsed.servers[name], client)); const removeLegacy = legacyPath !== undefined && isOwnedRegistration(parsed.legacyServers[name], client); @@ -356,6 +362,8 @@ const removeClient = async ( ); if ( (removeNative && Object.hasOwn(readback.servers, name)) || + (readback.dialect === "dsh" && + dshDocument(readback.document).carriesEntry) || (removeLegacy && Object.hasOwn(readback.legacyServers, name)) ) throw new Error("registration readback mismatch"); diff --git a/tests/acceptance/setup/dshClientSetup.test.ts b/tests/acceptance/setup/dshClientSetup.test.ts new file mode 100644 index 000000000..1ee935da6 --- /dev/null +++ b/tests/acceptance/setup/dshClientSetup.test.ts @@ -0,0 +1,232 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { expect } from "vitest"; +import { parse } from "yaml"; +import { z } from "zod"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest( + "DSH CLI previews, approves, repairs, launches MCP, repeats and uninstalls while preserving caller YAML", + async ({ cli }) => { + const home = await createTestTempDirectory("rea-dsh-lifecycle-"); + const profile = join(home, "selected harness"); + const config = join(profile, "cordis.patch.yml"); + const skill = join( + home, + ".agents/skills/reverse-engineer-anything/SKILL.md", + ); + await mkdir(profile); + const original = `# caller home patch +- insert: + - id: rea-mcp + name: '@deepseek-ai/dsh-mcp-client' + config: {serverName: rea, transport: stdio, command: stale, args: [mcp]} +- holder: &op + insert: + - id: unrelated + name: caller-plugin + disabled: true + config: {cwd: !!js process.cwd()} + - id: rea-mcp + name: '@deepseek-ai/dsh-mcp-client' + config: + serverName: rea + transport: stdio + command: stale + args: [mcp] + cwd: !!js process.cwd() + env: + REA_LOG_LEVEL: !!js process.env.REA_LOG_LEVEL +- *op +`; + await writeFile(config, original); + const environment = { HOME: home, USERPROFILE: home, DSH_HOME: profile }; + const run = (args: readonly string[]) => + cli.run({ + arguments: [...args, "--json"], + cwd: home, + environment, + timeoutMs: 20_000, + }); + const plan = await run(["setup", "--client", "dsh", "--dry-run"]); + expect(plan.exitCode).toBe(0); + expect(plan.json).toMatchObject({ + status: "planned", + appliedActions: [], + plannedActions: expect.arrayContaining([ + expect.objectContaining({ id: "configure_client:dsh", target: config }), + expect.objectContaining({ id: "install_skill" }), + ]), + }); + expect(await readFile(config, "utf8")).toBe(original); + await expect(readFile(`${config}.rea.backup`)).rejects.toMatchObject({ + code: "ENOENT", + }); + const unapproved = await run(["setup", "--client", "dsh"]); + expect(unapproved.json).toMatchObject({ + status: "needs_confirmation", + appliedActions: [], + }); + expect(await readFile(config, "utf8")).toBe(original); + const configured = await run(["setup", "--client", "dsh", "--yes"]); + expect(configured.exitCode).toBe(0); + expect(configured.json).toMatchObject({ + status: "ready", + clients: { dsh: { status: "configured" } }, + }); + const text = await readFile(config, "utf8"); + expect(text).toContain("# caller home patch"); + const patches = parse(text, { + customTags: [ + { + tag: "tag:yaml.org,2002:js", + resolve: (source: string) => ({ __jsExpr: source }), + }, + ], + }) as { insert?: { id: string; config: unknown }[] }[]; + const rows = patches.flatMap((patch) => patch.insert ?? []); + expect(rows.filter(({ id }) => id === "unrelated")).toEqual([ + { + id: "unrelated", + name: "caller-plugin", + disabled: true, + config: { cwd: { __jsExpr: "process.cwd()" } }, + }, + ]); + expect(rows.filter(({ id }) => id === "rea-mcp")).toHaveLength(1); + const registration = z + .object({ + command: z.string(), + args: z.array(z.string()), + cwd: z.unknown(), + env: z.unknown(), + }) + .parse(rows.find(({ id }) => id === "rea-mcp")?.config); + expect(registration.cwd).toEqual({ __jsExpr: "process.cwd()" }); + expect(registration.env).toMatchObject({ + REA_LOG_LEVEL: { __jsExpr: "process.env.REA_LOG_LEVEL" }, + }); + const doctor = await run(["doctor", "--client", "dsh", "--skill"]); + expect(doctor.exitCode).toBe(0); + expect(doctor.json).toMatchObject({ + healthy: true, + identity: { + registrations: expect.arrayContaining([ + expect.objectContaining({ client: "dsh", state: "aligned" }), + ]), + }, + }); + await verifyGeneratedLauncher(registration, home, environment); + const repeated = await run(["setup", "--client", "dsh", "--yes"]); + expect(repeated.json).toMatchObject({ + status: "ready", + appliedActions: [], + plannedActions: [], + }); + expect(await readFile(config, "utf8")).toBe(text); + expect(await readFile(`${config}.rea.backup`, "utf8")).toBe(original); + const removed = await run(["uninstall"]); + expect(removed.exitCode).toBe(0); + expect(removed.json).toMatchObject({ + status: "complete", + items: expect.arrayContaining([ + expect.objectContaining({ name: "dsh", status: "removed" }), + ]), + }); + const remaining = parse(await readFile(config, "utf8"), { + logLevel: "silent", + }) as { insert?: { id: string }[] }[]; + expect( + remaining.flatMap((patch) => patch.insert ?? []).map(({ id }) => id), + ).toEqual(["unrelated"]); + expect(await readFile(`${config}.rea.backup`, "utf8")).toBe(original); + await expect(readFile(skill)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); + +async function verifyGeneratedLauncher( + registration: { command: string; args: string[] }, + home: string, + environment: Record, +): Promise { + // Exercise the generated launcher. Native Cordis expression evaluation belongs + // to verify:deepseek-client, not this SDK transport journey. + const transport = new StdioClientTransport({ + command: registration.command, + args: registration.args, + cwd: home, + env: { PATH: process.env.PATH ?? "", ...environment }, + stderr: "pipe", + }); + const client = new Client({ name: "dsh-setup-lifecycle", version: "1" }); + try { + await client.connect(transport); + expect((await client.listTools()).tools.map(({ name }) => name)).toContain( + "binary_session", + ); + const session = await client.callTool({ + name: "binary_session", + arguments: {}, + }); + expect(session.isError).not.toBe(true); + expect(session.structuredContent).toMatchObject({ + result: { open: false }, + }); + } finally { + await client.close(); + await transport.close(); + } +} + +cliTest.for(["null", "caller-value", "[caller-value]"])( + "DSH CLI refuses to edit config %s and plain uninstall removes its owned row", + async (configValue, { cli }) => { + const home = await createTestTempDirectory("rea-dsh-malformed-config-"); + const profile = join(home, ".dsh"); + const config = join(profile, "cordis.patch.yml"); + await mkdir(profile); + const initial = `# caller patch +- insert: + - id: other + name: caller-plugin + - id: rea-mcp + name: '@deepseek-ai/dsh-mcp-client' + config: ${configValue} +`; + await writeFile(config, initial); + const run = (args: readonly string[]) => + cli.run({ + arguments: [...args, "--json"], + cwd: home, + environment: { HOME: home, USERPROFILE: home, DSH_HOME: profile }, + }); + const configured = await run(["setup", "--client", "dsh", "--yes"]); + expect(configured.exitCode).not.toBe(0); + expect(configured.json).toMatchObject({ + status: "needs_human", + appliedActions: [], + remediation: expect.stringContaining("config: Use a literal mapping"), + }); + expect(await readFile(config, "utf8")).toBe(initial); + await expect(readFile(`${config}.rea.backup`)).rejects.toMatchObject({ + code: "ENOENT", + }); + const removed = await run(["uninstall"]); + expect(removed.exitCode).toBe(0); + expect(removed.json).toMatchObject({ + status: "complete", + items: expect.arrayContaining([ + expect.objectContaining({ name: "dsh", status: "removed" }), + ]), + }); + const remaining = await readFile(config, "utf8"); + expect(remaining).toContain("# caller patch"); + expect(parse(remaining)).toEqual([ + { insert: [{ id: "other", name: "caller-plugin" }] }, + ]); + expect(await readFile(`${config}.rea.backup`, "utf8")).toBe(initial); + }, +); diff --git a/tests/boundary/filesystem/clientLifecycle.test.ts b/tests/boundary/filesystem/clientLifecycle.test.ts index 9b3e8f5b9..db0b517e7 100644 --- a/tests/boundary/filesystem/clientLifecycle.test.ts +++ b/tests/boundary/filesystem/clientLifecycle.test.ts @@ -67,6 +67,7 @@ describe("client configuration filesystem lifecycle", () => { "omp", "pi", "hermes", + "dsh", "grok_bot", ]); expect( diff --git a/tests/boundary/filesystem/dshClientConfiguration.test.ts b/tests/boundary/filesystem/dshClientConfiguration.test.ts new file mode 100644 index 000000000..c33354b40 --- /dev/null +++ b/tests/boundary/filesystem/dshClientConfiguration.test.ts @@ -0,0 +1,2062 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + isNode, + isSeq, + parseDocument, + parse as parseYaml, + stringify as stringifyYaml, +} from "yaml"; + +import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; +import { npxRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; +import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js"; +import { + clientSkillDirectories, + supportedClients, +} from "../../../src/application/SupportedClients.js"; +import { systemUninstallHost } from "../../../src/application/Uninstall.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment.js"; + +/** + * The registration command setup writes on the host running the tests. Windows + * wraps it in `cmd.exe /d /c`, so the expected entry follows the host too. + */ +const command = npxRegistrationCommand(process.platform); + +beforeEach(clearClientLocationEnvironment); +afterEach(() => vi.unstubAllEnvs()); + +const dshClient = ( + env: Parameters[2], + platform: NodeJS.Platform = "linux", +) => + supportedClients(join("/home/a"), platform, env).find( + ({ name }) => name === "dsh", + ); + +describe("DeepSeek Harness configuration paths", () => { + it("resolves the home Cordis patch under the Harness home", () => { + expect(dshClient({})).toEqual({ + name: "dsh", + displayName: "DeepSeek Harness", + configPath: join("/home/a", ".dsh", "cordis.patch.yml"), + markerPath: join("/home/a", ".dsh"), + format: "dsh", + }); + // The home patch applies to every profile; DSH_HOME relocates it. + expect(dshClient({ DSH_HOME: "/custom/harness" })?.configPath).toBe( + join("/custom/harness", "cordis.patch.yml"), + ); + expect( + clientSkillDirectories(join("/home/a"), ["dsh"], { + DSH_AGENTS_HOME: "/custom/agents", + }), + ).toEqual([{ client: "dsh", directory: join("/custom/agents", "skills") }]); + }); +}); + +const harnessClient = async () => { + const home = await createTestTempDirectory("rea-dsh-"); + const client = supportedClients(home, process.platform, {}).find( + ({ name }) => name === "dsh", + ); + if (client?.markerPath === undefined) + throw new Error("missing DeepSeek Harness client"); + await mkdir(client.markerPath, { recursive: true }); + return { home, client }; +}; + +describe("DeepSeek Harness Cordis patch registration", () => { + it("appends the rea-mcp insert, preserves user patches, and reads back", async () => { + const { home, client } = await harnessClient(); + await writeFile( + client.configPath, + `# Harness home patch +- insert: + - id: user-tool + name: "@deepseek-ai/dsh-mcp-client" + config: + serverName: other + transport: stdio + command: other-server +`, + ); + + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const text = await readFile(client.configPath, "utf8"); + expect(text).toContain("# Harness home patch"); + expect(parseYaml(text)).toEqual([ + { + insert: [ + { + id: "user-tool", + name: "@deepseek-ai/dsh-mcp-client", + config: { + serverName: "other", + transport: "stdio", + command: "other-server", + }, + }, + ], + }, + { + insert: [ + { + id: "rea-mcp", + name: "@deepseek-ai/dsh-mcp-client", + config: { + serverName: "rea", + transport: "stdio", + command: command[0], + args: command.slice(1), + }, + }, + ], + }, + ]); + + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "unchanged", + }); + expect( + ( + await readClientRegistrationStatuses(home, undefined, { + platform: process.platform, + environment: {}, + }) + ).find(({ client: name }) => name === "dsh"), + ).toMatchObject({ state: "aligned", command }); + + expect((await systemUninstallHost(home).removeClient(client)).status).toBe( + "removed", + ); + expect(parseYaml(await readFile(client.configPath, "utf8"))).toEqual([ + { + insert: [ + { + id: "user-tool", + name: "@deepseek-ai/dsh-mcp-client", + config: { + serverName: "other", + transport: "stdio", + command: "other-server", + }, + }, + ], + }, + ]); + }); + + it("replaces an existing rea-mcp entry without duplicating it", async () => { + const { client } = await harnessClient(); + await writeFile( + client.configPath, + `- insert: + - id: rea-mcp + name: "@deepseek-ai/dsh-mcp-client" + config: + serverName: rea + transport: stdio + command: stale-launcher + args: [mcp] +`, + ); + + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const patches = parseYaml( + await readFile(client.configPath, "utf8"), + ) as unknown[]; + expect(patches).toHaveLength(1); + expect((patches[0] as { insert: unknown[] }).insert).toHaveLength(1); + expect(textOf(await readFile(client.configPath, "utf8"))).not.toContain( + "stale-launcher", + ); + }); + + it("rewrites a wrong-package rea-mcp entry into the canonical one", async () => { + const { client } = await harnessClient(); + await writeFile( + client.configPath, + `- insert: + - id: rea-mcp + name: not-the-harness-client + config: + serverName: rea + transport: stdio + command: stale-launcher + args: [mcp] + env: + GHIDRA_INSTALL_DIR: /caller/nondefault/ghidra +`, + ); + + // The non-canonical entry reads back as stale, so setup rewrites it with + // the canonical package name rather than reporting unchanged, and the + // row's configured environment survives the identity repair. + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const patches = parseYaml(await readFile(client.configPath, "utf8")) as { + insert: { id: string; name: string; config?: { env?: unknown } }[]; + }[]; + expect(patches).toHaveLength(1); + expect(patches[0]?.insert).toHaveLength(1); + expect(patches[0]?.insert[0]?.id).toBe("rea-mcp"); + expect(patches[0]?.insert[0]?.name).toBe("@deepseek-ai/dsh-mcp-client"); + expect(patches[0]?.insert[0]?.config?.env).toEqual({ + GHIDRA_INSTALL_DIR: "/caller/nondefault/ghidra", + }); + expect(textOf(await readFile(client.configPath, "utf8"))).not.toContain( + "not-the-harness-client", + ); + }); +}); + +describe("DeepSeek Harness registration identity", () => { + it("applies a direct config patch before judging the registration", async () => { + const { home, client } = await harnessClient(); + await writeFile( + client.configPath, + `- insert: + - id: rea-mcp + name: "@deepseek-ai/dsh-mcp-client" + config: + serverName: rea + transport: stdio + command: ${command[0]} + args: [${command.slice(1).join(", ")}] +- id: rea-mcp + config: + serverName: rea + transport: stdio + command: redirected-launcher + args: [mcp] +`, + ); + + // The later direct patch replaces the row's config, so the effective + // command is the redirect and the registration is stale, not aligned. + expect( + ( + await readClientRegistrationStatuses(home, undefined, { + platform: process.platform, + environment: {}, + }) + ).find(({ client: name }) => name === "dsh"), + ).toMatchObject({ state: "stale" }); + + // Setup rewrites one canonical entry and drops the superseded direct op. + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const text = await readFile(client.configPath, "utf8"); + expect(text).not.toContain("redirected-launcher"); + expect(text).not.toMatch(/^- id: rea-mcp$/mu); + const patches = parseYaml(text) as unknown[]; + expect(patches).toHaveLength(1); + }); + + it("collapses duplicate rea entries into one canonical row", async () => { + const { client } = await harnessClient(); + await writeFile( + client.configPath, + `- insert: + - id: rea-mcp + name: "@deepseek-ai/dsh-mcp-client" + config: + serverName: rea + transport: stdio + command: first-launcher + args: [mcp] +- insert: + - id: rea-mcp + name: "@deepseek-ai/dsh-mcp-client" + config: + serverName: rea + transport: stdio + command: second-launcher + args: [mcp] +`, + ); + + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const patches = parseYaml(await readFile(client.configPath, "utf8")) as { + insert: unknown[]; + }[]; + const reaRows = patches.flatMap(({ insert }) => + insert.filter( + (entry) => + typeof entry === "object" && + entry !== null && + (entry as { id?: unknown }).id === "rea-mcp", + ), + ); + expect(reaRows).toHaveLength(1); + }); + + it("reports a foreign server name as stale instead of aligned", async () => { + const { home, client } = await harnessClient(); + await writeFile( + client.configPath, + `- insert: + - id: rea-mcp + name: "@deepseek-ai/dsh-mcp-client" + config: + serverName: other + transport: stdio + command: ${command[0]} + args: [${command.slice(1).join(", ")}] +`, + ); + + expect( + ( + await readClientRegistrationStatuses(home, undefined, { + platform: process.platform, + environment: {}, + }) + ).find(({ client: name }) => name === "dsh"), + ).toMatchObject({ state: "stale" }); + }); + + it("creates the patch list for an absent file and empties it on uninstall", async () => { + const { home, client } = await harnessClient(); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const created = parseYaml( + await readFile(client.configPath, "utf8"), + ) as unknown[]; + expect(created).toEqual([ + { + insert: [ + { + id: "rea-mcp", + name: "@deepseek-ai/dsh-mcp-client", + config: { + serverName: "rea", + transport: "stdio", + command: command[0], + args: command.slice(1), + }, + }, + ], + }, + ]); + expect((await systemUninstallHost(home).removeClient(client)).status).toBe( + "removed", + ); + expect(await readFile(client.configPath, "utf8")).toBe("[]\n"); + }); +}); + +describe("DeepSeek Harness repair boundaries", () => { + it("repairs a direct-only registration by appending the insert", async () => { + const { client } = await harnessClient(); + await writeFile( + client.configPath, + `- id: rea-mcp + name: "@deepseek-ai/dsh-mcp-client" + config: + serverName: rea + transport: stdio + command: stale-launcher + args: [mcp] +`, + ); + + // An incomplete manual registration has no insertion to rewrite, so the + // repair appends one canonical row instead of leaving the entry absent. + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + expect(parseYaml(await readFile(client.configPath, "utf8"))).toEqual([ + { + insert: [ + { + id: "rea-mcp", + name: "@deepseek-ai/dsh-mcp-client", + config: { + serverName: "rea", + transport: "stdio", + command: command[0], + args: command.slice(1), + }, + }, + ], + }, + ]); + }); + + it("refuses a group-targeted insertion whose applicability is unknown", async () => { + const { home, client } = await harnessClient(); + const initial = stringifyYaml([ + { id: "missing-group", insert: [canonicalEntry()] }, + ]); + await writeFile(client.configPath, initial); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("failed"); + expect(await dshStatus(home)).not.toMatchObject({ state: "aligned" }); + expect(await readFile(client.configPath, "utf8")).toBe(initial); + }); +}); + +describe("DeepSeek Harness registration state", () => { + it("diagnoses a disabled entry as stale and re-enables it on repair", async () => { + const { home, client } = await harnessClient(); + await writeFile( + client.configPath, + `- insert: + - id: rea-mcp + name: "@deepseek-ai/dsh-mcp-client" + disabled: true + config: + serverName: rea + transport: stdio + command: ${command[0]} + args: [${command.slice(1).join(", ")}] +`, + ); + + // The command matches, but Cordis never activates a disabled row, so the + // registration reads as stale rather than aligned. + expect( + ( + await readClientRegistrationStatuses(home, undefined, { + platform: process.platform, + environment: {}, + }) + ).find(({ client: name }) => name === "dsh"), + ).toMatchObject({ state: "stale" }); + + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const patches = parseYaml(await readFile(client.configPath, "utf8")) as { + insert: { disabled?: unknown }[]; + }[]; + expect(patches[0]?.insert[0]?.disabled).toBeUndefined(); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("unchanged"); + }); + + it("retains the existing registration environment across a repair", async () => { + const { client } = await harnessClient(); + await writeFile( + client.configPath, + `- insert: + - id: rea-mcp + name: "@deepseek-ai/dsh-mcp-client" + config: + serverName: rea + transport: stdio + command: stale-launcher + args: [mcp] + env: + GHIDRA_INSTALL_DIR: /caller/nondefault/ghidra +`, + ); + + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const patches = parseYaml(await readFile(client.configPath, "utf8")) as { + insert: { config: { env?: Record } }[]; + }[]; + expect(patches[0]?.insert[0]?.config.env).toEqual({ + GHIDRA_INSTALL_DIR: "/caller/nondefault/ghidra", + }); + }); + + it("treats a direct-only registration as absent even when its config matches", async () => { + const { home, client } = await harnessClient(); + await writeFile( + client.configPath, + `- id: rea-mcp + name: "@deepseek-ai/dsh-mcp-client" + config: + serverName: rea + transport: stdio + command: ${command[0]} + args: [${command.slice(1).join(", ")}] +`, + ); + + // A direct patch needs an inserted row to target; with none, Harness has + // no entry, so the registration is absent rather than aligned. + expect( + ( + await readClientRegistrationStatuses(home, undefined, { + platform: process.platform, + environment: {}, + }) + ).find(({ client: name }) => name === "dsh"), + ).toMatchObject({ state: "missing" }); + + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const patches = parseYaml(await readFile(client.configPath, "utf8")) as { + insert: unknown[]; + }[]; + expect(patches).toHaveLength(1); + expect(patches[0]?.insert).toHaveLength(1); + }); + + it("keeps caller-owned config fields across a refresh", async () => { + const { client } = await harnessClient(); + const withCwd = (text: string): string => + text.replace("args: [mcp]", "args: [mcp]\n cwd: /custom/workdir"); + await writeFile( + client.configPath, + withCwd(`- insert: + - id: rea-mcp + name: "@deepseek-ai/dsh-mcp-client" + config: + serverName: rea + transport: stdio + command: stale-launcher + args: [mcp] +`), + ); + + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const patches = parseYaml(await readFile(client.configPath, "utf8")) as { + insert: { config: { cwd?: unknown; command?: unknown } }[]; + }[]; + expect(patches[0]?.insert[0]?.config.cwd).toBe("/custom/workdir"); + expect(patches[0]?.insert[0]?.config.command).toBe(command[0]); + + // An otherwise-matching row with caller fields is current, not stale. + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("unchanged"); + }); +}); + +describe("DeepSeek Harness canonical row repair", () => { + it("normalizes two aligned insertions instead of reporting unchanged", async () => { + const { home, client } = await harnessClient(); + const row = `- insert: + - id: rea-mcp + name: "@deepseek-ai/dsh-mcp-client" + config: + serverName: rea + transport: stdio + command: ${command[0]} + args: [${command.slice(1).join(", ")}] +`; + await writeFile(client.configPath, `${row}${row}`); + + // Both rows already match, but Cordis still has two insertions. Doctor + // and setup must normalize to the single owned row. + const registration = async () => + ( + await readClientRegistrationStatuses(home, undefined, { + platform: process.platform, + environment: {}, + }) + ).find(({ client: name }) => name === "dsh"); + expect(await registration()).toMatchObject({ state: "stale" }); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const patches = parseYaml(await readFile(client.configPath, "utf8")) as { + insert: unknown[]; + }[]; + const reaRows = patches.flatMap(({ insert }) => + insert.filter( + (entry) => + typeof entry === "object" && + entry !== null && + (entry as { id?: unknown }).id === "rea-mcp", + ), + ); + expect(reaRows).toHaveLength(1); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("unchanged"); + expect(await registration()).toMatchObject({ state: "aligned" }); + }); + + it("keeps Cordis expression tags on caller-owned config fields", async () => { + const { client } = await harnessClient(); + await writeFile( + client.configPath, + `- insert: + - id: rea-mcp + name: "@deepseek-ai/dsh-mcp-client" + config: + serverName: rea + transport: stdio + command: stale-launcher + args: [mcp] + cwd: !!js process.cwd() # caller launch directory +`, + ); + + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const text = await readFile(client.configPath, "utf8"); + expect(text).toContain("!!js process.cwd()"); + expect(text).toContain("# caller launch directory"); + expect(text).not.toContain("stale-launcher"); + const patches = parseYaml(text, { logLevel: "error" }) as { + insert: { config: { cwd?: unknown; command?: unknown } }[]; + }[]; + expect(patches[0]?.insert[0]?.config.command).toBe(command[0]); + expect(patches[0]?.insert[0]?.config.cwd).toBe("process.cwd()"); + + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("unchanged"); + expect(await readFile(client.configPath, "utf8")).toContain( + "!!js process.cwd()", + ); + }); + + it("keeps a Cordis tag from the direct patch that owns the effective config", async () => { + const { client } = await harnessClient(); + await writeFile( + client.configPath, + `- insert: + - id: rea-mcp + name: "@deepseek-ai/dsh-mcp-client" + config: + serverName: rea + transport: stdio + command: first-launcher + args: [mcp] +- id: rea-mcp + config: + serverName: rea + transport: stdio + command: stale-launcher + args: [mcp] + cwd: !!js process.cwd() +`, + ); + + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const text = await readFile(client.configPath, "utf8"); + expect(text).toContain("!!js process.cwd()"); + expect(text).not.toContain("stale-launcher"); + expect(text).not.toContain("first-launcher"); + expect(text).not.toMatch(/^- id: rea-mcp$/mu); + const patches = parseYaml(text, { logLevel: "error" }) as { + insert?: { config?: { cwd?: unknown } }[]; + }[]; + expect(patches).toHaveLength(1); + expect(patches[0]?.insert?.[0]?.config?.cwd).toBe("process.cwd()"); + }); +}); + +describe("DeepSeek Harness document edges", () => { + it("keeps Cordis tags on environment values that were not overridden", async () => { + const { client } = await harnessClient(); + await writeFile( + client.configPath, + `- insert: + - id: rea-mcp + name: "@deepseek-ai/dsh-mcp-client" + config: + serverName: rea + transport: stdio + command: stale-launcher + args: [mcp] + env: + OTHER: !!js process.env.OTHER + TOKEN: !!js process.env.TOKEN # caller token +`, + ); + + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const text = await readFile(client.configPath, "utf8"); + expect(text).toContain("!!js process.env.TOKEN"); + expect(text).toContain("# caller token"); + expect(text).toContain("!!js process.env.OTHER"); + expect(text).not.toContain("stale-launcher"); + const patches = parseYaml(text, { logLevel: "error" }) as { + insert: { config: { env?: Record; command?: string } }[]; + }[]; + expect(patches[0]?.insert[0]?.config.command).toBe(command[0]); + expect(patches[0]?.insert[0]?.config.env).toEqual({ + OTHER: "process.env.OTHER", + TOKEN: "process.env.TOKEN", + }); + + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("unchanged"); + expect(await readFile(client.configPath, "utf8")).toContain( + "!!js process.env.TOKEN", + ); + }); + + it("replaces only the environment entry a caller overrode", async () => { + const { client } = await harnessClient(); + await writeFile( + client.configPath, + `- insert: + - id: rea-mcp + name: "@deepseek-ai/dsh-mcp-client" + config: + serverName: rea + transport: stdio + command: stale-launcher + args: [mcp] + env: + OTHER: !!js process.env.OTHER + TOKEN: !!js process.env.TOKEN +`, + ); + + expect( + ( + await configureClientConfiguration( + client, + { TOKEN: "literal-override" }, + command, + ) + ).status, + ).toBe("configured"); + const text = await readFile(client.configPath, "utf8"); + expect(text).toContain("!!js process.env.OTHER"); + expect(text).not.toContain("!!js process.env.TOKEN"); + expect(text).toContain("literal-override"); + const patches = parseYaml(text, { logLevel: "error" }) as { + insert: { config: { env?: Record } }[]; + }[]; + expect(patches[0]?.insert[0]?.config.env).toEqual({ + OTHER: "process.env.OTHER", + TOKEN: "literal-override", + }); + }); + + it("appends a registration to a comment-only patch file", async () => { + const { home, client } = await harnessClient(); + const notes = "# harness notes\n# keep me\n"; + await writeFile(client.configPath, notes); + + expect( + ( + await readClientRegistrationStatuses(home, undefined, { + platform: process.platform, + environment: {}, + }) + ).find(({ client: name }) => name === "dsh"), + ).toMatchObject({ state: "missing" }); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const text = await readFile(client.configPath, "utf8"); + expect(text).toContain("# harness notes"); + expect(text).toContain("# keep me"); + expect(text).toContain("id: rea-mcp"); + expect(text).not.toMatch(/^null$/mu); + + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("unchanged"); + expect((await systemUninstallHost(home).removeClient(client)).status).toBe( + "removed", + ); + const removed = await readFile(client.configPath, "utf8"); + expect(removed).toContain("# harness notes"); + expect(removed).toContain("# keep me"); + expect(removed).not.toContain("rea-mcp"); + expect(removed).not.toMatch(/^null$/mu); + }); +}); + +describe("DeepSeek Harness entry ownership", () => { + const registration = async (home: string) => + ( + await readClientRegistrationStatuses(home, undefined, { + platform: process.platform, + environment: {}, + }) + ).find(({ client: name }) => name === "dsh"); + + it("removes a stale launcher and a direct-only row by entry id", async () => { + const { home, client } = await harnessClient(); + await writeFile( + client.configPath, + `- insert: + - id: other-tool + name: other + config: + command: keep-me + - id: rea-mcp + name: "@deepseek-ai/dsh-mcp-client" + config: + serverName: rea + transport: stdio + command: stale-launcher + args: [mcp] +`, + ); + + expect((await systemUninstallHost(home).removeClient(client)).status).toBe( + "removed", + ); + const removed = await readFile(client.configPath, "utf8"); + expect(removed).toContain("other-tool"); + expect(removed).not.toContain("rea-mcp"); + expect(removed).not.toContain("stale-launcher"); + + await writeFile( + client.configPath, + `- id: other-tool + config: + command: keep-me +- id: rea-mcp + name: "@deepseek-ai/dsh-mcp-client" + config: {} +`, + ); + expect((await systemUninstallHost(home).removeClient(client)).status).toBe( + "removed", + ); + const direct = await readFile(client.configPath, "utf8"); + expect(direct).toContain("other-tool"); + expect(direct).not.toContain("rea-mcp"); + }); + + it("treats an expression disabled state as stale and clears it", async () => { + const { home, client } = await harnessClient(); + await writeFile( + client.configPath, + `- insert: + - id: rea-mcp + name: "@deepseek-ai/dsh-mcp-client" + disabled: !!js process.env.DISABLE_REA === "1" + config: + serverName: rea + transport: stdio + command: ${command[0]} + args: [${command.slice(1).join(", ")}] +`, + ); + + expect(await registration(home)).toMatchObject({ state: "stale" }); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const text = await readFile(client.configPath, "utf8"); + expect(text).not.toContain("DISABLE_REA"); + expect(text).not.toContain("disabled:"); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("unchanged"); + expect(await registration(home)).toMatchObject({ state: "aligned" }); + }); + + it("keeps a Cordis expression on a known environment setting", async () => { + const { home, client } = await harnessClient(); + await writeFile( + client.configPath, + `- insert: + - id: rea-mcp + name: "@deepseek-ai/dsh-mcp-client" + config: + serverName: rea + transport: stdio + command: stale-launcher + args: [mcp] + env: + GHIDRA_INSTALL_DIR: !!js process.env.GHIDRA_INSTALL_DIR +`, + ); + + // The expression is not a literal path, so doctor can still classify the + // row instead of rejecting the file. + expect(await registration(home)).toMatchObject({ state: "stale" }); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const text = await readFile(client.configPath, "utf8"); + expect(text).toContain("!!js process.env.GHIDRA_INSTALL_DIR"); + expect(text).not.toContain("stale-launcher"); + const patches = parseYaml(text, { logLevel: "error" }) as { + insert: { config: { env?: Record } }[]; + }[]; + expect(patches[0]?.insert[0]?.config.env).toEqual({ + GHIDRA_INSTALL_DIR: "process.env.GHIDRA_INSTALL_DIR", + }); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("unchanged"); + expect(await readFile(client.configPath, "utf8")).toContain( + "!!js process.env.GHIDRA_INSTALL_DIR", + ); + }); +}); + +describe("DeepSeek Harness alias collapse", () => { + it("collapses an aliased insertion and keeps an unrelated alias", async () => { + const { client } = await harnessClient(); + await writeFile( + client.configPath, + `- &other + insert: + - id: other-tool + name: other + config: + command: keep-me +- *other +- &row + insert: + - id: rea-mcp + name: "@deepseek-ai/dsh-mcp-client" + config: + serverName: rea + transport: stdio + command: stale-launcher + args: [mcp] +- *row +`, + ); + + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const text = await readFile(client.configPath, "utf8"); + expect(text).toContain("*other"); + expect(text).not.toContain("*row"); + expect(text).not.toContain("stale-launcher"); + const patches = parseYaml(text) as { insert?: { id?: string }[] }[]; + const reaRows = patches.flatMap(({ insert }) => + (insert ?? []).filter((entry) => entry.id === "rea-mcp"), + ); + expect(reaRows).toHaveLength(1); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("unchanged"); + }); + + it("collapses an aliased entry inside one insertion", async () => { + const { client } = await harnessClient(); + await writeFile( + client.configPath, + `- insert: + - &entry + id: rea-mcp + name: "@deepseek-ai/dsh-mcp-client" + config: + serverName: rea + transport: stdio + command: stale-launcher + args: [mcp] + - *entry +`, + ); + + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const text = await readFile(client.configPath, "utf8"); + expect(text).not.toContain("*entry"); + const patches = parseYaml(text) as { insert?: { id?: string }[] }[]; + expect( + patches.flatMap(({ insert }) => + (insert ?? []).filter((entry) => entry.id === "rea-mcp"), + ), + ).toHaveLength(1); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("unchanged"); + }); +}); + +describe("DeepSeek Harness alias targets", () => { + it("collapses an alias of the insert list", async () => { + const { client } = await harnessClient(); + await writeFile( + client.configPath, + `- insert: &rows + - id: other-tool + name: other + config: + command: keep-me + - id: rea-mcp + name: "@deepseek-ai/dsh-mcp-client" + config: + serverName: rea + transport: stdio + command: stale-launcher + args: [mcp] +- insert: *rows +`, + ); + + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const text = await readFile(client.configPath, "utf8"); + expect(text).toContain("other-tool"); + expect(text).not.toContain("*rows"); + expect(text).not.toContain("stale-launcher"); + const patches = parseYaml(text) as { insert?: { id?: string }[] }[]; + expect( + patches.flatMap(({ insert }) => + (insert ?? []).filter((entry) => entry.id === "rea-mcp"), + ), + ).toHaveLength(1); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("unchanged"); + }); + + it("keeps a Cordis tag when the config map is an alias", async () => { + const { client } = await harnessClient(); + await writeFile( + client.configPath, + `- &cfg + cwd: !!js process.cwd() # caller launch directory + serverName: rea + transport: stdio + command: stale-launcher + args: [mcp] +- insert: + - id: rea-mcp + name: "@deepseek-ai/dsh-mcp-client" + config: *cfg +`, + ); + + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const text = await readFile(client.configPath, "utf8"); + expect(text).toContain("!!js process.cwd()"); + expect(text).toContain("# caller launch directory"); + const patches = parseYaml(text, { logLevel: "error" }) as { + insert?: { config?: { cwd?: unknown; command?: unknown } }[]; + }[]; + const row = patches.find((patch) => patch.insert !== undefined); + expect(row?.insert?.[0]?.config?.cwd).toBe("process.cwd()"); + expect(row?.insert?.[0]?.config?.command).toBe(command[0]); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("unchanged"); + }); +}); + +describe("DeepSeek Harness alias identity", () => { + it("keeps unrelated rows when the insert list is anchored outside an operation", async () => { + const { client } = await harnessClient(); + await writeFile( + client.configPath, + `- &rows + - id: other-tool + name: other + config: + command: keep-me + - id: rea-mcp + name: "@deepseek-ai/dsh-mcp-client" + config: + serverName: rea + transport: stdio + command: stale-launcher + args: [mcp] +- insert: *rows +`, + ); + + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const patches = parseYaml(await readFile(client.configPath, "utf8")) as { + insert?: { id?: string; config?: { command?: string } }[]; + }[]; + const inserted = patches.flatMap((patch) => patch.insert ?? []); + expect(inserted.filter((entry) => entry.id === "other-tool")).toHaveLength( + 1, + ); + expect(inserted.filter((entry) => entry.id === "rea-mcp")).toEqual([ + expect.objectContaining({ + config: expect.objectContaining({ command: command[0] }), + }), + ]); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("unchanged"); + }); + + it("keeps an aliased Cordis tag on an environment value", async () => { + const { client } = await harnessClient(); + await writeFile( + client.configPath, + `- &expr !!js process.env.TOKEN +- insert: + - id: rea-mcp + name: "@deepseek-ai/dsh-mcp-client" + config: + serverName: rea + transport: stdio + command: stale-launcher + args: [mcp] + env: + TOKEN: *expr +`, + ); + + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const text = await readFile(client.configPath, "utf8"); + expect(text).toContain("!!js process.env.TOKEN"); + expect(text).toContain("*expr"); + expect(text).not.toContain("stale-launcher"); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("unchanged"); + }); + + it("recognizes an aliased entry id", async () => { + const { home, client } = await harnessClient(); + await writeFile( + client.configPath, + `- &entryId rea-mcp +- insert: + - id: *entryId + name: "@deepseek-ai/dsh-mcp-client" + config: + serverName: rea + transport: stdio + command: stale-launcher + args: [mcp] +`, + ); + + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const patches = parseYaml(await readFile(client.configPath, "utf8")) as { + insert?: { id?: string }[]; + }[]; + expect( + patches.flatMap((patch) => + (patch.insert ?? []).filter((entry) => entry.id === "rea-mcp"), + ), + ).toHaveLength(1); + expect((await systemUninstallHost(home).removeClient(client)).status).toBe( + "removed", + ); + expect(await readFile(client.configPath, "utf8")).not.toContain("*entryId"); + }); +}); + +describe("DeepSeek Harness alias cleanup", () => { + it("keeps tags when the environment map is an alias", async () => { + const { client } = await harnessClient(); + await writeFile( + client.configPath, + `- &reaEnv + TOKEN: !!js process.env.TOKEN +- insert: + - id: rea-mcp + name: "@deepseek-ai/dsh-mcp-client" + config: + serverName: rea + transport: stdio + command: stale-launcher + args: [mcp] + env: *reaEnv +`, + ); + + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const text = await readFile(client.configPath, "utf8"); + expect(text).toContain("!!js process.env.TOKEN"); + expect(text).not.toContain("stale-launcher"); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("unchanged"); + }); + + it("does not validate an aliased Cordis path expression as a literal", async () => { + const { home, client } = await harnessClient(); + await writeFile( + client.configPath, + `- &expr !!js process.env.GHIDRA_INSTALL_DIR +- insert: + - id: rea-mcp + name: "@deepseek-ai/dsh-mcp-client" + config: + serverName: rea + transport: stdio + command: stale-launcher + args: [mcp] + env: + GHIDRA_INSTALL_DIR: *expr +`, + ); + + expect( + ( + await readClientRegistrationStatuses(home, undefined, { + platform: process.platform, + environment: {}, + }) + ).find(({ client: name }) => name === "dsh"), + ).toMatchObject({ state: "stale" }); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + expect(await readFile(client.configPath, "utf8")).toContain( + "!!js process.env.GHIDRA_INSTALL_DIR", + ); + }); + + it("removes an aliased insert list on uninstall", async () => { + const { home, client } = await harnessClient(); + await writeFile( + client.configPath, + `- insert: &rows + - id: rea-mcp + name: "@deepseek-ai/dsh-mcp-client" + config: + serverName: rea + transport: stdio + command: ${command[0]} + args: [${command.slice(1).join(", ")}] +- insert: *rows +`, + ); + + expect((await systemUninstallHost(home).removeClient(client)).status).toBe( + "removed", + ); + const text = await readFile(client.configPath, "utf8"); + expect(text).not.toContain("rea-mcp"); + expect(text).not.toContain("*rows"); + }); +}); + +describe("DeepSeek Harness root aliases", () => { + it("uses a direct patch replayed by a root alias as the winning config", async () => { + const { home, client } = await harnessClient(); + await writeFile( + client.configPath, + `- insert: + - id: rea-mcp + name: "@deepseek-ai/dsh-mcp-client" + config: + serverName: rea + transport: stdio + command: first-launcher + args: [mcp] + env: + GHIDRA_INSTALL_DIR: /absolute/ghidra +- &patch + id: rea-mcp + config: + serverName: rea + transport: stdio + command: stale-launcher + args: [mcp] + env: + GHIDRA_INSTALL_DIR: !!js process.env.GHIDRA_INSTALL_DIR +- *patch +`, + ); + + expect( + ( + await readClientRegistrationStatuses(home, undefined, { + platform: process.platform, + environment: {}, + }) + ).find(({ client: name }) => name === "dsh"), + ).toMatchObject({ state: "stale" }); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + expect(await readFile(client.configPath, "utf8")).toContain( + "!!js process.env.GHIDRA_INSTALL_DIR", + ); + }); + + it("removes a root alias of the owned insertion on uninstall", async () => { + const { home, client } = await harnessClient(); + await writeFile( + client.configPath, + `- &row + insert: + - id: rea-mcp + name: "@deepseek-ai/dsh-mcp-client" + config: + serverName: rea + transport: stdio + command: ${command[0]} + args: [${command.slice(1).join(", ")}] +- *row +`, + ); + + expect((await systemUninstallHost(home).removeClient(client)).status).toBe( + "removed", + ); + const text = await readFile(client.configPath, "utf8"); + expect(text).not.toContain("rea-mcp"); + expect(text).not.toContain("*row"); + }); + + it("keeps a Cordis tag when the only owned row is an alias", async () => { + const { client } = await harnessClient(); + await writeFile( + client.configPath, + `- &entry + id: rea-mcp + name: "@deepseek-ai/dsh-mcp-client" + config: + serverName: rea + transport: stdio + command: stale-launcher + args: [mcp] + cwd: !!js process.cwd() +- insert: + - *entry +`, + ); + + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const text = await readFile(client.configPath, "utf8"); + expect(text).toContain("!!js process.cwd()"); + expect(text).not.toContain("stale-launcher"); + const patches = parseYaml(text, { logLevel: "error" }) as { + insert?: { config?: { cwd?: unknown; command?: unknown } }[]; + }[]; + const row = patches.find((patch) => patch.insert !== undefined); + expect(row?.insert?.[0]?.config?.cwd).toBe("process.cwd()"); + expect(row?.insert?.[0]?.config?.command).toBe(command[0]); + }); +}); + +const loadedInserts = ( + text: string, +): { id?: string; config?: { command?: string } }[] => { + const patches = parseYaml(text) as { + insert?: { id?: string; config?: { command?: string } }[]; + }[]; + return patches.flatMap((patch) => patch.insert ?? []); +}; + +describe("DeepSeek Harness inert anchors", () => { + it("keeps unrelated rows when the only active insertion is a root alias", async () => { + const { client } = await harnessClient(); + await writeFile( + client.configPath, + `- holder: &op + insert: + - id: other-tool + name: other + config: + command: keep-me + - id: rea-mcp + name: "@deepseek-ai/dsh-mcp-client" + config: + serverName: rea + transport: stdio + command: stale-launcher + args: [mcp] +- *op +`, + ); + + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const inserted = loadedInserts(await readFile(client.configPath, "utf8")); + expect(inserted.filter((entry) => entry.id === "other-tool")).toHaveLength( + 1, + ); + expect(inserted.filter((entry) => entry.id === "rea-mcp")).toEqual([ + expect.objectContaining({ + config: expect.objectContaining({ command: command[0] }), + }), + ]); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("unchanged"); + }); + + it("keeps unrelated rows when uninstall removes that root alias", async () => { + const { home, client } = await harnessClient(); + await writeFile( + client.configPath, + `- holder: &op + insert: + - id: other-tool + name: other + config: + command: keep-me + - id: rea-mcp + name: "@deepseek-ai/dsh-mcp-client" + config: + serverName: rea + transport: stdio + command: ${command[0]} + args: [${command.slice(1).join(", ")}] +- *op +`, + ); + + expect((await systemUninstallHost(home).removeClient(client)).status).toBe( + "removed", + ); + const text = await readFile(client.configPath, "utf8"); + expect(parseYaml(text)).toBeTruthy(); + const inserted = loadedInserts(text); + expect(inserted.filter((entry) => entry.id === "other-tool")).toHaveLength( + 1, + ); + expect(inserted.filter((entry) => entry.id === "rea-mcp")).toHaveLength(0); + }); +}); + +describe("DeepSeek Harness alias anchors", () => { + it("rewrites an entry id alias before removing its anchor", async () => { + const { client } = await harnessClient(); + await writeFile( + client.configPath, + `- id: &entryId rea-mcp + config: + serverName: rea + transport: stdio + command: stale-launcher + args: [mcp] +- insert: + - id: *entryId + name: "@deepseek-ai/dsh-mcp-client" + config: + serverName: rea + transport: stdio + command: stale-launcher + args: [mcp] +`, + ); + + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const text = await readFile(client.configPath, "utf8"); + expect(text).not.toContain("*entryId"); + expect( + loadedInserts(text).filter((entry) => entry.id === "rea-mcp"), + ).toHaveLength(1); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("unchanged"); + }); + + it("edits a root alias whose insert list is itself an alias", async () => { + const { client } = await harnessClient(); + await writeFile( + client.configPath, + `- &rows + - id: other-tool + name: other + config: + command: keep-me + - id: rea-mcp + name: "@deepseek-ai/dsh-mcp-client" + config: + serverName: rea + transport: stdio + command: stale-launcher + args: [mcp] +- holder: &op + insert: *rows +- *op +`, + ); + + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const inserted = loadedInserts(await readFile(client.configPath, "utf8")); + expect(inserted.filter((entry) => entry.id === "other-tool")).toHaveLength( + 1, + ); + expect(inserted.filter((entry) => entry.id === "rea-mcp")).toHaveLength(1); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("unchanged"); + }); + + it("drops a replayed root alias after its insert alias is emptied", async () => { + const { home, client } = await harnessClient(); + await writeFile( + client.configPath, + `- &rows + - id: rea-mcp + name: "@deepseek-ai/dsh-mcp-client" + config: + serverName: rea + transport: stdio + command: ${command[0]} + args: [${command.slice(1).join(", ")}] +- &op + insert: *rows +- *op +`, + ); + + expect((await systemUninstallHost(home).removeClient(client)).status).toBe( + "removed", + ); + const text = await readFile(client.configPath, "utf8"); + expect(text).not.toContain("*op"); + expect(() => parseYaml(text)).not.toThrow(); + }); +}); + +const textOf = (value: unknown): string => String(value); + +/** Same tagged expression representation as Harness's entry-list schema. */ +const cordisPatches = (text: string) => + parseYaml(text, { + customTags: [ + { + tag: "tag:yaml.org,2002:js", + resolve: (source: string) => ({ __jsExpr: source }), + }, + ], + }) as { insert?: { id?: string; config?: Record }[] }[]; + +const canonicalEntry = () => ({ + id: "rea-mcp", + name: "@deepseek-ai/dsh-mcp-client", + config: { + serverName: "rea", + transport: "stdio", + command: command[0], + args: command.slice(1), + }, +}); + +const dshStatus = async (home: string) => + ( + await readClientRegistrationStatuses(home, undefined, { + platform: process.platform, + environment: {}, + }) + ).find(({ client }) => client === "dsh"); + +describe("DeepSeek Harness source ownership", () => { + it.each(["setup", "uninstall"])( + "preserves unrelated rows in a later externally anchored operation during %s", + async (operation) => { + const { home, client } = await harnessClient(); + const initial = `${stringifyYaml([{ insert: [canonicalEntry()] }])}- holder: &op + insert: + - id: other-tool + name: other + config: {command: keep-me} + - id: rea-mcp + name: '@deepseek-ai/dsh-mcp-client' + config: {serverName: rea, transport: stdio, command: stale, args: [mcp]} +- *op +`; + await writeFile(client.configPath, initial); + const result = + operation === "setup" + ? await configureClientConfiguration(client, {}, command) + : await systemUninstallHost(home).removeClient(client); + expect(result.status).toBe( + operation === "setup" ? "configured" : "removed", + ); + const entries = loadedInserts(await readFile(client.configPath, "utf8")); + expect(entries.filter(({ id }) => id === "other-tool")).toEqual([ + { id: "other-tool", name: "other", config: { command: "keep-me" } }, + ]); + expect(entries.filter(({ id }) => id === "rea-mcp")).toHaveLength( + operation === "setup" ? 1 : 0, + ); + expect(await readFile(`${client.configPath}.rea.backup`, "utf8")).toBe( + initial, + ); + }, + ); + + it.each(["inline", "aliased config"])( + "snapshots environment aliases before replacing their anchor in %s", + async (shape) => { + const { client } = await harnessClient(); + const config = `serverName: rea + transport: stdio + command: stale-launcher + args: [mcp] + env: + A: &expr !!js process.env.TOKEN # caller expression + B: *expr +`; + const initial = + shape === "inline" + ? `- insert:\n - id: rea-mcp\n name: '@deepseek-ai/dsh-mcp-client'\n config:\n ${config.replaceAll("\n", "\n ").trimEnd()}\n` + : `- holder: &cfg\n ${config}- insert:\n - id: rea-mcp\n name: '@deepseek-ai/dsh-mcp-client'\n config: *cfg\n`; + await writeFile(client.configPath, initial); + expect( + ( + await configureClientConfiguration( + client, + { A: "literal-override" }, + command, + ) + ).status, + ).toBe("configured"); + const text = await readFile(client.configPath, "utf8"); + const row = cordisPatches(text) + .flatMap((op) => op.insert ?? []) + .find(({ id }) => id === "rea-mcp"); + expect(row?.config?.env).toEqual({ + A: "literal-override", + B: { __jsExpr: "process.env.TOKEN" }, + }); + expect(text).toContain("# caller expression"); + expect( + ( + await configureClientConfiguration( + client, + { A: "literal-override" }, + command, + ) + ).status, + ).toBe("unchanged"); + }, + ); + + it("keeps expression aliases used by unrelated rows when removing their source entry", async () => { + const { home, client } = await harnessClient(); + const initial = `- insert: + - id: rea-mcp + name: '@deepseek-ai/dsh-mcp-client' + config: + command: stale + cwd: &cwd !!js process.cwd() + - id: other-tool + name: other + config: + cwd: *cwd +`; + await writeFile(client.configPath, initial); + expect((await systemUninstallHost(home).removeClient(client)).status).toBe( + "removed", + ); + expect(cordisPatches(await readFile(client.configPath, "utf8"))).toEqual([ + { + insert: [ + { + id: "other-tool", + name: "other", + config: { cwd: { __jsExpr: "process.cwd()" } }, + }, + ], + }, + ]); + }); +}); + +describe("DeepSeek Harness registration applicability", () => { + it("preserves literal scalar keys in unrelated and caller-owned config", async () => { + const { client } = await harnessClient(); + const initial = `- insert: + - id: other + name: caller-plugin + config: {1: one, true: yes} + - id: rea-mcp + name: '@deepseek-ai/dsh-mcp-client' + config: {command: stale, extra: {2: two}} +`; + await writeFile(client.configPath, initial); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const rows = cordisPatches(await readFile(client.configPath, "utf8"))[0] + ?.insert; + expect(rows?.[0]?.config).toEqual({ "1": "one", true: "yes" }); + expect(rows?.[1]?.config?.extra).toEqual({ "2": "two" }); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("unchanged"); + }); + + it("uses direct-patch names as selectors and preserves the actual effective config", async () => { + const { home, client } = await harnessClient(); + const entry = canonicalEntry(); + const initial = stringifyYaml([ + { + insert: [ + { + ...entry, + name: "other-package", + config: { ...entry.config, env: { FROM_INSERT: "keep-me" } }, + }, + ], + }, + { + id: "rea-mcp", + name: entry.name, + config: { ...entry.config, env: { FROM_SKIPPED_PATCH: "discard-me" } }, + }, + ]); + await writeFile(client.configPath, initial); + expect(await dshStatus(home)).toMatchObject({ state: "stale" }); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + expect( + cordisPatches(await readFile(client.configPath, "utf8"))[0]?.insert?.[0] + ?.config?.env, + ).toEqual({ FROM_INSERT: "keep-me" }); + expect(await dshStatus(home)).toMatchObject({ state: "aligned" }); + }); + + it.each(["serverName", "transport", "command", "args"] as const)( + "distinguishes expression-backed %s from literal launcher values", + async (key) => { + const { home, client } = await harnessClient(); + const entry = canonicalEntry(); + const doc = stringifyYaml([{ insert: [entry] }]); + const { parseDocument } = await import("yaml"); + const yaml = parseDocument(doc); + const expression = yaml.createNode( + key === "args" ? "[]" : String(entry.config[key]), + ); + expression.tag = "tag:yaml.org,2002:js"; + yaml.setIn([0, "insert", 0, "config", key], expression); + await writeFile(client.configPath, yaml.toString()); + expect((await dshStatus(home))?.state).not.toBe("aligned"); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + expect(cordisPatches(await readFile(client.configPath, "utf8"))).toEqual([ + { insert: [entry] }, + ]); + expect(await dshStatus(home)).toMatchObject({ state: "aligned" }); + }, + ); + + it("keeps caller config separate from package, duplicate and activation diagnostics", async () => { + const { home, client } = await harnessClient(); + const entry = canonicalEntry(); + const initial = stringifyYaml([ + { + insert: [ + { + ...entry, + config: { + ...entry.config, + package: "caller-package", + duplicate: "caller-state", + disabled: "caller-value", + enabled: false, + }, + }, + ], + }, + ]); + await writeFile(client.configPath, initial); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("unchanged"); + expect(await dshStatus(home)).toMatchObject({ state: "aligned" }); + expect(await readFile(client.configPath, "utf8")).toBe(initial); + }); + + it.each([ + "- &cycle {holder: *cycle}\n", + "- insert: [\n", + "- insert: *missing\n", + [ + "- holder: &a [value, value]", + "- holder: &b [*a, *a, *a, *a, *a, *a, *a, *a, *a, *a]", + "- holder: &c [*b, *b, *b, *b, *b, *b, *b, *b, *b, *b]", + "- insert: [{id: rea-mcp, config: {extra: *c}}]", + ].join("\n"), + "- insert: [{id: rea-mcp, name: '@deepseek-ai/dsh-mcp-client', config: !!js process.env.CONFIG}]\n", + ])("refuses unsafe YAML without mutation: %s", async (initial) => { + const { client } = await harnessClient(); + await writeFile(client.configPath, initial); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("failed"); + expect(await readFile(client.configPath, "utf8")).toBe(initial); + await expect( + readFile(`${client.configPath}.rea.backup`), + ).rejects.toMatchObject({ + code: "ENOENT", + }); + }); +}); + +describe("DeepSeek Harness ownership and editability", () => { + it.each([ + `- insert: [{id: !!js '"rea-mcp"', name: caller-plugin}]\n`, + `- id: !!js '"rea-mcp"'\n config: {command: caller-launcher}\n`, + `- holder: &id !!js '"rea-mcp"'\n- insert: [{id: *id}]\n`, + ])("retains registrations with unknown ID ownership: %s", async (initial) => { + const { home, client } = await harnessClient(); + await writeFile(client.configPath, initial); + expect(await dshStatus(home)).toMatchObject({ state: "invalid" }); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("failed"); + const host = systemUninstallHost(home); + expect(await host.inspectClient(client)).toMatchObject({ + status: "failed", + }); + expect((await host.removeClient(client)).status).toBe("failed"); + expect(await readFile(client.configPath, "utf8")).toBe(initial); + await expect( + readFile(`${client.configPath}.rea.backup`), + ).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + + it.each(["selector", "insertion"] as const)( + "preserves unknown %s name applicability while allowing owned removal", + async (expressionPosition) => { + const { home, client } = await harnessClient(); + const { parseDocument } = await import("yaml"); + const yaml = parseDocument( + stringifyYaml([ + { insert: [canonicalEntry()] }, + { + id: "rea-mcp", + name: "@deepseek-ai/dsh-mcp-client", + config: { command: "patched-launcher" }, + }, + ]), + ); + const expression = yaml.createNode('"@deepseek-ai/dsh-mcp-client"'); + expression.tag = "tag:yaml.org,2002:js"; + yaml.setIn( + expressionPosition === "selector" + ? [1, "name"] + : [0, "insert", 0, "name"], + expression, + ); + const initial = yaml.toString(); + await writeFile(client.configPath, initial); + expect(await dshStatus(home)).toMatchObject({ state: "invalid" }); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("failed"); + expect(await readFile(client.configPath, "utf8")).toBe(initial); + const host = systemUninstallHost(home); + expect(await host.inspectClient(client)).toBeUndefined(); + expect((await host.removeClient(client)).status).toBe("removed"); + expect(parseYaml(await readFile(client.configPath, "utf8"))).toEqual([]); + expect(await readFile(`${client.configPath}.rea.backup`, "utf8")).toBe( + initial, + ); + }, + ); +}); + +describe("DeepSeek Harness aliased mapping keys", () => { + it.each(["setup", "uninstall"])( + "resolves owned fields and preserves caller config during %s", + async (operation) => { + const { home, client } = await harnessClient(); + const initial = `- holder: &insertKey insert +- holder: &idKey id +- holder: &configKey config +- holder: &envKey env +- ? *insertKey + : - ? *idKey + : rea-mcp + name: '@deepseek-ai/dsh-mcp-client' + ? *configKey + : command: stale + ? *envKey + : TOKEN: keep-me + - id: other + name: caller-plugin +`; + await writeFile(client.configPath, initial); + const result = + operation === "setup" + ? await configureClientConfiguration(client, {}, command) + : await systemUninstallHost(home).removeClient(client); + expect(result.status).toBe( + operation === "setup" ? "configured" : "removed", + ); + const entries = loadedInserts(await readFile(client.configPath, "utf8")); + expect(entries.filter(({ id }) => id === "other")).toEqual([ + { id: "other", name: "caller-plugin" }, + ]); + const owned = entries.filter(({ id }) => id === "rea-mcp"); + expect(owned).toHaveLength(operation === "setup" ? 1 : 0); + if (operation === "setup") { + expect(owned[0]?.config).toMatchObject({ env: { TOKEN: "keep-me" } }); + expect(await dshStatus(home)).toMatchObject({ state: "aligned" }); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("unchanged"); + } + expect(await readFile(`${client.configPath}.rea.backup`, "utf8")).toBe( + initial, + ); + }, + ); +}); + +describe("DeepSeek Harness ordered selector applicability", () => { + it.each(["before insertion", "before replacement"])( + "discards selector uncertainty %s", + async (position) => { + const { home, client } = await harnessClient(); + const selector = `- id: rea-mcp + name: !!js '"@deepseek-ai/dsh-mcp-client"' + config: {command: ignored-launcher, env: {IGNORED: discarded}} +`; + const insertion = stringifyYaml([{ insert: [canonicalEntry()] }]); + const initial = + position === "before insertion" + ? selector + insertion + : insertion + selector + insertion; + await writeFile(client.configPath, initial); + expect((await dshStatus(home))?.state).toBe( + position === "before insertion" ? "aligned" : "stale", + ); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe(position === "before insertion" ? "unchanged" : "configured"); + expect(await dshStatus(home)).toMatchObject({ state: "aligned" }); + const text = await readFile(client.configPath, "utf8"); + expect( + cordisPatches(text) + .flatMap((patch) => patch.insert ?? []) + .filter(({ id }) => id === "rea-mcp"), + ).toEqual([canonicalEntry()]); + if (position === "before insertion") expect(text).toBe(initial); + expect( + (await systemUninstallHost(home).removeClient(client)).status, + ).toBe("removed"); + expect(loadedInserts(await readFile(client.configPath, "utf8"))).toEqual( + [], + ); + }, + ); +}); + +describe("DeepSeek Harness caller node metadata", () => { + it.each(["environment", "launcher"])( + "preserves scalar and argument comments during %s repairs", + async (repair) => { + const { home, client } = await harnessClient(); + const entry = canonicalEntry(); + const yaml = parseDocument( + stringifyYaml([ + { + insert: [ + { + ...entry, + config: { + ...entry.config, + ...(repair === "launcher" ? { command: "stale" } : {}), + env: { TOKEN: "original" }, + }, + }, + ], + }, + ]), + ); + for (const path of [ + [0, "insert", 0, "id"], + [0, "insert", 0, "name"], + ...["serverName", "transport", "command", "args"].map((key) => [ + 0, + "insert", + 0, + "config", + key, + ]), + [0, "insert", 0, "config", "env", "TOKEN"], + ]) { + const node = yaml.getIn(path, true); + if (!isNode(node)) throw new Error("missing caller node"); + node.comment = ` caller ${path.at(-1)}`; + node.commentBefore = ` before ${path.at(-1)}`; + } + const args = yaml.getIn([0, "insert", 0, "config", "args"], true); + if (!isSeq(args) || !isNode(args.items[0])) + throw new Error("missing launcher arguments"); + args.items[0].comment = " caller argument"; + const initial = yaml.toString(); + await writeFile(client.configPath, initial); + expect( + ( + await configureClientConfiguration( + client, + { TOKEN: "updated" }, + command, + ) + ).status, + ).toBe("configured"); + const text = await readFile(client.configPath, "utf8"); + for (const key of [ + "id", + "name", + "serverName", + "transport", + "command", + "args", + "TOKEN", + ]) { + expect(text).toContain(`# caller ${key}`); + expect(text).toContain(`# before ${key}`); + } + expect(text).toContain("# caller argument"); + expect(loadedInserts(text)).toEqual([ + { + ...entry, + config: { ...entry.config, env: { TOKEN: "updated" } }, + }, + ]); + expect(await dshStatus(home)).toMatchObject({ state: "aligned" }); + expect( + ( + await configureClientConfiguration( + client, + { TOKEN: "updated" }, + command, + ) + ).status, + ).toBe("unchanged"); + expect(await readFile(client.configPath, "utf8")).toBe(text); + expect(await readFile(`${client.configPath}.rea.backup`, "utf8")).toBe( + initial, + ); + }, + ); +}); diff --git a/tests/fixtures/clientEnvironment.ts b/tests/fixtures/clientEnvironment.ts index 91ccb92cf..35fc6843c 100644 --- a/tests/fixtures/clientEnvironment.ts +++ b/tests/fixtures/clientEnvironment.ts @@ -15,6 +15,8 @@ export const CLIENT_LOCATION_ENVIRONMENT = [ "APPDATA", "CLAUDE_CONFIG_DIR", "CODEX_HOME", + "DSH_HOME", + "DSH_AGENTS_HOME", "COPILOT_HOME", "GROK_HOME", "GEMINI_CLI_HOME",