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",