diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 50f3dc0df..8300dcc00 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -59,7 +59,19 @@ jobs: with: python-version: ${{ matrix.python-version }} + - name: Set up project tools + uses: jdx/mise-action@c37c93293d6b742fc901e1406b8f764f6fb19dac # v2 + + - name: Install and resolve native DSH configuration test APIs + run: | + pnpm --dir integrations/dsh/plugins/powercontext/tests/config-runtime install --frozen-lockfile + boot="$(node --input-type=module -e "import { createRequire } from 'node:module'; import { resolve } from 'node:path'; const require = createRequire(resolve('integrations/dsh/plugins/powercontext/tests/config-runtime/package.json')); process.stdout.write(require.resolve('@deepseek-ai/dsh-app-boot'))")" + test -n "$boot" && test -f "$boot" || { echo "DSH configuration runtime unresolved"; exit 1; } + echo "DSH_TEST_CONFIG_BOOT=$boot" >> "$GITHUB_ENV" + - name: Run unit tests + env: + PYTEST_ADDOPTS: --require-dsh-config-runtime run: make unit-test - name: Run end-to-end tests @@ -237,6 +249,21 @@ jobs: - name: Test built plugin in the real DSH runtime run: make dsh-runtime-test + - name: Install native DSH configuration test APIs + run: pnpm --dir integrations/dsh/plugins/powercontext/tests/config-runtime install --frozen-lockfile + + - name: Resolve required DSH test runtimes + run: | + bin="$(node --input-type=module -e "import { dshBin } from './integrations/dsh/plugins/powercontext/tests/runtime/fixture.mjs'; process.stdout.write(dshBin)")" + test -n "$bin" && test -f "$bin" || { echo "DSH test executable unresolved"; exit 1; } + boot="$(node --input-type=module -e "import { createRequire } from 'node:module'; import { resolve } from 'node:path'; const require = createRequire(resolve('integrations/dsh/plugins/powercontext/tests/config-runtime/package.json')); process.stdout.write(require.resolve('@deepseek-ai/dsh-app-boot'))")" + test -n "$boot" && test -f "$boot" || { echo "DSH configuration runtime unresolved"; exit 1; } + echo "DSH_TEST_EXECUTABLE=$bin" >> "$GITHUB_ENV" + echo "DSH_TEST_CONFIG_BOOT=$boot" >> "$GITHUB_ENV" + + - name: Verify DSH setup with customized profiles + run: uv run pytest tests/test_dsh_transport.py --require-dsh-runtime + - name: Verify DSH guidance adapter requests and results run: uv run pytest "tests/test_integration_guidance_evaluation.py::test_native_adapter_handoff_uses_real_request_mapping_and_response_envelopes[dsh]" diff --git a/docs/en/docs/integrations/dsh.md b/docs/en/docs/integrations/dsh.md index 6727063d5..6ba2400f3 100644 --- a/docs/en/docs/integrations/dsh.md +++ b/docs/en/docs/integrations/dsh.md @@ -38,6 +38,19 @@ repeating that command does not update a moving branch. A broken checkout is rep `setup dsh` calls `dsh plugin --profile web add`; it does not start the Server. Restart DSH after installation. +Existing UI and model patches can remain in place. Setup checks the composed PowerContext connection settings; +see [remote connection configuration](../operate/connect-remote-server.md) for conflicts and dynamic configuration limits. +The check includes children of `group: true` groups and groups named `@deepseek-ai/cordis-plugin-group` or `cordis:group`; +multiple PowerContext entries are rejected before installation and during the installed-configuration check. +Native `@deepseek-ai/cordis-plugin-include` and `cordis:include` trees are checked with the selected host's +YAML/JSON parser and include patches. Relative paths start at the profile directory; nested includes resolve +from their containing file's directory. Unrelated UI/model expressions remain unevaluated during setup. +Enabled include files must already exist. Cycles, unsupported file types or URLs, dynamic paths or patch structure, +and unverifiable group children fail before connection settings or credentials are saved. + +An unset, empty, or whitespace-only `DSH_HOME` selects `~/.dsh`. A nonblank path keeps its leading and trailing +spaces. These rules apply to configuration inspection, stored credentials, and plugin dependency lookup. + ## Start the Server and the host For automatic Source-to-Memory extraction, generate and validate a Server configuration: diff --git a/docs/en/docs/operate/connect-remote-server.md b/docs/en/docs/operate/connect-remote-server.md index ebcd8e3dd..f777b1f67 100644 --- a/docs/en/docs/operate/connect-remote-server.md +++ b/docs/en/docs/operate/connect-remote-server.md @@ -69,10 +69,31 @@ Setup configures both the native MCP URL and hook endpoint for Codex, Claude Cod Codex hooks intentionally use the installed plugin's `.mcp.json`; rerun setup after a plugin update that replaces that file. Do not change only a hook URL environment variable and assume the native MCP URL changed too. -DSH custom `cordis.patch.yml` layers can override setup-managed settings. Setup accepts the standard empty -profile patch, but stops before installation when custom overlays are present: align the endpoint/consent -manually or remove those overrides before rerunning setup. Doctor reports unsupported native composition -(including unsupported JSON5/includes) as unknown/failed instead of claiming a safe loopback connection. +DSH setup preserves existing UI, model, and other unrelated `cordis.patch.yml` customizations. It uses the +installed DSH's native parser and patch composition to check PowerContext's `baseUrl` and `allowInsecureHttp`, +then checks the actual candidate against the complete bundle stack the native installer will enable, including +installed but inactive dependencies on DSH versions that reactivate them. Existing bundle order and the selected +CLI's activation rules are preserved. Matching settings pass; an override that would +undo the selected endpoint or HTTP consent must be aligned or removed. The check does not rewrite user patches, +start plugins, or evaluate `!!js`. Dynamic PowerContext transport fields, disabled/ambiguous entries, and unreadable +bundles are reported explicitly. PowerContext's own version incompatibility blocks setup. On DSH versions that +skip incompatible third-party bundles, setup warns and excludes their patch layers as the host does; explicit +version exemptions retain those layers and their transport overrides. Unrelated dynamic plugin configuration +does not block setup. + +After installation, setup reads the actual enabled configuration again before saving connection settings or +credentials. If the result cannot be verified or disagrees with the selected transport, setup fails and leaves +those settings unsaved; inspect the modified DSH profile before restarting. This readback does not roll back +native package installation. Standalone doctor checks only currently enabled bundles. + +Configuration inspection requires Node.js and an npm/pnpm DSH installation exposing the native composition APIs. +The APIs are loaded from that DSH installation's `@deepseek-ai/dsh-app-boot`, which DSH declares as its own +dependency. PowerContext does not install a replacement parser into the plugin. Missing packages or required +APIs produce upgrade/reinstallation guidance before installation effects. Profiles and credentials use the same +`DSH_HOME`; an unset, empty, or whitespace-only value uses `~/.dsh`. +The CLI observes the selected configuration files and its own environment, not a running DSH session's extra +`--patch` arguments or environment; use `/pc doctor` inside that session. Doctor reports unsupported native +composition (including unsupported JSON5/includes in other hosts) as unknown/failed rather than claiming safety. MiniMax and the generic Agent Plugin delegate MCP transport to the host and have no PowerContext setup subcommand or independent HTTP client. Their host may have its own restrictions; this flag cannot override host policy. diff --git a/docs/zh/docs/integrations/dsh.md b/docs/zh/docs/integrations/dsh.md index 7f2a5cd13..4000faed2 100644 --- a/docs/zh/docs/integrations/dsh.md +++ b/docs/zh/docs/integrations/dsh.md @@ -38,6 +38,18 @@ powercontext setup dsh --source ./powercontext-dsh-dev `setup dsh` 调用 `dsh plugin --profile web add`,不会启动 Server。安装完成后重启 DSH。 +已有界面和模型 patch 可以保留。setup 检查合成后的 PowerContext 连接设置; +冲突处理及动态配置限制见[远程连接配置](../operate/connect-remote-server.md)。 +检查会遍历 `group: true` 分组及名称为 `@deepseek-ai/cordis-plugin-group` 或 `cordis:group` 的分组内的子项; +安装前检查和安装后复查都会拒绝多个 PowerContext 条目。 +原生 `@deepseek-ai/cordis-plugin-include` 和 `cordis:include` 配置树使用所选宿主的 YAML/JSON 解析器及 +include patches 检查。相对路径从 profile 目录解析,嵌套 include 从所在文件的目录解析。 +setup 不求值无关的界面和模型表达式。启用的 include 文件必须已存在;循环引用、不支持的文件类型或 URL、 +动态路径或 patch 结构、无法验证的分组子项,都会在保存连接设置或凭据前报错。 + +`DSH_HOME` 未设置、为空或仅包含空白时使用 `~/.dsh`;非空路径保留开头和末尾的空格。 +配置检查、凭据保存与读取、插件依赖查找都遵循这两条规则。 + ## 启动 Server 和宿主 需要自动将 Source 提取为 Memory 时,生成并校验 Server 配置: diff --git a/docs/zh/docs/operate/connect-remote-server.md b/docs/zh/docs/operate/connect-remote-server.md index 0eac1c5cc..e9b34116b 100644 --- a/docs/zh/docs/operate/connect-remote-server.md +++ b/docs/zh/docs/operate/connect-remote-server.md @@ -65,9 +65,25 @@ Codex、Claude Code、WorkBuddy 的 setup 会同时配置原生 MCP 地址与 Ho Codex Hook 特意读取已安装插件中的 `.mcp.json`,插件升级覆盖该文件后需重新运行 setup。 只修改 Hook 的 URL 环境变量,不能视为宿主原生 MCP 的地址也已修改。 -DSH 自定义 `cordis.patch.yml` 可能覆盖 setup 保存的地址与同意状态。标准空白 profile patch 可直接安装; -检测到自定义覆盖层时,setup 会在安装前退出,请先手动对齐地址及同意设置,或移除覆盖后重试。 -对无法解析的原生配置组合(包括未支持的 JSON5/include),doctor 会报告未知/失败,不会假定连接是安全的环回地址。 +DSH setup 保留已有的界面、模型及其他无关 `cordis.patch.yml` 自定义配置。它使用已安装 DSH 的原生解析和合成接口, +检查 PowerContext 的 `baseUrl` 与 `allowInsecureHttp`,并在安装前用实际待安装 bundle 检查原生安装器最终会启用的 +完整 bundle 组合,包括旧版 DSH 会重新启用的已安装但未启用的依赖。检查保留原有 bundle 顺序,遵循所选 CLI 的启用规则。 +配置一致时允许安装;会覆盖所选地址或 HTTP 同意状态的设置,需要先对齐或移除。 +检查不会改写用户 patch、启动插件或执行 `!!js`。动态 PowerContext 连接字段、被禁用或不唯一的插件条目, +以及无法读取的 bundle 会明确报错。PowerContext 自身不兼容会阻止安装;如果所选 DSH 会跳过不兼容的第三方 bundle, +setup 会告警并同样排除其 patch。用户明确授权的版本豁免仍保留这些 patch,其连接覆盖也会继续检查。 +其他插件的动态配置不会因此阻止安装。 + +安装后,setup 会再次读取实际已启用的配置,验证通过才保存连接设置或凭据。若结果无法验证或与所选连接配置冲突, +setup 会失败并保留原有连接设置;请在重启前检查已改动的 DSH profile。这项读回检查不会回滚原生包安装。 +独立 doctor 只检查当前已启用的 bundle。 + +配置检查需要 Node.js,以及提供原生配置合成接口的 npm/pnpm DSH 安装。 +这些接口从所选 DSH 安装的 `@deepseek-ai/dsh-app-boot` 加载,该包由 DSH 自己声明依赖;PowerContext 不会向插件中 +安装另一份解析器来替代宿主。包或必需接口缺失时,会在安装前提示升级或重新安装 DSH。 +Profile 和凭据使用同一个 `DSH_HOME`;未设置、空值或纯空格均使用 `~/.dsh`。 +独立 CLI 只能观察所选配置文件和自身环境,无法观察运行中 DSH 会话额外的 `--patch` 参数或环境;请在该会话内运行 `/pc doctor`。 +对无法解析的原生配置组合(包括其他宿主未支持的 JSON5/include),doctor 会报告未知/失败,不会假定连接安全。 MiniMax 和通用 Agent Plugin 由宿主负责 MCP 传输,没有单独的 PowerContext HTTP Client 或 setup 子命令; 本选项不能绕过宿主自己的限制。LangChain、LangGraph、Pydantic AI 及 Bub 评测适配器支持客户端显式同意, diff --git a/integrations/dsh/plugins/powercontext/lib/index.js b/integrations/dsh/plugins/powercontext/lib/index.js index 8d198fbd3..4c33a709d 100644 --- a/integrations/dsh/plugins/powercontext/lib/index.js +++ b/integrations/dsh/plugins/powercontext/lib/index.js @@ -2841,13 +2841,14 @@ function contextAssembly(raw, fallback) { return structuredClone(value); } function storedAuthorization(env, baseUrl) { - const path = join(env.DSH_HOME?.trim() || join(homedir(), ".dsh"), "powercontext", "credentials.json"); + const configuredHome = env.DSH_HOME; + const path = join(configuredHome?.trim() ? configuredHome : join(homedir(), ".dsh"), "powercontext", "credentials.json"); try { if (process.platform !== "win32" && (statSync(path).mode & 63) !== 0) return void 0; const parsed = JSON.parse(readFileSync(path, "utf8")); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return void 0; const payload = parsed; - if (payload.version !== 1 || typeof payload.server_url !== "string" || stripSlash(payload.server_url) !== baseUrl) return void 0; + if (payload.version !== 1 || typeof payload.server_url !== "string" || normalizeServerUrl(payload.server_url, true) !== baseUrl) return void 0; if (typeof payload.authorization !== "string") return void 0; const authorization = payload.authorization; return /^Bearer [^\s]+$/.test(authorization) ? authorization : void 0; @@ -2882,7 +2883,8 @@ function resolveConfig(config = {}, env = process.env) { //#endregion //#region src/peers.ts function profileNodeModulesDir(env = process.env) { - return join(env.DSH_HOME?.trim() || join(homedir(), ".dsh"), "profiles", env.DSH_PROFILE?.trim() || "web", "node_modules"); + const configuredHome = env.DSH_HOME; + return join(configuredHome?.trim() ? configuredHome : join(homedir(), ".dsh"), "profiles", env.DSH_PROFILE?.trim() || "web", "node_modules"); } function profileModulesAnchor(env = process.env) { return join(profileNodeModulesDir(env), "powercontext-dsh-resolver.cjs"); diff --git a/integrations/dsh/plugins/powercontext/src/config.ts b/integrations/dsh/plugins/powercontext/src/config.ts index 29847017d..d52cdd53e 100644 --- a/integrations/dsh/plugins/powercontext/src/config.ts +++ b/integrations/dsh/plugins/powercontext/src/config.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { resolveTransport } from './transport.ts' +import { normalizeServerUrl, resolveTransport } from './transport.ts' import { readFileSync, statSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' @@ -97,14 +97,16 @@ function contextAssembly(raw: string | undefined, fallback?: Record - if (payload.version !== 1 || typeof payload.server_url !== 'string' || stripSlash(payload.server_url) !== baseUrl) return undefined + if (payload.version !== 1 || typeof payload.server_url !== 'string' + || normalizeServerUrl(payload.server_url, true) !== baseUrl) return undefined if (typeof payload.authorization !== 'string') return undefined const authorization = payload.authorization return /^Bearer [^\s]+$/.test(authorization) ? authorization : undefined diff --git a/integrations/dsh/plugins/powercontext/src/peers.ts b/integrations/dsh/plugins/powercontext/src/peers.ts index 0fce52eea..d21d35aaf 100644 --- a/integrations/dsh/plugins/powercontext/src/peers.ts +++ b/integrations/dsh/plugins/powercontext/src/peers.ts @@ -20,7 +20,8 @@ import { join } from 'node:path' import { pathToFileURL } from 'node:url' export function profileNodeModulesDir(env: NodeJS.ProcessEnv = process.env): string { - const home = env.DSH_HOME?.trim() || join(homedir(), '.dsh') + const configuredHome = env.DSH_HOME + const home = configuredHome?.trim() ? configuredHome : join(homedir(), '.dsh') const profile = env.DSH_PROFILE?.trim() || 'web' return join(home, 'profiles', profile, 'node_modules') } diff --git a/integrations/dsh/plugins/powercontext/tests/config-runtime/package.json b/integrations/dsh/plugins/powercontext/tests/config-runtime/package.json new file mode 100644 index 000000000..ff5d59b8d --- /dev/null +++ b/integrations/dsh/plugins/powercontext/tests/config-runtime/package.json @@ -0,0 +1,8 @@ +{ + "name": "powercontext-dsh-config-runtime-tests", + "private": true, + "type": "module", + "devDependencies": { + "@deepseek-ai/dsh-app-boot": "0.2.0-rc.2" + } +} diff --git a/integrations/dsh/plugins/powercontext/tests/config-runtime/pnpm-lock.yaml b/integrations/dsh/plugins/powercontext/tests/config-runtime/pnpm-lock.yaml new file mode 100644 index 000000000..3f2c7dbf6 --- /dev/null +++ b/integrations/dsh/plugins/powercontext/tests/config-runtime/pnpm-lock.yaml @@ -0,0 +1,455 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@deepseek-ai/dsh-app-boot': + specifier: 0.2.0-rc.2 + version: 0.2.0-rc.2(@deepseek-ai/cordis-plugin-group@1.0.4(@deepseek-ai/cordis-plugin-loader@1.0.5)(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/cordis-plugin-include@1.0.9)(@deepseek-ai/cordis-plugin-loader@1.0.5)(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-home-paths@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/dsh-launch-environment@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/dsh-system-prompt@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/dsh-llm@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/dsh-scope@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)))) + +packages: + + '@deepseek-ai/cordis-plugin-group@1.0.4': + resolution: {integrity: sha512-/7tuY5pMQetHava4v6QUeOa4CwZA+mleNvbIoj+iqEmzkYUtlXbLqhzJIcbnDa/Hxgr1jaCwROrI77nuLyeCFg==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.4 + '@deepseek-ai/cordis-plugin-loader': ~1.0.5 + + '@deepseek-ai/cordis-plugin-include@1.0.9': + resolution: {integrity: sha512-Xmekm7u5Kpeym6nABL4T1/Mg6aQYFZxsipB0WycmCctvX1DqAv8nCFwRmg2XMx/f7EbZgNEbXQwJfabcMu8zxQ==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.4 + '@deepseek-ai/cordis-plugin-loader': ~1.0.5 + + '@deepseek-ai/cordis-plugin-loader@1.0.5': + resolution: {integrity: sha512-200huL2ttWcjyeYllvPYn+qMR7I89qy8MhZasbHltPzB/KMrSfsyoCYKdCGc5KqPGj5iJugcmibQzjINMO9I+Q==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.4 + node-addon-require-builtin: ^0.1.6 + peerDependenciesMeta: + node-addon-require-builtin: + optional: true + + '@deepseek-ai/cordis@4.0.4': + resolution: {integrity: sha512-obgyxqWAmFn3Re8kvsuUnyW+ihrz6eJCnJO4fh1cQzDtmPYz/zzVeUkH9R94I0OwSVOocK67Kgakm04j/oQXzg==} + hasBin: true + peerDependencies: + '@deepseek-ai/cordis-plugin-include': ~1.0.9 + '@deepseek-ai/cordis-plugin-loader': ~1.0.5 + peerDependenciesMeta: + '@deepseek-ai/cordis-plugin-include': + optional: true + '@deepseek-ai/cordis-plugin-loader': + optional: true + + '@deepseek-ai/cosmokit@1.8.5': + resolution: {integrity: sha512-LXsrlem9z8dq4sLflj2yuCuYX7KqhdzF5hZly3eZyuTo8d6oDSOXuEgC5DbQWKW+lksm6zmOutQLsP/ma6wR6A==} + + '@deepseek-ai/dsh-app-boot@0.2.0-rc.2': + resolution: {integrity: sha512-WvgNhBHSj85Z7u9vC1oQr9C7yZQ/L/JS+eVK4bueWEkaoqdEna504V5bi7qtWXxAz5JkeVZTy1sX86p8XG7LJg==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.4 + '@deepseek-ai/cordis-plugin-group': ~1.0.4 + '@deepseek-ai/cordis-plugin-include': ~1.0.9 + '@deepseek-ai/cordis-plugin-loader': ~1.0.5 + '@deepseek-ai/dsh-home-paths': 0.2.0-rc.2 + '@deepseek-ai/dsh-launch-environment': 0.2.0-rc.2 + '@deepseek-ai/dsh-system-prompt': 0.2.0-rc.2 + + '@deepseek-ai/dsh-atomic-write@0.2.0-rc.2': + resolution: {integrity: sha512-CezxdPmADDnOyG/ed9R18In/NifnLgu8hfJEjTBKxd7kbu0XXenyqnk25jeWD6bJYwu8AWAwcpVFuf438EJPJA==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.4 + + '@deepseek-ai/dsh-brand@0.2.0-rc.2': + resolution: {integrity: sha512-A5XlN4tIgP0qObkYFhcZ7Oz/tM2oESEZqLwS0Qq+sM1ivGx6o4AMPgJdfKQlwLzqk0cMLNtAaVBHfiYoElt4lw==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.4 + + '@deepseek-ai/dsh-home-paths@0.2.0-rc.2': + resolution: {integrity: sha512-n4O5C9pNIVWbBklB5RzSTc3kr9hmUnDlZ6Leqj3TIk+CO593DTAmDGTt0rHMQe8H+1Ur+Jqj9Hqgsug/WRkkvQ==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.4 + + '@deepseek-ai/dsh-invariants@0.2.0-rc.2': + resolution: {integrity: sha512-QOVc0HpRsS8ekvprb45FlxieDvgR/cAMaxW289HASjMoV/U/B54FpbkHuPBw4EnTw07/sPkjx2B/23+YqE0cbg==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.4 + + '@deepseek-ai/dsh-launch-environment@0.2.0-rc.2': + resolution: {integrity: sha512-IyhZjJJQ7RVLcKlc4pPPjojOKrr2XCYm7VRpslFq7bvGj3DGt8WMTSCCZUWxCThY2jg6kFF1ujiTGunELfRO6Q==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.4 + + '@deepseek-ai/dsh-llm@0.2.0-rc.2': + resolution: {integrity: sha512-6QFrQn/h0iNqCfPpgQidnHtLSpA99A7ZND/uYKaGSd/4BGP8KBhuRd0w63e9s4u+hfW8jc3j29WKcNLIOeUfcA==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.4 + + '@deepseek-ai/dsh-package-manifest@0.2.0-rc.2': + resolution: {integrity: sha512-CFycoLxUT4g4w1IGjaHi/IjTzTRgnQ2PQVeHHSWoEppD+0NTZ1rHHW3ofkh74ZgQagoeo7n+91Yjl0rDGuWE3g==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.4 + + '@deepseek-ai/dsh-scope@0.2.0-rc.2': + resolution: {integrity: sha512-066p7Wv74GNWt4xA5Cyvl+7c/i9KrNEDALYh2/hu0VsJPLb4T9He+TY7y1bld9OgungmqyTy1n/cB2ZrVskvxA==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.4 + '@deepseek-ai/dsh-invariants': 0.2.0-rc.2 + + '@deepseek-ai/dsh-system-prompt@0.2.0-rc.2': + resolution: {integrity: sha512-ZZNk2gkXFV8CIT0IOZXS+pFaxNVJaRknYyH2vc4ZgZAiHKT+RqUYNr8e1F5Kp0AaN0DNlcTkjOGP5MCxeXfDjg==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.4 + '@deepseek-ai/dsh-invariants': 0.2.0-rc.2 + '@deepseek-ai/dsh-llm': 0.2.0-rc.2 + '@deepseek-ai/dsh-scope': 0.2.0-rc.2 + + '@deepseek-ai/dsh-timeout@0.2.0-rc.2': + resolution: {integrity: sha512-NDob8b8DJDB/sDPz3P7xhrkxOh1O9FbN1EZRKsxP4fuTfp4hrQU7aekYS5qZENFKoR8dBJMTh/zcEBw4m31glQ==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.4 + + '@deepseek-ai/dsh-typert-protocol@0.2.0-rc.2': + resolution: {integrity: sha512-95RvIDcVac2BWdv1IxN6yKlRV+8eEvkofqSxV+d0r8x1kx2EIWEsbdxz6C+coYIm+8e03MiN+rwOt8J7sLfe7Q==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.4 + + '@deepseek-ai/dsh-util-crypto@0.2.0-rc.2': + resolution: {integrity: sha512-5OvvmepY69uXTagUMlnoHqg7euI4ZMifLgfdV8xZO20OslWGGiRCkJbZRyDtcJG9GsXWz9UimfWmUu52zKXJ5w==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.4 + + '@deepseek-ai/dsh-util-values@0.2.0-rc.2': + resolution: {integrity: sha512-qgpsbgiZABuE9UvgNaVo9b2HiWwLeU93CTl9PFXqE0jN8ap1ALxzQsLGvr/q2MJuWDYqnFBg60XnqnY8ZxPWEg==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.4 + + '@deepseek-ai/schemastery@3.18.4': + resolution: {integrity: sha512-SSXO6tYuyrIqKVbmOnIq0s+riUywYzouFMcnBluHF9n4KMo2G8HvEzhCYj6pj2617/glOjbJuVInJ9hfONsjkg==} + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-uri@3.1.8: + resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} + + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} + hasBin: true + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + node-addon-native-custom-loader@0.1.7: + resolution: {integrity: sha512-mny2aBXuqWZLu8xFg0Azk6HZd8tlEBbpkOMhideAY3OABE9tp38VeKo8/sw5g/qeYAAdE8oEmsmeJ5IBJd6YHg==} + engines: {node: '>=20'} + + node-addon-require-builtin-darwin-arm64@0.1.7: + resolution: {integrity: sha512-3n5SgmD6S4lCyeFPumbwfUh+YLssWiSF8GuToHdr00hBPhDikyzHCy2UV5qJUTDNNtUpfw9QuFijsC+ulnvmog==} + engines: {node: '>=20'} + cpu: [arm64] + os: [darwin] + + node-addon-require-builtin-darwin-x64@0.1.7: + resolution: {integrity: sha512-ZelNDiBgGH//ZHw6FCv4VdWUxV2YwdZNa0za7nOJIYodGcow0rJX6san45iOI73/gd2qtyKBOrHgylDjnBblbg==} + engines: {node: '>=20'} + cpu: [x64] + os: [darwin] + + node-addon-require-builtin-linux-arm64-gnu@0.1.7: + resolution: {integrity: sha512-osAdFdxBUeciYOAIMxxIZzVyPK73zex7xJvSAyfDPyY8cTxhGZVZGQnB0ZtZEy3TRQQ2psjWhu5HbcHXIXotXw==} + engines: {node: '>=20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + node-addon-require-builtin-linux-arm64-musl@0.1.7: + resolution: {integrity: sha512-pN027xk8o64/i1evlncK3VoPGdnflHzMgD+MQ+vApMpu2U6XKc9KAqKiImTpMd+mtGxElkkhD/AXNybF0MscZw==} + engines: {node: '>=20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + node-addon-require-builtin-linux-x64-gnu@0.1.7: + resolution: {integrity: sha512-HedJA3MGD7I3rH/mPuPIaGTTtSm61L5uHv/90xHPy0tHGlP5cxC9Dt9l/5iwIrTE04LrT0woiG8Ef2Eg2sRyuA==} + engines: {node: '>=20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + node-addon-require-builtin-linux-x64-musl@0.1.7: + resolution: {integrity: sha512-4SkkvMTQqwFRkNzWp8Jc+1e/wMzA893YYX6lhRFFXWcb+igpXq/KD92JEWI0RHr9rrQkwF51/9odyt0Jk9WnDA==} + engines: {node: '>=20'} + cpu: [x64] + os: [linux] + libc: [musl] + + node-addon-require-builtin-win32-arm64-msvc@0.1.7: + resolution: {integrity: sha512-nooBYRrieUMClXU3HBM+9yfOEXC0EJS3kFGJzu7oXocOgADKmAwt3xpF3YunjnyN9bty9/QaoZRlHG5EBX4Usg==} + engines: {node: '>=20'} + cpu: [arm64] + os: [win32] + + node-addon-require-builtin-win32-ia32-msvc@0.1.7: + resolution: {integrity: sha512-i3/cymlUwzReJTtFLC75m+Gabi1RWa4ym4KgVThGLySy4/EJ0QldTv5VWHg3i7HP/g8gIWXwFrxTFk5v2DGchA==} + engines: {node: '>=20 <23'} + cpu: [ia32] + os: [win32] + + node-addon-require-builtin-win32-x64-msvc@0.1.7: + resolution: {integrity: sha512-iwYELawC0J+nKFTIfJumU53VzTVDshSFUZCU6oUb4Uw+JAfsXCCA2WaSQbtmHng6ub0HEFqWgfh/Wp+qsEVtNg==} + engines: {node: '>=20'} + cpu: [x64] + os: [win32] + + node-addon-require-builtin@0.1.7: + resolution: {integrity: sha512-2/yiwaMEpIJdrsjwY/09IoLMLKJtyJGlPhtJd80a+B4x0+SYsnAorg5GlDyJHfTb/UgiIYzbNOgL3HHrigFaAQ==} + engines: {node: '>=20'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve.exports@2.0.3: + resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} + engines: {node: '>=10'} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + zod@4.6.5: + resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} + +snapshots: + + '@deepseek-ai/cordis-plugin-group@1.0.4(@deepseek-ai/cordis-plugin-loader@1.0.5)(@deepseek-ai/cordis@4.0.4)': + dependencies: + '@deepseek-ai/cordis': 4.0.4(@deepseek-ai/cordis-plugin-include@1.0.9)(@deepseek-ai/cordis-plugin-loader@1.0.5) + '@deepseek-ai/cordis-plugin-loader': 1.0.5(@deepseek-ai/cordis@4.0.4)(node-addon-require-builtin@0.1.7) + + '@deepseek-ai/cordis-plugin-include@1.0.9(@deepseek-ai/cordis-plugin-loader@1.0.5)(@deepseek-ai/cordis@4.0.4)': + dependencies: + '@deepseek-ai/cordis': 4.0.4(@deepseek-ai/cordis-plugin-include@1.0.9)(@deepseek-ai/cordis-plugin-loader@1.0.5) + '@deepseek-ai/cordis-plugin-loader': 1.0.5(@deepseek-ai/cordis@4.0.4)(node-addon-require-builtin@0.1.7) + '@deepseek-ai/cosmokit': 1.8.5 + js-yaml: 4.3.2 + + '@deepseek-ai/cordis-plugin-loader@1.0.5(@deepseek-ai/cordis@4.0.4)(node-addon-require-builtin@0.1.7)': + dependencies: + '@deepseek-ai/cordis': 4.0.4(@deepseek-ai/cordis-plugin-include@1.0.9)(@deepseek-ai/cordis-plugin-loader@1.0.5) + '@deepseek-ai/cosmokit': 1.8.5 + '@deepseek-ai/schemastery': 3.18.4 + optionalDependencies: + node-addon-require-builtin: 0.1.7 + + '@deepseek-ai/cordis@4.0.4(@deepseek-ai/cordis-plugin-include@1.0.9)(@deepseek-ai/cordis-plugin-loader@1.0.5)': + dependencies: + '@deepseek-ai/cosmokit': 1.8.5 + '@standard-schema/spec': 1.1.0 + optionalDependencies: + '@deepseek-ai/cordis-plugin-include': 1.0.9(@deepseek-ai/cordis-plugin-loader@1.0.5)(@deepseek-ai/cordis@4.0.4) + '@deepseek-ai/cordis-plugin-loader': 1.0.5(@deepseek-ai/cordis@4.0.4)(node-addon-require-builtin@0.1.7) + + '@deepseek-ai/cosmokit@1.8.5': {} + + '@deepseek-ai/dsh-app-boot@0.2.0-rc.2(@deepseek-ai/cordis-plugin-group@1.0.4(@deepseek-ai/cordis-plugin-loader@1.0.5)(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/cordis-plugin-include@1.0.9)(@deepseek-ai/cordis-plugin-loader@1.0.5)(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-home-paths@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/dsh-launch-environment@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/dsh-system-prompt@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/dsh-llm@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/dsh-scope@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))))': + dependencies: + '@deepseek-ai/cordis': 4.0.4(@deepseek-ai/cordis-plugin-include@1.0.9)(@deepseek-ai/cordis-plugin-loader@1.0.5) + '@deepseek-ai/cordis-plugin-group': 1.0.4(@deepseek-ai/cordis-plugin-loader@1.0.5)(@deepseek-ai/cordis@4.0.4) + '@deepseek-ai/cordis-plugin-include': 1.0.9(@deepseek-ai/cordis-plugin-loader@1.0.5)(@deepseek-ai/cordis@4.0.4) + '@deepseek-ai/cordis-plugin-loader': 1.0.5(@deepseek-ai/cordis@4.0.4)(node-addon-require-builtin@0.1.7) + '@deepseek-ai/dsh-atomic-write': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4) + '@deepseek-ai/dsh-home-paths': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4) + '@deepseek-ai/dsh-launch-environment': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4) + '@deepseek-ai/dsh-package-manifest': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4) + '@deepseek-ai/dsh-system-prompt': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/dsh-llm@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/dsh-scope@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))) + '@eslint-community/regexpp': 4.12.2 + ajv: 8.20.0 + js-yaml: 4.3.2 + node-addon-require-builtin: 0.1.7 + resolve.exports: 2.0.3 + semver: 7.8.5 + + '@deepseek-ai/dsh-atomic-write@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)': + dependencies: + '@deepseek-ai/cordis': 4.0.4(@deepseek-ai/cordis-plugin-include@1.0.9)(@deepseek-ai/cordis-plugin-loader@1.0.5) + + '@deepseek-ai/dsh-brand@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)': + dependencies: + '@deepseek-ai/cordis': 4.0.4(@deepseek-ai/cordis-plugin-include@1.0.9)(@deepseek-ai/cordis-plugin-loader@1.0.5) + + '@deepseek-ai/dsh-home-paths@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)': + dependencies: + '@deepseek-ai/cordis': 4.0.4(@deepseek-ai/cordis-plugin-include@1.0.9)(@deepseek-ai/cordis-plugin-loader@1.0.5) + + '@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)': + dependencies: + '@deepseek-ai/cordis': 4.0.4(@deepseek-ai/cordis-plugin-include@1.0.9)(@deepseek-ai/cordis-plugin-loader@1.0.5) + '@deepseek-ai/schemastery': 3.18.4 + + '@deepseek-ai/dsh-launch-environment@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)': + dependencies: + '@deepseek-ai/cordis': 4.0.4(@deepseek-ai/cordis-plugin-include@1.0.9)(@deepseek-ai/cordis-plugin-loader@1.0.5) + + '@deepseek-ai/dsh-llm@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)': + dependencies: + '@deepseek-ai/cordis': 4.0.4(@deepseek-ai/cordis-plugin-include@1.0.9)(@deepseek-ai/cordis-plugin-loader@1.0.5) + '@deepseek-ai/dsh-brand': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4) + '@deepseek-ai/dsh-timeout': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4) + '@deepseek-ai/dsh-typert-protocol': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4) + '@deepseek-ai/dsh-util-crypto': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4) + '@deepseek-ai/dsh-util-values': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4) + '@deepseek-ai/schemastery': 3.18.4 + zod: 4.6.5 + + '@deepseek-ai/dsh-package-manifest@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)': + dependencies: + '@deepseek-ai/cordis': 4.0.4(@deepseek-ai/cordis-plugin-include@1.0.9)(@deepseek-ai/cordis-plugin-loader@1.0.5) + + '@deepseek-ai/dsh-scope@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))': + dependencies: + '@deepseek-ai/cordis': 4.0.4(@deepseek-ai/cordis-plugin-include@1.0.9)(@deepseek-ai/cordis-plugin-loader@1.0.5) + '@deepseek-ai/dsh-invariants': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4) + + '@deepseek-ai/dsh-system-prompt@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/dsh-llm@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/dsh-scope@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)))': + dependencies: + '@deepseek-ai/cordis': 4.0.4(@deepseek-ai/cordis-plugin-include@1.0.9)(@deepseek-ai/cordis-plugin-loader@1.0.5) + '@deepseek-ai/dsh-invariants': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4) + '@deepseek-ai/dsh-llm': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4) + '@deepseek-ai/dsh-scope': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)) + '@deepseek-ai/schemastery': 3.18.4 + + '@deepseek-ai/dsh-timeout@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)': + dependencies: + '@deepseek-ai/cordis': 4.0.4(@deepseek-ai/cordis-plugin-include@1.0.9)(@deepseek-ai/cordis-plugin-loader@1.0.5) + + '@deepseek-ai/dsh-typert-protocol@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)': + dependencies: + '@deepseek-ai/cordis': 4.0.4(@deepseek-ai/cordis-plugin-include@1.0.9)(@deepseek-ai/cordis-plugin-loader@1.0.5) + '@deepseek-ai/dsh-brand': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4) + + '@deepseek-ai/dsh-util-crypto@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)': + dependencies: + '@deepseek-ai/cordis': 4.0.4(@deepseek-ai/cordis-plugin-include@1.0.9)(@deepseek-ai/cordis-plugin-loader@1.0.5) + + '@deepseek-ai/dsh-util-values@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)': + dependencies: + '@deepseek-ai/cordis': 4.0.4(@deepseek-ai/cordis-plugin-include@1.0.9)(@deepseek-ai/cordis-plugin-loader@1.0.5) + + '@deepseek-ai/schemastery@3.18.4': + dependencies: + '@deepseek-ai/cosmokit': 1.8.5 + '@standard-schema/spec': 1.1.0 + + '@eslint-community/regexpp@4.12.2': {} + + '@standard-schema/spec@1.1.0': {} + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.8 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + argparse@2.0.1: {} + + fast-deep-equal@3.1.3: {} + + fast-uri@3.1.8: {} + + js-yaml@4.3.2: + dependencies: + argparse: 2.0.1 + + json-schema-traverse@1.0.0: {} + + node-addon-native-custom-loader@0.1.7: {} + + node-addon-require-builtin-darwin-arm64@0.1.7: + dependencies: + node-addon-native-custom-loader: 0.1.7 + optional: true + + node-addon-require-builtin-darwin-x64@0.1.7: + dependencies: + node-addon-native-custom-loader: 0.1.7 + optional: true + + node-addon-require-builtin-linux-arm64-gnu@0.1.7: + dependencies: + node-addon-native-custom-loader: 0.1.7 + optional: true + + node-addon-require-builtin-linux-arm64-musl@0.1.7: + dependencies: + node-addon-native-custom-loader: 0.1.7 + optional: true + + node-addon-require-builtin-linux-x64-gnu@0.1.7: + dependencies: + node-addon-native-custom-loader: 0.1.7 + optional: true + + node-addon-require-builtin-linux-x64-musl@0.1.7: + dependencies: + node-addon-native-custom-loader: 0.1.7 + optional: true + + node-addon-require-builtin-win32-arm64-msvc@0.1.7: + dependencies: + node-addon-native-custom-loader: 0.1.7 + optional: true + + node-addon-require-builtin-win32-ia32-msvc@0.1.7: + dependencies: + node-addon-native-custom-loader: 0.1.7 + optional: true + + node-addon-require-builtin-win32-x64-msvc@0.1.7: + dependencies: + node-addon-native-custom-loader: 0.1.7 + optional: true + + node-addon-require-builtin@0.1.7: + dependencies: + node-addon-native-custom-loader: 0.1.7 + optionalDependencies: + node-addon-require-builtin-darwin-arm64: 0.1.7 + node-addon-require-builtin-darwin-x64: 0.1.7 + node-addon-require-builtin-linux-arm64-gnu: 0.1.7 + node-addon-require-builtin-linux-arm64-musl: 0.1.7 + node-addon-require-builtin-linux-x64-gnu: 0.1.7 + node-addon-require-builtin-linux-x64-musl: 0.1.7 + node-addon-require-builtin-win32-arm64-msvc: 0.1.7 + node-addon-require-builtin-win32-ia32-msvc: 0.1.7 + node-addon-require-builtin-win32-x64-msvc: 0.1.7 + + require-from-string@2.0.2: {} + + resolve.exports@2.0.3: {} + + semver@7.8.5: {} + + zod@4.6.5: {} diff --git a/integrations/dsh/plugins/powercontext/tests/config-runtime/pnpm-workspace.yaml b/integrations/dsh/plugins/powercontext/tests/config-runtime/pnpm-workspace.yaml new file mode 100644 index 000000000..dfa1a49a7 --- /dev/null +++ b/integrations/dsh/plugins/powercontext/tests/config-runtime/pnpm-workspace.yaml @@ -0,0 +1,18 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +packages: + - . + +autoInstallPeers: true diff --git a/integrations/dsh/plugins/powercontext/tests/peers.spec.ts b/integrations/dsh/plugins/powercontext/tests/peers.spec.ts index 997c318aa..30ca35c83 100644 --- a/integrations/dsh/plugins/powercontext/tests/peers.spec.ts +++ b/integrations/dsh/plugins/powercontext/tests/peers.spec.ts @@ -19,6 +19,10 @@ import { describe, expect, it } from 'vitest' import { profileNodeModulesDir } from '../src/peers.ts' describe('profileNodeModulesDir', () => { + it.each(['/tmp/dsh-home ', ' /tmp/dsh-home'])('preserves the nonblank home %j', (home) => { + expect(profileNodeModulesDir({ DSH_HOME: home })).toBe(join(home, 'profiles', 'web', 'node_modules')) + }) + it('resolves peer modules under the web profile by default', () => { expect(profileNodeModulesDir({ DSH_HOME: '/tmp/dsh-home' } as NodeJS.ProcessEnv)).toBe( join('/tmp/dsh-home', 'profiles', 'web', 'node_modules'), diff --git a/integrations/dsh/plugins/powercontext/tests/runtime/README.md b/integrations/dsh/plugins/powercontext/tests/runtime/README.md index 1ce0b208d..b0308ecc8 100644 --- a/integrations/dsh/plugins/powercontext/tests/runtime/README.md +++ b/integrations/dsh/plugins/powercontext/tests/runtime/README.md @@ -21,6 +21,33 @@ The test package pins `@deepseek-ai/dsh-sdk-client@0.1.2-rc.1` and the resolved lockfile. Use Node 22.19+ (CI: 22.19.0; also tested on Windows with 24.14.1). Install peers in this isolated package; the plugin's ordinary unit-test installation deliberately does not install its optional host peers. +`../config-runtime` separately pins `@deepseek-ai/dsh-app-boot@0.2.0-rc.2` for native configuration/compatibility +checks; install it with `pnpm --dir integrations/dsh/plugins/powercontext/tests/config-runtime install --frozen-lockfile`. +Its own dependency graph keeps the 0.2 API peers separate from the 0.1.2 SDK host. +`tests/test_dsh_transport.py` loads those real APIs through a synthetic carrier in disposable profiles; those +checks do not execute a 0.2 CLI installation or a Desktop host. They verify omitted incompatible third-party +layers, rejected incompatible PowerContext bundles, and explicit version exemptions. The SDK continues to use +its matched 0.1.2 runtime. Setup reads the selected installation's own configuration APIs, not this test copy. + +Run the native configuration regressions without installing a DSH CLI: + +```bash +pnpm --dir integrations/dsh/plugins/powercontext/tests/config-runtime install --frozen-lockfile +uv run pytest tests/test_dsh_transport.py --require-dsh-config-runtime +``` + +The Python 3.11–3.14 CI matrix installs this locked configuration package, resolves `DSH_TEST_CONFIG_BOOT`, +and requires it while running the unit suite. Missing APIs fail instead of silently skipping their tests; +tests that need the DSH CLI can still skip when that host is absent. +Native configuration regressions cover all supported group forms (`group: true`, +`@deepseek-ai/cordis-plugin-group`, and `cordis:group`) through setup and installed readback, +including duplicate instances, nested includes, disabled children and conditional activation. + +Run `uv run pytest tests/test_dsh_transport.py --require-dsh-runtime` with `DSH_TEST_EXECUTABLE` selecting the +installed DSH CLI. The `dsh-package` CI job resolves both runtimes before running that command and fails if +either is unavailable. +The file also retains ordinary Python transport-policy tests that require no DSH installation. + These tests launch the real `dsh --profile sdk` subprocess and a real PowerContext Server with isolated homes. Only the built distributable plugin files are installed. Host tool registration, pre-step processing, message construction, model request assembly, and session persistence are not replaced. A loopback model fixture provides @@ -30,6 +57,16 @@ failures at individual PowerContext endpoints. The setup scenario first runs `powercontext setup dsh --source ` with the pinned DSH executable and a clean DSH home. It verifies Web-profile registration through `powercontext doctor dsh --json`, then loads that installed package's distributable files into the SDK profile. The Server and plugin use the same checkout. +A separate setup acceptance installs twice into an existing Web profile with a non-empty, unrelated model patch, +checks registration through the real CLI, and verifies the patch is preserved. Run it independently with +`node --test setup.test.mjs`; it does not require an SDK conversation or external model service. +The native include regression starts a minimal real DSH profile with a recording plugin. It confirms that +named groups and nested relative YAML/JSON includes, with include-local patches, load two endpoint configurations; +public setup then rejects those entries while preserving the saved endpoint and credentials. +Replacing the extra PowerContext entry with UI/model configuration allows setup and a subsequent real host +startup; include files retain their original bytes and the native host evaluates the model expression. +The Python configuration tests also cover installed readback after an injected installation change; that injection is +not a real native installation race. A test-only loopback adapter invokes the real host command service because the pinned SDK protocol only exposes prompts. It verifies `/pc doctor` with an environment URL overriding an unusable patch URL, preserves health when Scope authentication fails, and confirms that Doctor makes no capture or flush requests. diff --git a/integrations/dsh/plugins/powercontext/tests/runtime/package.json b/integrations/dsh/plugins/powercontext/tests/runtime/package.json index c9e1f2220..d3e4c8645 100644 --- a/integrations/dsh/plugins/powercontext/tests/runtime/package.json +++ b/integrations/dsh/plugins/powercontext/tests/runtime/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "test": "node --test --test-concurrency=1 runtime.test.mjs", + "test": "node --test --test-concurrency=1 setup.test.mjs runtime.test.mjs", "test:real": "node --test real-model.test.mjs" }, "devDependencies": { diff --git a/integrations/dsh/plugins/powercontext/tests/runtime/runtime.test.mjs b/integrations/dsh/plugins/powercontext/tests/runtime/runtime.test.mjs index e05d89c61..dd7285430 100644 --- a/integrations/dsh/plugins/powercontext/tests/runtime/runtime.test.mjs +++ b/integrations/dsh/plugins/powercontext/tests/runtime/runtime.test.mjs @@ -19,13 +19,13 @@ import { readdirSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { test } from 'node:test' import { environment, injected, CANARY } from './fixture.mjs' -import { installIntoCleanHome } from './setup-fixture.mjs' +import { installIntoHome } from './setup-fixture.mjs' import { registerSkill } from '../../src/skill.ts' test('documented setup installs the matched plugin, diagnoses the running host and recalls processed Source', { timeout: 240000 }, async () => { const env = await environment() try { - const installation = await installIntoCleanHome(env.home) + const installation = await installIntoHome(env.home) assert.ok(installation.setup.includes('powercontext-dsh')) assert.equal(installation.doctor.checks.plugin.checks.registration, 'present') assert.equal(installation.doctor.checks.plugin.checks.running_host_configuration, 'not_observed') diff --git a/integrations/dsh/plugins/powercontext/tests/runtime/setup-fixture.mjs b/integrations/dsh/plugins/powercontext/tests/runtime/setup-fixture.mjs index 714c1ab14..8b5faa179 100644 --- a/integrations/dsh/plugins/powercontext/tests/runtime/setup-fixture.mjs +++ b/integrations/dsh/plugins/powercontext/tests/runtime/setup-fixture.mjs @@ -15,13 +15,14 @@ */ import { execFile } from 'node:child_process' -import { chmodSync, mkdirSync, writeFileSync } from 'node:fs' +import assert from 'node:assert/strict' +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { delimiter, join } from 'node:path' import { promisify } from 'node:util' import { defaultPowerContextRoot } from '../../scripts/e2e-server.mjs' import { dshBin } from './fixture.mjs' -export async function installIntoCleanHome(home) { +export function setupFixture(home, { dshHome = join(home, 'installed-dsh') } = {}) { const bin = join(home, 'bin') mkdirSync(bin) const windows = process.platform === 'win32' @@ -32,15 +33,35 @@ export async function installIntoCleanHome(home) { : '#!/bin/sh\nexec ' + quote(process.execPath) + ' ' + quote(dshBin) + ' "$@"\n') if (!windows) chmodSync(launcher, 0o755) const root = defaultPowerContextRoot() + const inherited = Object.fromEntries(Object.entries(process.env) + .filter(([key]) => !key.toUpperCase().startsWith('POWERCONTEXT_') && !key.toUpperCase().startsWith('DSH_'))) const env = { - ...process.env, CI: 'true', DSH_HOME: join(home, 'installed-dsh'), + ...inherited, CI: 'true', DSH_HOME: dshHome, POWERCONTEXT_HOME: join(home, 'installed-powercontext'), + POWERCONTEXT_CLIENT_CONFIG_FILE: join(home, 'clients.json'), PATH: bin + delimiter + process.env.PATH, DSH_TELEMETRY_DISABLED: '1', } + const options = { cwd: home, env, windowsHide: true, timeout: 150000, maxBuffer: 4 * 1024 * 1024 } const cli = async args => (await promisify(execFile)(windows ? 'uv.exe' : 'uv', - ['run', '--no-sync', 'powercontext', ...args], - { cwd: root, env, windowsHide: true, timeout: 150000, maxBuffer: 4 * 1024 * 1024 })).stdout + ['run', '--project', root, '--no-sync', 'powercontext', ...args], options)).stdout + const native = async args => (await promisify(execFile)(process.execPath, [dshBin, ...args], options)).stdout + const profile = join(env.DSH_HOME, 'profiles/web') + return { root, cli, native, env, profile, patch: join(profile, 'cordis.patch.yml'), + homePatch: join(env.DSH_HOME, 'cordis.patch.yml'), clients: env.POWERCONTEXT_CLIENT_CONFIG_FILE } +} + +export async function installIntoHome(home, { customized = false } = {}) { + const { root, cli, native, profile, patch } = setupFixture(home) + const customizations = '- id: agent-default-model\n name: "@deepseek-ai/dsh-agent-default-model"\n' + if (customized) { + await native(['--profile', 'web', '--dump-default-config']) + writeFileSync(patch, customizations) + } const setup = await cli(['setup', 'dsh', '--source', root]) + if (customized) { + await cli(['setup', 'dsh', '--source', root]) + assert.equal(readFileSync(patch, 'utf8'), customizations) + } const doctor = await cli(['doctor', 'dsh', '--json']) - return { setup, doctor: JSON.parse(doctor), plugin: join(env.DSH_HOME, 'profiles/web/node_modules/powercontext-dsh') } + return { setup, doctor: JSON.parse(doctor), plugin: join(profile, 'node_modules/powercontext-dsh') } } diff --git a/integrations/dsh/plugins/powercontext/tests/runtime/setup.test.mjs b/integrations/dsh/plugins/powercontext/tests/runtime/setup.test.mjs new file mode 100644 index 000000000..0fef4298e --- /dev/null +++ b/integrations/dsh/plugins/powercontext/tests/runtime/setup.test.mjs @@ -0,0 +1,287 @@ +/* + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import assert from 'node:assert/strict' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { test } from 'node:test' +import { pathToFileURL } from 'node:url' +import { installIntoHome, setupFixture } from './setup-fixture.mjs' + +test('setup validates native include trees observed by a real DSH startup', { timeout: 240000 }, async () => { + const home = mkdtempSync(join(tmpdir(), 'pc-dsh-setup-include-')) + try { + const fixture = setupFixture(home) + const source = join(home, 'recording-powercontext') + mkdirSync(join(source, 'lib'), { recursive: true }) + writeFileSync(join(source, 'package.json'), JSON.stringify({ + name: 'powercontext-dsh', version: '1.0.0', type: 'module', main: './lib/index.js', + dsh: { bundle: { patch: './cordis.patch.yml' } }, + })) + const recording = join(home, 'received.jsonl') + writeFileSync(join(source, 'lib/index.js'), + "import { appendFileSync } from 'node:fs'; export const name = 'powercontext-dsh'; " + + "export function apply(ctx, config) { appendFileSync(process.env.DSH_INCLUDE_RECORDING, JSON.stringify(config) + '\\n'); }\n") + writeFileSync(join(source, 'cordis.patch.yml'), JSON.stringify([{ insert: [{ + id: 'powercontext-dsh', name: 'powercontext-dsh', config: { baseUrl: 'https://selected.example' }, + }] }])) + mkdirSync(fixture.profile, { recursive: true }) + writeFileSync(join(fixture.profile, 'package.json'), JSON.stringify({ + private: true, dsh: { profile: { bundles: [], patchReload: 'startup' } }, + })) + await fixture.native(['plugin', '--profile', 'web', 'add', source]) + const directory = join(fixture.profile, 'custom files') + mkdirSync(directory) + const included = join(directory, 'nested.json') + writeFileSync(included, JSON.stringify([{ id: 'powercontext-dsh', name: 'powercontext-dsh', config: {} }])) + const outer = join(directory, 'outer.yml') + writeFileSync(outer, JSON.stringify([{ + id: 'nested', name: '@deepseek-ai/cordis-plugin-include', config: { + path: './nested.json', patches: [{ id: 'powercontext-dsh', config: { baseUrl: 'https://unexpected.example' } }], + }, + }])) + writeFileSync(fixture.patch, JSON.stringify([{ insert: [{ + id: 'extra-group', name: '@deepseek-ai/cordis-plugin-group', config: [{ + id: 'included', name: '@deepseek-ai/cordis-plugin-include', config: { path: './custom files/outer.yml' }, + }], + }] }])) + fixture.env.DSH_INCLUDE_RECORDING = recording + await fixture.native(['--profile', 'web']) + const received = readFileSync(recording, 'utf8').trim().split('\n').map(line => JSON.parse(line)) + assert.deepEqual(received.map(config => config.baseUrl).sort(), [ + 'https://selected.example', 'https://unexpected.example', + ]) + writeFileSync(fixture.clients, JSON.stringify({ version: 1, hosts: { + dsh: { server_url: 'https://saved.example', allow_insecure_http: false }, + } })) + const credentials = join(fixture.env.DSH_HOME, 'powercontext/credentials.json') + mkdirSync(dirname(credentials), { recursive: true }) + writeFileSync(credentials, JSON.stringify({ version: 1, server_url: 'https://saved.example', authorization: 'saved-token' })) + fixture.env.POWERCONTEXT_DSH_AUTHORIZATION = 'Bearer new-token' + const paths = [join(fixture.profile, 'package.json'), fixture.patch, outer, included, fixture.clients, credentials] + const before = paths.map(path => readFileSync(path)) + await assert.rejects(fixture.cli(['setup', 'dsh', '--source', source, + '--server-url', 'https://selected.example', '--json']), error => { + assert.equal(error.code, 1) + assert.match(error.stderr, /Multiple PowerContext entries/) + return true + }) + for (const [index, path] of paths.entries()) assert.deepEqual(readFileSync(path), before[index]) + + const uiPlugin = join(home, 'ui-fixture.mjs') + const uiRecording = join(home, 'ui.json') + writeFileSync(uiPlugin, "import { writeFileSync } from 'node:fs'; " + + "export function apply(ctx, config) { writeFileSync(process.env.DSH_UI_RECORDING, JSON.stringify(config)); }\n") + writeFileSync(included, JSON.stringify([{ + id: 'ui-fixture', name: pathToFileURL(uiPlugin).href, config: {}, + }])) + writeFileSync(outer, JSON.stringify([{ + id: 'nested', name: '@deepseek-ai/cordis-plugin-include', config: { + path: './nested.json', patches: [{ id: 'ui-fixture', config: { + theme: 'dark', model: { __jsExpr: "'configured-model'" }, + } }], + }, + }])) + const includePaths = [fixture.patch, outer, included] + const includeBefore = includePaths.map(path => readFileSync(path)) + const result = JSON.parse(await fixture.cli(['setup', 'dsh', '--source', source, + '--server-url', 'https://selected.example', '--json'])) + assert.equal(result.plugin, 'powercontext-dsh') + assert.equal(JSON.parse(readFileSync(fixture.clients, 'utf8')).hosts.dsh.server_url, 'https://selected.example') + assert.equal(JSON.parse(readFileSync(credentials, 'utf8')).authorization, 'Bearer new-token') + for (const [index, path] of includePaths.entries()) assert.deepEqual(readFileSync(path), includeBefore[index]) + writeFileSync(recording, '') + fixture.env.DSH_UI_RECORDING = uiRecording + await fixture.native(['--profile', 'web']) + assert.deepEqual(readFileSync(recording, 'utf8').trim().split('\n').map(line => JSON.parse(line)), [ + { baseUrl: 'https://selected.example' }, + ]) + assert.deepEqual(JSON.parse(readFileSync(uiRecording, 'utf8')), { theme: 'dark', model: 'configured-model' }) + } finally { + assert.equal(dirname(resolve(home)), resolve(tmpdir())) + rmSync(home, { recursive: true, force: true }) + } +}) + +test('setup rejects duplicate PowerContext entries in a native named group', { timeout: 240000 }, async () => { + const home = mkdtempSync(join(tmpdir(), 'pc-dsh-setup-group-')) + try { + const fixture = setupFixture(home) + await fixture.native(['plugin', '--profile', 'web', 'add', + join(fixture.root, 'integrations/dsh/plugins/powercontext')]) + writeFileSync(fixture.patch, '- id: powercontext-dsh\n disabled: false\n') + writeFileSync(fixture.homePatch, JSON.stringify([{ insert: [{ + id: 'extra-group', name: '@deepseek-ai/cordis-plugin-group', config: [{ + id: 'powercontext-dsh', name: 'powercontext-dsh', + config: { baseUrl: 'https://unexpected.example' }, + }], + }] }])) + writeFileSync(fixture.clients, JSON.stringify({ version: 1, hosts: { + dsh: { server_url: 'https://saved.example', allow_insecure_http: false }, + } })) + const dump = await fixture.native(['--profile', 'web', '--dump-config']) + assert.match(dump, /@deepseek-ai\/cordis-plugin-group/) + assert.equal((dump.match(/\bid: powercontext-dsh\b/g) ?? []).length, 2) + const paths = [join(fixture.profile, 'package.json'), fixture.patch, fixture.homePatch, fixture.clients] + const before = paths.map(path => readFileSync(path)) + await assert.rejects(fixture.cli(['setup', 'dsh', '--source', fixture.root, + '--server-url', 'https://selected.example', '--json']), error => { + assert.equal(error.code, 1) + assert.match(error.stderr, /Multiple PowerContext entries/) + return true + }) + for (const [index, path] of paths.entries()) assert.deepEqual(readFileSync(path), before[index]) + } finally { + assert.equal(dirname(resolve(home)), resolve(tmpdir())) + rmSync(home, { recursive: true, force: true }) + } +}) + +test('setup preserves a trailing-space DSH home and URL-bound credentials', { + timeout: 240000, skip: process.platform === 'win32', +}, async () => { + const home = mkdtempSync(join(tmpdir(), 'pc-dsh-setup-home-')) + try { + const fixture = setupFixture(home, { dshHome: join(home, 'installed-dsh ') }) + fixture.env.POWERCONTEXT_DSH_AUTHORIZATION = 'Bearer test-token' + await fixture.native(['--profile', 'web', '--dump-default-config']) + writeFileSync(fixture.patch, '- id: agent-default-model\n name: "@deepseek-ai/dsh-agent-default-model"\n') + const patch = readFileSync(fixture.patch) + for (let attempt = 0; attempt < 2; attempt += 1) { + const result = JSON.parse(await fixture.cli(['setup', 'dsh', '--source', fixture.root, + '--server-url', 'https://selected.example', '--json'])) + assert.equal(result.plugin, 'powercontext-dsh') + assert.deepEqual(JSON.parse(readFileSync(fixture.clients, 'utf8')).hosts.dsh, { + server_url: 'https://selected.example', allow_insecure_http: false, + }) + assert.deepEqual(JSON.parse(readFileSync(join(fixture.env.DSH_HOME, 'powercontext/credentials.json'), 'utf8')), { + version: 1, server_url: 'https://selected.example', authorization: 'Bearer test-token', + }) + const dump = await fixture.native(['--profile', 'web', '--dump-config']) + assert.match(dump, /id: powercontext-dsh/) + assert.deepEqual(readFileSync(fixture.patch), patch) + } + } finally { + assert.equal(dirname(resolve(home)), resolve(tmpdir())) + rmSync(home, { recursive: true, force: true }) + } +}) + +test('setup and repeated setup preserve an existing customized DSH profile', { timeout: 240000 }, async () => { + const home = mkdtempSync(join(tmpdir(), 'pc-dsh-setup-')) + try { + const installation = await installIntoHome(home, { customized: true }) + assert.match(installation.setup, /setup complete/) + assert.equal(installation.doctor.ok, true) + assert.equal(installation.doctor.checks.plugin.checks.registration, 'present') + } finally { + assert.equal(dirname(resolve(home)), resolve(tmpdir())) + rmSync(home, { recursive: true, force: true }) + } +}) + +async function inactiveTransportBundle(home, config, { installed = true } = {}) { + const fixture = setupFixture(home) + const bundle = join(home, 'transport-override') + mkdirSync(bundle) + writeFileSync(join(bundle, 'package.json'), JSON.stringify({ + name: 'transport-override', version: '1.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } }, + })) + writeFileSync(join(bundle, 'cordis.patch.yml'), JSON.stringify([{ id: 'powercontext-dsh', config }])) + if (installed) { + await fixture.native(['plugin', '--profile', 'web', 'add', + join(fixture.root, 'integrations/dsh/plugins/powercontext')]) + } + await fixture.native(['plugin', '--profile', 'web', 'add', bundle]) + const manifest = join(fixture.profile, 'package.json') + const metadata = JSON.parse(readFileSync(manifest, 'utf8')) + metadata.dsh.profile.bundles = metadata.dsh.profile.bundles.filter(name => name !== 'transport-override') + writeFileSync(manifest, JSON.stringify(metadata, undefined, 2) + '\n') + writeFileSync(fixture.patch, '- id: powercontext-dsh\n disabled: false\n') + writeFileSync(fixture.homePatch, '- id: agent-default-model\n name: "@deepseek-ai/dsh-agent-default-model"\n') + writeFileSync(fixture.clients, JSON.stringify({ version: 1, hosts: { + dsh: { server_url: 'https://saved.example', allow_insecure_http: false }, + pi: { server_url: 'https://unrelated.example', allow_insecure_http: false }, + } }, undefined, 2) + '\n') + return { ...fixture, manifest } +} + +for (const scenario of [ + { name: 'endpoint override during reinstall', installed: true, + config: { baseUrl: 'https://unexpected.example' }, endpoint: 'https://selected.example', + consent: '--no-allow-insecure-http', error: /baseUrl conflicts/ }, + { name: 'endpoint override during first installation', installed: false, + config: { baseUrl: 'https://unexpected.example' }, endpoint: 'https://selected.example', + consent: '--no-allow-insecure-http', error: /baseUrl conflicts/ }, + { name: 'endpoint-bound HTTP refusal', installed: true, + config: { baseUrl: 'http://selected.example', allowInsecureHttp: false }, endpoint: 'http://selected.example', + consent: '--allow-insecure-http', error: /HTTP consent/ }, +]) { + test(`setup rejects an inactive bundle's ${scenario.name} without changing saved state`, { timeout: 240000 }, async () => { + const home = mkdtempSync(join(tmpdir(), 'pc-dsh-setup-conflict-')) + try { + const fixture = await inactiveTransportBundle(home, scenario.config, { installed: scenario.installed }) + const paths = [fixture.manifest, fixture.patch, fixture.homePatch, fixture.clients] + const before = paths.map(path => readFileSync(path)) + await assert.rejects(fixture.cli(['setup', 'dsh', '--source', fixture.root, + '--server-url', scenario.endpoint, scenario.consent, '--json']), error => { + assert.equal(error.code, 1) + assert.match(error.stderr, scenario.error) + return true + }) + for (const [index, path] of paths.entries()) assert.deepEqual(readFileSync(path), before[index]) + } finally { + assert.equal(dirname(resolve(home)), resolve(tmpdir())) + rmSync(home, { recursive: true, force: true }) + } + }) +} + +test('matching inactive bundle settings survive setup and repeated setup in native configuration', { timeout: 240000 }, async () => { + const home = mkdtempSync(join(tmpdir(), 'pc-dsh-setup-matching-')) + try { + const fixture = await inactiveTransportBundle(home, { + baseUrl: 'http://selected.example', allowInsecureHttp: true, + }) + const patches = [fixture.patch, fixture.homePatch] + const original = patches.map(path => readFileSync(path)) + let manifest + for (let attempt = 0; attempt < 2; attempt += 1) { + const result = JSON.parse(await fixture.cli(['setup', 'dsh', '--source', fixture.root, + '--server-url', 'http://selected.example', '--allow-insecure-http', '--json'])) + assert.equal(result.plugin, 'powercontext-dsh') + const settings = JSON.parse(readFileSync(fixture.clients, 'utf8')).hosts + assert.deepEqual(settings.dsh, { server_url: 'http://selected.example', allow_insecure_http: true }) + assert.deepEqual(settings.pi, { server_url: 'https://unrelated.example', allow_insecure_http: false }) + const dump = await fixture.native(['--profile', 'web', '--dump-config']) + const entry = dump.match(/- id: powercontext-dsh\r?\n(?:(?!- id:)[\s\S])*/)?.[0] + assert.ok(entry, 'native configuration must contain the installed PowerContext entry') + assert.match(entry, /baseUrl: http:\/\/selected\.example/) + assert.match(entry, /allowInsecureHttp: true/) + const current = readFileSync(fixture.manifest) + const bundles = JSON.parse(current).dsh.profile.bundles + assert.equal(bundles.filter(name => name === 'powercontext-dsh').length, 1) + assert.equal(bundles.filter(name => name === 'transport-override').length, 1) + if (manifest) assert.deepEqual(current, manifest) + manifest = current + for (const [index, path] of patches.entries()) assert.deepEqual(readFileSync(path), original[index]) + } + } finally { + assert.equal(dirname(resolve(home)), resolve(tmpdir())) + rmSync(home, { recursive: true, force: true }) + } +}) diff --git a/integrations/dsh/plugins/powercontext/tests/transport-consent.spec.ts b/integrations/dsh/plugins/powercontext/tests/transport-consent.spec.ts index 6a4efca1f..9dff926f9 100644 --- a/integrations/dsh/plugins/powercontext/tests/transport-consent.spec.ts +++ b/integrations/dsh/plugins/powercontext/tests/transport-consent.spec.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -46,6 +46,19 @@ beforeEach(() => { afterEach(() => rmSync(directory, { recursive: true, force: true })) describe('dsh transport consent', () => { + for (const name of ['dsh', 'dsh ']) { + it.skipIf(name.endsWith(' ') && process.platform === 'win32')(`reads URL-bound credentials from home ${JSON.stringify(name)}`, () => { + const home = join(directory, name) + const credentials = join(home, 'powercontext', 'credentials.json') + mkdirSync(join(home, 'powercontext'), { recursive: true }) + writeFileSync(credentials, JSON.stringify({ + version: 1, server_url: 'https://memory.example/', authorization: 'Bearer test-token', + }), { mode: 0o600 }) + expect(resolve({ DSH_HOME: home }, { baseUrl: 'https://memory.example' }).authorization).toBe('Bearer test-token') + expect(resolve({ DSH_HOME: home }, { baseUrl: 'https://other.example' }).authorization).toBeUndefined() + }) + } + it('rejects non-boolean consent from direct JavaScript callers', () => { expect(() => new PowerContextClient({ baseUrl: remote, requestTimeoutMs: 1000, diff --git a/src/powercontext/cli/authorization.py b/src/powercontext/cli/authorization.py index 147a42ea4..ef79a064c 100644 --- a/src/powercontext/cli/authorization.py +++ b/src/powercontext/cli/authorization.py @@ -75,13 +75,15 @@ def normalize_authorization(value: str) -> str: def credential_path(host: str) -> Path: """Return a PowerContext-owned credential path below the host config root.""" + from powercontext.cli.dsh_runtime import dsh_home + roots = { "codex": Path(os.environ.get("CODEX_HOME", Path.home() / ".codex")).expanduser(), "claude-code": Path(os.environ.get("CLAUDE_CONFIG_DIR", Path.home() / ".claude")).expanduser(), "opencode": Path(os.environ.get("OPENCODE_CONFIG_DIR", Path.home() / ".config" / "opencode")).expanduser(), "pi": Path(os.environ.get("PI_CODING_AGENT_DIR", Path.home() / ".pi" / "agent")).expanduser(), "workbuddy": Path(os.environ.get("WORKBUDDY_HOME", Path.home() / ".workbuddy")).expanduser(), - "dsh": Path(os.environ.get("DSH_HOME", Path.home() / ".dsh")).expanduser(), + "dsh": dsh_home(), } try: root = roots[host] diff --git a/src/powercontext/cli/dsh.py b/src/powercontext/cli/dsh.py index 1e08c99df..0bd1fb5df 100644 --- a/src/powercontext/cli/dsh.py +++ b/src/powercontext/cli/dsh.py @@ -51,7 +51,9 @@ class DshSetupResult: authorization_state: str = "not_attempted" -def install_dsh_plugin(*, source: str, ref: str, server_url: str = "http://127.0.0.1:8000") -> DshSetupResult: +def install_dsh_plugin( + *, source: str, ref: str, server_url: str = "http://127.0.0.1:8000", allow_insecure_http: bool = False +) -> DshSetupResult: """Install the plugin from a PowerContext checkout or Git source.""" dsh_executable() @@ -62,7 +64,22 @@ def install_dsh_plugin(*, source: str, ref: str, server_url: str = "http://127.0 raise SetupError.data_directory(data_dir, error) from error plugin_dir = resolve_dsh_plugin_dir(source=source, ref=ref) require_built_plugin(plugin_dir) + from powercontext.cli.dsh_transport import read_dsh_settings, validate_dsh_setup_transport + + try: + settings = read_dsh_settings(profile=DSH_PROFILE, candidate=plugin_dir, prospective=True) + validate_dsh_setup_transport(settings, server_url, allow_insecure_http) + except ValueError as error: + raise SetupError(str(error)) from error _run_dsh("plugin", "--profile", DSH_PROFILE, "add", str(plugin_dir)) + try: + settings = read_dsh_settings(profile=DSH_PROFILE, require_installed=True) + validate_dsh_setup_transport(settings, server_url, allow_insecure_http) + except ValueError as error: + raise SetupError( # noqa: TRY003 - actionable host-specific readback failure. + f"DSH installation completed but its resulting configuration could not be validated: {error}. " + "Connection settings and credentials were not saved. Check the profile before restarting DSH." + ) from error from powercontext.cli.authorization import ( configure_stored_authorization, setup_authorization_value, diff --git a/src/powercontext/cli/dsh_config.mjs b/src/powercontext/cli/dsh_config.mjs new file mode 100644 index 000000000..98804e09a --- /dev/null +++ b/src/powercontext/cli/dsh_config.mjs @@ -0,0 +1,300 @@ +/* + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Read configuration using the installed DSH's parser and patch algorithm. +// Never call loadProfile/prepareProfile: those initialize or rewrite host files. +import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, extname, join, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const plugin = 'powercontext-dsh' +class InspectionError extends Error { + constructor(reason, location = '') { + super(reason) + this.location = location + } +} +function fail(reason, location) { throw new InspectionError(reason, location) } +function read(location, action) { + try { return action() } catch (error) { + if (error instanceof InspectionError) throw error + fail('Cannot read or compose DSH configuration', location) + } +} +function isExpression(value) { return value && typeof value === 'object' && '__jsExpr' in value } + +function installation(executable) { + const real = realpathSync(executable) + // npm/pnpm Windows shims live beside node_modules; Unix bins are symlinks + // into the package. Resolve from the selected CLI, never the working directory. + const candidates = [join(dirname(executable), 'node_modules', '@deepseek-ai', 'dsh', 'package.json')] + // pnpm and user launchers may be scripts rather than symlinks. Read only a + // literal quoted lib/bin.js path; never execute or source launcher contents. + if (statSync(real).size < 65536) { + const text = readFileSync(real, 'utf8') + for (const match of text.matchAll(/["']([^"'\r\n]*[\\/]lib[\\/]bin\.js)["']/g)) { + const path = match[1].replace(/^(?:%~dp0|%dp0%|\$basedir|\$\{basedir\})[\\/]?/, dirname(executable) + '/') + if (path.includes('$') || path.includes('%') || !existsSync(path)) continue + candidates.push(join(dirname(dirname(realpathSync(path))), 'package.json')) + } + } + for (let dir = dirname(real); dirname(dir) !== dir; dir = dirname(dir)) { + candidates.push(join(dir, 'package.json')) + } + for (const file of candidates) { + if (!existsSync(file)) continue + try { + if (JSON.parse(readFileSync(file, 'utf8')).name === '@deepseek-ai/dsh') return file + } catch { /* A parent manifest need not belong to DSH. */ } + } + fail('Cannot locate the installed DSH package from its CLI; use a supported npm/pnpm DSH installation') +} + +async function inspect(executable, home, profile, candidate, prospective, requireInstalled) { + const anchor = installation(executable) + const require = createRequire(anchor) + // These are the selected host's APIs, not dependencies of the plugin being installed. + // Resolving a different copy from PowerContext would inspect a different host contract. + let boot, bootPath + try { + bootPath = require.resolve('@deepseek-ai/dsh-app-boot') + boot = await import(pathToFileURL(bootPath).href) + } + catch { fail('Installed DSH cannot provide its dsh-app-boot configuration APIs; upgrade or reinstall DSH', anchor) } + for (const name of ['readProfileManifest', 'resolveBundleDir', 'loadOverlayPatches', 'composeEntries']) { + if (typeof boot[name] !== 'function') fail(`Installed DSH does not expose ${name}; upgrade or reinstall DSH`, anchor) + } + const dir = join(home, 'profiles', profile) + const manifestPath = join(dir, 'package.json') + const manifest = existsSync(manifestPath) + ? read(manifestPath, () => boot.readProfileManifest('powercontext', dir)) + : undefined + const template = boot.PROFILE_TEMPLATES?.[profile] + let bundles = manifest?.dsh?.profile?.bundles ?? (manifest ? [] : template?.bundles ?? template) + if (!Array.isArray(bundles) || bundles.some(name => typeof name !== 'string')) { + fail('Cannot determine the DSH profile bundle list', manifestPath) + } + if (requireInstalled && !bundles.includes(plugin)) fail('PowerContext is not enabled in this profile', manifestPath) + let candidateBundle = candidate + if (candidate) { + // Both native reconciliation and composition prefer installation-owned + // packages. The native resolver also handles packages hiding package.json. + try { candidateBundle = boot.resolveBundleDir('powercontext', plugin, anchor, dirname(anchor)) } + catch { /* The candidate supplies the profile-owned package. */ } + } + // With a materialized candidate, project the same direct-dependency + // reconciliation performed by the selected CLI. Doctor reads current bundles. + if (candidate) { + const dependencies = manifest?.dependencies ?? {} + if (!dependencies || typeof dependencies !== 'object' || Array.isArray(dependencies)) { + fail('Cannot determine the DSH profile dependencies', manifestPath) + } + const before = new Set(Object.keys(dependencies)) + const after = [...new Set([...before, plugin])] + // pnpm sorts dependency keys when saving a changed manifest. A no-op add + // leaves its original bytes and ordering intact. + const spec = 'link:' + resolve(candidate).replaceAll('\\', '/') + if (dependencies[plugin] !== spec) after.sort() + const cli = read(anchor, () => JSON.parse(readFileSync(anchor, 'utf8'))) + const preservesInactive = !!cli.dependencies?.['@deepseek-ai/dsh-plugin-manager'] + const declarations = new Map() + function declaresBundle(name) { + if (declarations.has(name)) return declarations.get(name) + let packageDir + if (name === plugin) packageDir = candidateBundle + else { + try { packageDir = boot.resolveBundleDir('powercontext', name, anchor, dir) } + catch { declarations.set(name, false); return false } + } + const file = join(packageDir, 'package.json') + const metadata = read(file, () => boot.readProfileManifest('powercontext', packageDir)) + const declared = metadata.dsh?.bundle?.patch !== undefined + declarations.set(name, declared) + return declared + } + const managed = new Set(after) + bundles = bundles.filter(name => !managed.has(name) || declaresBundle(name)) + for (const name of after) { + // Newer CLI operations preserve an already-installed inactive dependency; + // legacy CLI reconciliation enables every dependency declaring a patch. + if (preservesInactive && before.has(name)) continue + if (!bundles.includes(name) && declaresBundle(name)) bundles.push(name) + } + if (!bundles.includes(plugin)) { + fail('DSH installation would leave PowerContext disabled; enable its bundle in this profile before setup', manifestPath) + } + } + const exemptions = typeof boot.readProfileVersionExemptions === 'function' + ? read(dir, () => boot.readProfileVersionExemptions(dir)) : {} + const skippedBundles = [] + function bundle(name, packageDir) { + const file = join(packageDir, 'package.json') + const metadata = read(file, () => boot.readProfileManifest('powercontext', packageDir)) + if (typeof boot.evaluatePluginCompatibility === 'function') { + const issue = read(file, () => boot.evaluatePluginCompatibility(metadata, exemptions)) + if (issue && !issue.exempted) { + if (name === plugin) fail('PowerContext is incompatible with the installed DSH; resolve its version compatibility', file) + // Native DSH omits incompatible bundle layers. Composing their patches after + // merely warning would validate transport settings the host will never use. + skippedBundles.push(`DSH skipped an incompatible third-party bundle; its patches were not applied: ${file}`) + return [] + } + } + let files + if (typeof boot.bundlePatchPaths === 'function') { + files = read(file, () => boot.bundlePatchPaths(packageDir, metadata.dsh?.bundle)) + } else { + // Older DSH releases accept one patch path, not the newer list form. + const patch = metadata.dsh?.bundle?.patch + if (typeof patch !== 'string' || !patch) fail('DSH bundle has no supported patch declaration', file) + files = [join(packageDir, patch)] + } + return files.flatMap(path => read(path, () => boot.loadOverlayPatches('powercontext', path))) + } + const layers = bundles.map(name => { + if (name === plugin && candidate) return bundle(name, candidateBundle) + const packageDir = read(manifestPath, () => boot.resolveBundleDir('powercontext', name, anchor, dir)) + return bundle(name, packageDir) + }) + if (!bundles.includes(plugin)) { + if (candidate) layers.push(bundle(plugin, candidate)) + // Before materializing the source, expose the future plugin id to user + // patches. The installer rechecks with the actual candidate before add. + else if (prospective) layers.push([{ insert: [{ id: plugin, name: plugin, config: {} }] }]) + } + const userFiles = [join(dir, 'cordis.patch.yml'), join(home, 'cordis.patch.yml')] + for (const file of userFiles) { + if (existsSync(file)) layers.push(read(file, () => boot.loadOverlayPatches('powercontext', file))) + } + const warnings = [] + const rows = read(dir, () => boot.composeEntries(layers, warning => warnings.push(warning))) + if (warnings.some(warning => warning.includes(plugin))) fail('A PowerContext patch could not be applied', dir) + const matches = [] + let includeApi, includeYaml + const includeStack = new Set() + async function includeEntries(config, baseUrl, location) { + if (!config || typeof config !== 'object' || Array.isArray(config) || '__jsExpr' in config + || typeof config.path !== 'string' || !config.path) { + fail('Native DSH include path must be static before setup', location) + } + const file = read(location, () => fileURLToPath(new URL(config.path, baseUrl))) + if (!['.yaml', '.yml', '.json'].includes(extname(file))) { + fail('Native DSH includes must use readable YAML or JSON files before setup', location) + } + if (!existsSync(file)) { + // Do not let Include.initial create a tree after preflight accepted it. + fail('Native DSH include file must exist before setup; materialize it first', file) + } + const canonical = read(file, () => realpathSync(file)) + if (includeStack.has(canonical)) fail('Native DSH include tree contains a cycle', file) + if (config.patches != null && !Array.isArray(config.patches)) { + fail('Native DSH include patches must be a static list before setup', file) + } + // Include is an EntryGroup: the native Loader passes its config through + // without interpolating !!js. Require static patch structure while keeping + // ordinary plugin config expressions for the owning plugin to evaluate. + for (const patch of config.patches ?? []) { + if (!patch || typeof patch !== 'object' || Array.isArray(patch) || isExpression(patch) + || [patch.id, patch.name, patch.group].some(isExpression) + || (patch.insert != null && !Array.isArray(patch.insert))) { + fail('Native DSH include patch structure must be static before setup', file) + } + } + if (!includeApi) { + try { + const nativeRequire = createRequire(bootPath) + const includePath = nativeRequire.resolve('@deepseek-ai/cordis-plugin-include') + includeApi = await import(pathToFileURL(includePath).href) + includeYaml = createRequire(includePath)('js-yaml') + } catch { + fail('Installed DSH cannot provide its native include inspection APIs; upgrade or reinstall DSH', location) + } + if (typeof includeApi.applyEntryPatches !== 'function' || !includeApi.entryListSchema) { + fail('Installed DSH cannot provide its native include inspection APIs; upgrade or reinstall DSH', location) + } + } + const entries = read(file, () => { + const text = readFileSync(file, 'utf8') + const data = extname(file) === '.json' ? JSON.parse(text) + : includeYaml.load(text, { schema: includeApi.entryListSchema }) + if (!Array.isArray(data)) fail('Native DSH include file must contain an entry list', file) + // Include patches target this tree, not the outer profile's entries. + return includeApi.applyEntryPatches(data, config.patches, () => {}) + }) + return { entries, file, canonical, baseUrl: new URL('.', pathToFileURL(file)).href } + } + async function visit(entries, disabled = false, conditional = false, + baseUrl = pathToFileURL(dir + '/').href, location = dir) { + for (const row of entries) { + if (!row || typeof row !== 'object' || Array.isArray(row) || '__jsExpr' in row) { + fail('DSH plugin entries must be static mappings before setup', location) + } + if ([row.id, row.name, row.group].some(isExpression)) { + fail('DSH plugin identity must be static before setup', location) + } + const dynamicDisabled = isExpression(row.disabled) + const conditionalEntry = conditional || dynamicDisabled + const unavailable = disabled || (!dynamicDisabled && Boolean(row.disabled)) + if (row.id === plugin || row.name === plugin) { + if (row.id !== plugin || row.name !== plugin || unavailable || conditionalEntry) { + fail('PowerContext is renamed, disabled, or conditionally enabled in DSH', location) + } + matches.push(row) + } + if (row.name === '@deepseek-ai/cordis-plugin-include' || row.name === 'cordis:include') { + if (unavailable && !conditionalEntry) continue + const tree = await includeEntries(row.config, baseUrl, location) + includeStack.add(tree.canonical) + try { await visit(tree.entries, unavailable, conditionalEntry, tree.baseUrl, tree.file) } + finally { includeStack.delete(tree.canonical) } + } else if (row.group || row.name === '@deepseek-ai/cordis-plugin-group' || row.name === 'cordis:group') { + if (!Array.isArray(row.config)) fail('DSH group children must be a static entry list before setup', location) + await visit(row.config, unavailable, conditionalEntry, baseUrl, location) + } + } + } + await visit(rows) + if (matches.length > 1) fail('Multiple PowerContext entries make the DSH transport ambiguous', dir) + if (!matches.length && (requireInstalled || candidate || prospective || bundles.includes(plugin))) { + fail('The composed DSH profile does not contain PowerContext', dir) + } + const config = matches[0]?.config ?? {} + if (!config || typeof config !== 'object' || Array.isArray(config) || '__jsExpr' in config) { + fail('PowerContext config is not a static object', dir) + } + const settings = {} + for (const [key, type] of [['baseUrl', 'string'], ['allowInsecureHttp', 'boolean']]) { + if (config[key] === undefined) continue + if (typeof config[key] !== type || (type === 'string' && !config[key].trim())) { + fail(`PowerContext ${key} must be a static ${type}; dynamic transport expressions cannot be checked`, dir) + } + settings[key] = config[key] + } + return { settings, warnings: skippedBundles } +} + +try { + const [executable, home, profile, candidate, prospective, requireInstalled] = process.argv.slice(2) + const result = await inspect(resolve(executable), resolve(home), profile, candidate || undefined, prospective === 'true', requireInstalled === 'true') + process.stdout.write(JSON.stringify(result)) +} catch (error) { + // Native parser messages and stacks can include credentials or whole YAML rows. + process.stdout.write(JSON.stringify({ error: error instanceof InspectionError + ? { reason: error.message, location: error.location } + : { reason: 'Cannot inspect the installed DSH configuration APIs', location: '' } })) + process.exitCode = 1 +} diff --git a/src/powercontext/cli/dsh_runtime.py b/src/powercontext/cli/dsh_runtime.py new file mode 100644 index 000000000..e68da4c3b --- /dev/null +++ b/src/powercontext/cli/dsh_runtime.py @@ -0,0 +1,26 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Locate DSH's shared configuration and credential home.""" + +from __future__ import annotations + +import os +from pathlib import Path + + +def dsh_home() -> Path: + """Use the same home for profile configuration and URL-bound credentials.""" + configured = os.environ.get("DSH_HOME", "") + return Path(configured if configured.strip() else Path.home() / ".dsh").expanduser() diff --git a/src/powercontext/cli/dsh_transport.py b/src/powercontext/cli/dsh_transport.py new file mode 100644 index 000000000..f164b70d9 --- /dev/null +++ b/src/powercontext/cli/dsh_transport.py @@ -0,0 +1,136 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Inspect DSH patches without booting plugins or changing host configuration.""" + +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path +from shutil import which +from typing import Any + +import typer + +from powercontext.cli.dsh_runtime import dsh_home +from powercontext.client.transport_policy import normalize_client_url, parse_client_boolean + + +def read_dsh_settings( + *, profile: str = "web", candidate: Path | None = None, prospective: bool = False, require_installed: bool = False +) -> dict[str, Any]: + """Compose the installed host's layers; optionally substitute the installation candidate.""" + from powercontext.cli.dsh import dsh_executable + from powercontext.cli.system import SetupError + + home = dsh_home() + if Path(profile).name != profile or profile in {".", ".."} or "\\" in profile: + raise ValueError("Cannot inspect an invalid DSH profile name") # noqa: TRY003 + # An absent profile with no patches has no native transport override. The + # candidate check always composes the host's default bundles before install. + if ( + candidate is None + and not require_installed + and not any( + path.exists() + for path in ( + home / "profiles" / profile / "package.json", + home / "profiles" / profile / "cordis.patch.yml", + home / "cordis.patch.yml", + ) + ) + ): + return {} + try: + executable = dsh_executable() + except SetupError: + raise ValueError("DSH CLI is required to inspect its configuration") from None # noqa: TRY003 + sibling_node = Path(executable).parent / ("node.exe" if os.name == "nt" else "node") + node = str(sibling_node) if sibling_node.is_file() else which("node") + if not node: + raise ValueError("Node.js is required to inspect DSH configuration") # noqa: TRY003 + command = [ + node, + str(Path(__file__).with_name("dsh_config.mjs")), + executable, + str(home), + profile, + str(candidate) if candidate else "", + str(prospective).lower(), + str(require_installed).lower(), + ] + try: + result = subprocess.run( # noqa: S603 - fixed helper and separate arguments, no shell. + command, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30, check=False + ) + payload = json.loads(result.stdout) + except (OSError, subprocess.SubprocessError, ValueError): + raise ValueError("Cannot inspect DSH configuration; check the installed DSH and Node.js versions") from None # noqa: TRY003 + if not isinstance(payload, dict): + raise ValueError("Invalid DSH configuration inspection result") # noqa: TRY003, TRY004 + if error := payload.get("error"): + raise ValueError(f"{error['reason']}: {error['location']}".rstrip(": ")) + settings = payload.get("settings") + if result.returncode or not isinstance(settings, dict): + raise ValueError("Cannot inspect DSH configuration") # noqa: TRY003 + for warning in payload.get("warnings", []): + typer.echo(f"WARNING: {warning}", err=True) + return settings + + +def matching_dsh_consent(settings: dict[str, Any], endpoint: str) -> bool | None: + """Native refusal is unconditional; native permission belongs to its endpoint.""" + consent = settings.get("allowInsecureHttp") + if consent is False: + return False + native_url = settings.get("baseUrl") + if native_url and _endpoint(native_url) == _endpoint(endpoint): + return consent + return None + + +def validate_dsh_setup_transport(settings: dict[str, Any], endpoint: str, allowed: bool) -> None: + """Reject a native override that would undo setup's selected connection policy.""" + keys = ( + "POWERCONTEXT_DSH_BASE_URL", + "POWERCONTEXT_DSH_SERVER_URL", + "POWERCONTEXT_DSH_ENDPOINT", + "POWERCONTEXT_CLIENT_SERVER_URL", + ) + override = next((os.environ[key].strip() for key in keys if os.environ.get(key, "").strip()), None) + native_url = settings.get("baseUrl") + if _endpoint(override or native_url or endpoint) != _endpoint(endpoint): + raise ValueError( # noqa: TRY003 + "DSH PowerContext baseUrl conflicts with the selected setup endpoint. " + "Align or remove baseUrl in the profile/home cordis.patch.yml or bundle, then rerun setup." + ) + consent = next( + ( + parse_client_boolean(os.environ[key]) + for key in ("POWERCONTEXT_DSH_ALLOW_INSECURE_HTTP", "POWERCONTEXT_CLIENT_ALLOW_INSECURE_HTTP") + if key in os.environ + ), + matching_dsh_consent(settings, endpoint), + ) + if consent is not None and consent != allowed: + raise ValueError( # noqa: TRY003 + "DSH PowerContext allowInsecureHttp conflicts with setup's HTTP consent. " + "Align or remove the overriding setting in cordis.patch.yml, a bundle, or the runtime environment." + ) + + +def _endpoint(value: str) -> str: + return normalize_client_url(value).removesuffix("/mcp").rstrip("/") diff --git a/src/powercontext/cli/hosts.py b/src/powercontext/cli/hosts.py index e1626b04a..c565abf2c 100644 --- a/src/powercontext/cli/hosts.py +++ b/src/powercontext/cli/hosts.py @@ -334,7 +334,12 @@ def install_host( if name == "dsh": from powercontext.cli.dsh import install_dsh_plugin - return install_dsh_plugin(source=source, ref=ref, server_url=server_url or "http://127.0.0.1:8000") + return install_dsh_plugin( + source=source, + ref=ref, + server_url=server_url or "http://127.0.0.1:8000", + allow_insecure_http=allow_insecure_http, + ) if name == "openclaw": from powercontext.cli.openclaw import install_openclaw_plugin from powercontext.cli.system import DEFAULT_OPENCLAW_SERVER_URL diff --git a/src/powercontext/cli/native_transport.py b/src/powercontext/cli/native_transport.py index b90228a65..4539a047e 100644 --- a/src/powercontext/cli/native_transport.py +++ b/src/powercontext/cli/native_transport.py @@ -90,6 +90,14 @@ def _codex_url() -> str | None: def _native_settings(host: str) -> tuple[dict[str, Any], str, str]: + if host == "dsh": + from powercontext.cli.dsh_transport import read_dsh_settings + + return ( + read_dsh_settings(profile=os.environ.get("DSH_PROFILE", "").strip() or "web"), + "baseUrl", + "allowInsecureHttp", + ) if host == "claude-code": settings = _object_at( _read(_home("CLAUDE_CONFIG_DIR", ".claude") / "settings.json"), @@ -131,24 +139,6 @@ def _workbuddy_mcp_url() -> str | None: return _url(raw) -def _check_dsh_overlays(*, profile: str | None = None) -> None: - home = _home("DSH_HOME", ".dsh") - profile = profile or os.environ.get("DSH_PROFILE", "").strip() or "web" - for path in (home / "cordis.patch.yml", home / "profiles" / profile / "cordis.patch.yml"): - try: - content = path.read_text(encoding="utf-8") - except FileNotFoundError: - continue - except (OSError, UnicodeError): - raise ValueError(_UNKNOWN) from None - # DSH initProfile creates comments followed by an empty patch list. - # Recognize only that inert form; do not evaluate YAML tags or patches. - content = "\n".join(line.partition("#")[0].strip() for line in content.splitlines()).strip() - if content in {"", "[]"}: - continue - raise ValueError("Cannot determine PowerContext transport with unsupported DSH runtime overlays") # noqa: TRY003 - - def _selected_url(host: str, prefix: str, native_url: str | None) -> str: common_url = _environment_url("POWERCONTEXT_CLIENT_SERVER_URL") saved_url = load_client_settings(host).get("server_url") @@ -169,17 +159,6 @@ def _selected_url(host: str, prefix: str, native_url: str | None) -> str: return environment_url or common_url or native_url or fallback -def validate_dsh_setup_transport() -> None: - """Require a verifiable DSH patch layer before changing installation state.""" - try: - _check_dsh_overlays(profile="web") - except ValueError: - raise ValueError( # noqa: TRY003 - "Cannot verify customized DSH runtime overlays. Align the endpoint and HTTP consent manually, " - "or remove custom overlays before rerunning setup." - ) from None - - def configured_native_endpoint(host: str) -> str | None: """Read only explicitly configured native endpoints for setup conflict detection.""" if host == "codex": @@ -193,14 +172,12 @@ def configured_native_endpoint(host: str) -> str | None: return _url(native[url_key]) if url_key in native else None -def resolve_host_transport(host: str) -> tuple[str, bool]: # noqa: C901 - host-specific transport guards. +def resolve_host_transport(host: str) -> tuple[str, bool]: """Resolve the effective endpoint and consent, or report an unknown native configuration. Native HTTP consent is endpoint-bound. This is a read-only diagnostic, not an HTTP guard; callers must label remote HTTP as degraded or blocked. """ - if host == "dsh": - _check_dsh_overlays() if host == "zcode": from powercontext.cli.zcode import zcode_plugin_dir @@ -228,7 +205,7 @@ def resolve_host_transport(host: str) -> tuple[str, bool]: # noqa: C901 - host- _, allowed = resolve_client_transport(host, server_url=endpoint) native_consent = native.get(consent_key, _MISSING) if native_consent is not _MISSING: - if host == "openclaw" and not isinstance(native_consent, bool): + if host in {"openclaw", "dsh"} and not isinstance(native_consent, bool): raise ValueError(_UNKNOWN) consent = parse_client_boolean(native_consent) environment_consent = any( @@ -239,4 +216,4 @@ def resolve_host_transport(host: str) -> tuple[str, bool]: # noqa: C901 - host- return endpoint, allowed -__all__ = ["resolve_host_transport", "validate_dsh_setup_transport"] +__all__ = ["resolve_host_transport"] diff --git a/src/powercontext/cli/transport.py b/src/powercontext/cli/transport.py index 5cc4d9078..3660ed1e2 100644 --- a/src/powercontext/cli/transport.py +++ b/src/powercontext/cli/transport.py @@ -70,19 +70,14 @@ def prepare_setup_transport( from powercontext.cli.system import SetupError try: + native = {} if host == "dsh": - from powercontext.cli.native_transport import validate_dsh_setup_transport + from powercontext.cli.dsh_transport import read_dsh_settings - validate_dsh_setup_transport() + native = read_dsh_settings(profile="web", prospective=True) prefix = "POWERCONTEXT_" + ("CLAUDE" if host == "claude-code" else host.upper().replace("-", "_")) + "_" - server_url = resolve_setup_endpoint(host, server_url=server_url) - loaded = setup_environment() - if allow_insecure_http is None: - consent_keys = (prefix + "ALLOW_INSECURE_HTTP", "POWERCONTEXT_CLIENT_ALLOW_INSECURE_HTTP") - if not any(key in os.environ for key in consent_keys): - configured_consent = next((loaded[key] for key in consent_keys if key in loaded), None) - if configured_consent is not None: - allow_insecure_http = parse_client_boolean(configured_consent) + server_url = resolve_setup_endpoint(host, server_url=server_url, native_endpoint=native.get("baseUrl")) + allow_insecure_http = _setup_consent(host, server_url, allow_insecure_http, native) endpoint, allowed = resolve_client_transport( host, server_url=server_url, allow_insecure_http=allow_insecure_http ) @@ -123,9 +118,35 @@ def prepare_setup_transport( "protected in transit. TLS verification and Server authentication remain unchanged.", err=True, ) + if host == "dsh": + from powercontext.cli.dsh_transport import validate_dsh_setup_transport + + try: + validate_dsh_setup_transport(native, endpoint, allowed) + except ValueError as error: + raise SetupError(str(error)) from error return SetupTransport(host, endpoint, allowed) +def _setup_consent(host: str, endpoint: str, requested: bool | None, native: dict[str, Any]) -> bool | None: + """Select setup consent without allowing saved consent to override a native refusal.""" + loaded = setup_environment() + if requested is not None: + return requested + prefix = "POWERCONTEXT_" + ("CLAUDE" if host == "claude-code" else host.upper().replace("-", "_")) + "_" + keys = (prefix + "ALLOW_INSECURE_HTTP", "POWERCONTEXT_CLIENT_ALLOW_INSECURE_HTTP") + if any(key in os.environ for key in keys): + return None # resolve_client_transport reads the runtime environment. + value = next((loaded[key] for key in keys if key in loaded), None) + if value is not None: + return parse_client_boolean(value) + if host == "dsh": + from powercontext.cli.dsh_transport import matching_dsh_consent + + return matching_dsh_consent(native, endpoint) + return None + + def setup_environment() -> dict[str, str]: """Read setup configuration without executing shell code or changing the process.""" from powercontext.cli.env_file import read_environment_file @@ -154,7 +175,13 @@ def _explicit_setup_endpoint(host: str, server_url: str) -> str: return endpoint -def resolve_setup_endpoint(host: str, *, server_url: str | None = None, default: str = "http://127.0.0.1:8000") -> str: +def resolve_setup_endpoint( + host: str, + *, + server_url: str | None = None, + default: str = "http://127.0.0.1:8000", + native_endpoint: str | None = None, +) -> str: """Choose one endpoint, rejecting ambiguous explicit settings before installation. A command-line URL is an explicit choice. Otherwise URL declarations must agree; @@ -172,7 +199,7 @@ def resolve_setup_endpoint(host: str, *, server_url: str | None = None, default: if values.get(name) ] saved = load_client_settings(host).get("server_url") - native = existing_native_endpoint(host) + native = native_endpoint if host == "dsh" else existing_native_endpoint(host) for name, value in (("saved client settings", saved), ("native host settings", native)): if value: candidates.append((name, normalize_client_url(value).removesuffix("/mcp").rstrip("/"))) diff --git a/tests/builtin/runtime/test_model_usage_recorder.py b/tests/builtin/runtime/test_model_usage_recorder.py index ebb0a797b..9677d9f0d 100644 --- a/tests/builtin/runtime/test_model_usage_recorder.py +++ b/tests/builtin/runtime/test_model_usage_recorder.py @@ -116,7 +116,10 @@ async def scenario() -> None: def test_queue_capacity_drops_without_sql_or_sensitive_logs(caplog: pytest.LogCaptureFixture) -> None: async def scenario() -> None: async with _database() as database: - recorder = _ModelUsageRecorder(database, StatisticsRepository(), queue_capacity=2) + # Keep queue overflow assertions independent of SQLite write deadlines. + recorder = _ModelUsageRecorder( + database, StatisticsRepository(), queue_capacity=2, write_timeout_seconds=5.0, flush_timeout_seconds=5.0 + ) try: for _ in range(8): _offer(recorder) diff --git a/tests/conftest.py b/tests/conftest.py index 1bd493006..6ccc01923 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -40,9 +40,23 @@ def isolated_client_connection_settings(tmp_path, monkeypatch, request): if not request.config.getoption("run_real_e2e"): monkeypatch.setenv("POWERCONTEXT_CLIENT_CONFIG_FILE", str(tmp_path / "client-settings.json")) monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes-home")) + monkeypatch.setenv("DSH_HOME", str(tmp_path / "dsh-home")) def pytest_addoption(parser: pytest.Parser) -> None: + dsh = parser.getgroup("powercontext-dsh") + dsh.addoption( + "--require-dsh-runtime", + action="store_true", + default=False, + help="Require both the DSH CLI and native configuration APIs instead of skipping their tests.", + ) + dsh.addoption( + "--require-dsh-config-runtime", + action="store_true", + default=False, + help="Require native DSH configuration APIs without requiring the DSH CLI.", + ) zcode = parser.getgroup("zcode-host-acceptance") zcode.addoption("--run-zcode-acceptance", action="store_true", help="Run real ZCode CLI acceptance.") zcode.addoption( diff --git a/tests/e2e/test_topic_memory_generic_api.py b/tests/e2e/test_topic_memory_generic_api.py index 84356baf1..684da80f2 100644 --- a/tests/e2e/test_topic_memory_generic_api.py +++ b/tests/e2e/test_topic_memory_generic_api.py @@ -31,6 +31,7 @@ from powercontext.builtin.persistence.statistics import StatisticsRepository from powercontext.builtin.persistence.tag_schema import ensure_topic_memory_tag_schema from powercontext.builtin.runtime import InferenceConfig, RuntimeConfig +from powercontext.builtin.runtime.statistics import RelationalScopedStatistics from powercontext.server.factory import create_server_app from powercontext.server.settings import AccessControlConfig, BearerAuthConfig, McpConfig, ServerSettings @@ -58,6 +59,7 @@ def _app( embedding_timeout=30.0, busy_timeout_ms=5_000, model_usage_write_timeout_seconds=1.0, + model_usage_flush_timeout_seconds=0.5, ): return create_server_app( settings=ServerSettings( @@ -65,6 +67,7 @@ def _app( runtime=RuntimeConfig( artifact_processing_families=(), model_usage_write_timeout_seconds=model_usage_write_timeout_seconds, + model_usage_flush_timeout_seconds=model_usage_flush_timeout_seconds, ), inference=InferenceConfig(embedding_timeout_seconds=embedding_timeout), auth=BearerAuthConfig(enabled=False), @@ -287,7 +290,8 @@ def test_valid_emoji_and_punctuation_content_has_empty_lexical_projection(tmp_pa def test_write_embeddings_are_attributed_to_the_operation_scope(tmp_path): embedding = UsageEmbeddings() - with TestClient(_app(tmp_path, embedding)) as client: + # Check attribution, not how quickly a loaded runner can persist telemetry. + with TestClient(_app(tmp_path, embedding, model_usage_write_timeout_seconds=30.0)) as client: source, target = _scope(client, "usage-source"), _scope(client, "usage-target") created = _create(client, source, "create") path = created.headers["Location"] @@ -570,7 +574,13 @@ def test_cancelling_a_request_during_a_stalled_usage_write_leaves_the_runtime_he async def scenario(): release = asyncio.Event() entered = asyncio.Event() + flushing = asyncio.Event() original = StatisticsRepository.record + original_flush = RelationalScopedStatistics.flush_model_usage + + async def observed_flush(statistics): + flushing.set() + await original_flush(statistics) async def stalled_record(repository, connection, *args): entered.set() @@ -579,7 +589,12 @@ async def stalled_record(repository, connection, *args): # As above: the stalled write must be reached rather than dropped for # spending its budget on a slow machine. - app = _app(tmp_path, UsageEmbeddings(), model_usage_write_timeout_seconds=30.0) + app = _app( + tmp_path, + UsageEmbeddings(), + model_usage_write_timeout_seconds=30.0, + model_usage_flush_timeout_seconds=30.0, + ) async with ( app.router.lifespan_context(app), httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client, @@ -592,12 +607,21 @@ async def stalled_record(repository, connection, *args): payload = {"family": "topic-memory", "content": _content("cancelled")} with monkeypatch.context() as injected: injected.setattr(StatisticsRepository, "record", stalled_record) + injected.setattr(RelationalScopedStatistics, "flush_model_usage", observed_flush) pending = asyncio.create_task(client.post(path, json=payload)) - await asyncio.wait_for(entered.wait(), 5) - pending.cancel() - with pytest.raises(asyncio.CancelledError): - await pending - release.set() + try: + # The recorder may start before the business transaction commits. + # Cancel at its completion flush, not inside an arbitrary SQL call. + await asyncio.wait_for(asyncio.gather(entered.wait(), flushing.wait()), 5) + assert not pending.done() + pending.cancel() + with pytest.raises(asyncio.CancelledError): + await pending + finally: + release.set() + if not pending.done(): + pending.cancel() + await asyncio.gather(pending, return_exceptions=True) await _await_usage_record(tmp_path / "topics.db", scope, 30.0) assert (await client.post(path, json=payload)).status_code == 201 diff --git a/tests/test_authorization.py b/tests/test_authorization.py index 609893fb2..a39736406 100644 --- a/tests/test_authorization.py +++ b/tests/test_authorization.py @@ -151,6 +151,43 @@ def test_clear_stored_authorization_is_idempotent(tmp_path: Path) -> None: assert clear_stored_authorization(path) == "not_configured" +@pytest.mark.parametrize( + "configured", + [ + None, + "", + " ", + "explicit", + pytest.param("explicit ", marks=pytest.mark.skipif(os.name == "nt", reason="POSIX trailing-space directory")), + ], +) +def test_dsh_credentials_and_configuration_share_the_same_home(tmp_path, monkeypatch, configured): + from powercontext.cli.dsh_transport import read_dsh_settings + + default_home = tmp_path / "user/.dsh" + monkeypatch.setattr(Path, "home", classmethod(lambda _cls: default_home.parent)) + if configured is None: + monkeypatch.delenv("DSH_HOME", raising=False) + else: + monkeypatch.setenv("DSH_HOME", str(tmp_path / configured) if configured.strip() else configured) + expected = tmp_path / configured if configured and configured.strip() else default_home + # A cwd profile must not be mistaken for DSH_HOME when the environment is empty. + unrelated = tmp_path / "profiles/web" + unrelated.mkdir(parents=True) + (unrelated / "cordis.patch.yml").write_text("- id: powercontext-dsh\n config:\n baseUrl: !!js secret\n") + path = credential_path("dsh") + assert path == expected / "powercontext/credentials.json" + write_stored_authorization(path, server_url="https://memory.example", value="test-token") + assert read_stored_authorization(path, server_url="https://memory.example").status == "configured" + assert read_dsh_settings() == {} + + +@pytest.mark.parametrize("configured", ["relative ", " relative", " /data/dsh ", "/data/my dsh"]) +def test_dsh_credential_home_preserves_nonblank_path_whitespace(monkeypatch, configured): + monkeypatch.setenv("DSH_HOME", configured) + assert credential_path("dsh") == Path(configured).expanduser() / "powercontext/credentials.json" + + def test_setup_uses_host_specific_credentials_and_endpoints(monkeypatch) -> None: monkeypatch.setenv("POWERCONTEXT_OPENCODE_AUTHORIZATION", "Bearer host-token") monkeypatch.setenv("POWERCONTEXT_OPENCODE_BASE_URL", "https://memory.example/api") diff --git a/tests/test_dsh_transport.py b/tests/test_dsh_transport.py new file mode 100644 index 000000000..3431ac059 --- /dev/null +++ b/tests/test_dsh_transport.py @@ -0,0 +1,1045 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Native DSH composition and setup policy, without mutating the user's host.""" + +import json +import os +import shutil +import subprocess +from pathlib import Path +from shutil import which +from unittest.mock import Mock + +import pytest +from typer.testing import CliRunner + +from powercontext.cli.app import create_cli +from powercontext.cli.dsh_transport import matching_dsh_consent, read_dsh_settings, validate_dsh_setup_transport +from powercontext.cli.native_transport import resolve_host_transport +from powercontext.cli.system import setup_app +from powercontext.cli.transport import prepare_setup_transport + + +@pytest.fixture +def dsh_profile(tmp_path, monkeypatch, request): + """Use a real installed DSH parser, with an entirely disposable profile.""" + import powercontext.cli.dsh as dsh + + executable = os.environ.get("DSH_TEST_EXECUTABLE") or which("dsh.cmd" if os.name == "nt" else "dsh") + if not executable: + if request.config.getoption("--require-dsh-runtime"): + pytest.fail("Required DSH runtime is unavailable; set DSH_TEST_EXECUTABLE") + pytest.skip("Native DSH composition requires DSH; set DSH_TEST_EXECUTABLE to select a runtime") + monkeypatch.setattr(dsh, "dsh_executable", lambda: executable) + for key in list(os.environ): + if key.startswith("POWERCONTEXT_") or key == "DSH_PROFILE": + monkeypatch.delenv(key) + monkeypatch.setenv("POWERCONTEXT_CLIENT_CONFIG_FILE", str(tmp_path / "clients.json")) + monkeypatch.setenv("DSH_HOME", str(tmp_path / "dsh")) + monkeypatch.chdir(tmp_path) + profile = tmp_path / "dsh/profiles/web" + profile.mkdir(parents=True) + (profile / "package.json").write_text(json.dumps({"dsh": {"profile": {"bundles": []}}})) + return profile + + +@pytest.fixture(scope="session") +def dsh_config_boot(request): + """Resolve the pinned native configuration API package independently of the legacy SDK.""" + location = os.environ.get("DSH_TEST_CONFIG_BOOT") + if location is None and (node := which("node")): + manifest = ( + Path(__file__).resolve().parents[1] + / "integrations/dsh/plugins/powercontext/tests/config-runtime/package.json" + ) + result = subprocess.run( + [ + node, + "--input-type=module", + "-e", + "import { createRequire } from 'node:module'; " + "process.stdout.write(createRequire(process.argv[1]).resolve('@deepseek-ai/dsh-app-boot'))", + str(manifest), + ], + capture_output=True, + text=True, + check=False, + timeout=30, + ) + if result.returncode == 0: + location = result.stdout + if not location or not Path(location).is_file(): + if request.config.getoption("--require-dsh-runtime") or request.config.getoption( + "--require-dsh-config-runtime" + ): + pytest.fail("Required DSH configuration runtime is unavailable; install tests/config-runtime dependencies") + pytest.skip("Native DSH compatibility tests require tests/config-runtime dependencies") + return Path(location) + + +@pytest.fixture +def dsh_native_api_profile(dsh_config_boot, tmp_path, monkeypatch): + """Use native 0.2 configuration APIs with a synthetic carrier and disposable profile.""" + import powercontext.cli.dsh as dsh + + carrier = tmp_path / "carrier" + package = carrier / "node_modules/@deepseek-ai/dsh" + package.mkdir(parents=True) + (package / "package.json").write_text( + json.dumps({ + "name": "@deepseek-ai/dsh", + "dependencies": { + "@deepseek-ai/dsh-app-boot": "0.2.0-rc.2", + "@deepseek-ai/dsh-plugin-manager": "0.2.0-rc.2", + }, + }) + ) + boot = package.parent / "dsh-app-boot" + boot.mkdir() + (boot / "package.json").write_text(json.dumps({"type": "module", "main": str(dsh_config_boot)})) + executable = carrier / "dsh" + executable.write_text("// Configuration inspection fixture; not an executable host.\n") + monkeypatch.setattr(dsh, "dsh_executable", lambda: str(executable)) + for key in list(os.environ): + if key.startswith("POWERCONTEXT_") or key == "DSH_PROFILE": + monkeypatch.delenv(key) + monkeypatch.setenv("POWERCONTEXT_HOME", str(tmp_path / "data")) + monkeypatch.setenv("POWERCONTEXT_CLIENT_CONFIG_FILE", str(tmp_path / "clients.json")) + monkeypatch.setenv("DSH_HOME", str(tmp_path / "dsh")) + profile = tmp_path / "dsh/profiles/web" + profile.mkdir(parents=True) + (profile / "package.json").write_text(json.dumps({"dsh": {"profile": {"bundles": []}}})) + return profile + + +def write_patch(profile, content): + path = profile / "cordis.patch.yml" + path.write_text(content, encoding="utf-8") + return path + + +def plugin_source(tmp_path): + plugin = tmp_path / "plugin" + plugin.mkdir() + (plugin / "package.json").write_text( + json.dumps({"name": "powercontext-dsh", "dsh": {"bundle": {"patch": "cordis.patch.yml"}}}) + ) + (plugin / "cordis.patch.yml").write_text( + "- insert:\n - id: powercontext-dsh\n name: powercontext-dsh\n config: {}\n" + ) + (plugin / "lib").mkdir() + (plugin / "lib/index.js").write_text("export const name = 'powercontext-dsh'\n") + return plugin + + +def test_existing_customizations_allow_public_setup_and_preserve_files(dsh_profile, tmp_path, monkeypatch): + import powercontext.cli.dsh as dsh + + patch = write_patch( + dsh_profile, + "- insert:\n - id: ui\n name: ui\n- id: ui\n config:\n theme: dark\n title: 自定义界面\n model: !!js process.env.MODEL\n", + ) + source = plugin_source(tmp_path) + shutil.copytree(source, dsh_profile / "node_modules/powercontext-dsh") + (dsh_profile / "package.json").write_text( + json.dumps({ + "dependencies": {"powercontext-dsh": f"link:{source.as_posix()}"}, + "dsh": {"profile": {"bundles": ["powercontext-dsh"]}}, + }) + ) + original = {path: path.read_bytes() for path in dsh_profile.iterdir() if path.is_file()} + monkeypatch.setenv("POWERCONTEXT_HOME", str(tmp_path / "data")) + installer = Mock(return_value="id: powercontext-dsh\n") + monkeypatch.setattr(dsh, "_run_dsh", installer) + for _ in range(2): + result = CliRunner().invoke(create_cli([setup_app]), ["setup", "dsh", "--source", str(source), "--json"]) + assert result.exit_code == 0, result.output + assert ( + json.loads((tmp_path / "clients.json").read_text())["hosts"]["dsh"]["server_url"] == "http://127.0.0.1:8000" + ) + assert patch.read_bytes() == original[patch] + assert {path: path.read_bytes() for path in dsh_profile.iterdir() if path.is_file()} == original + + +@pytest.mark.parametrize("relative", ["profiles/web/cordis.patch.yml", "cordis.patch.yml"]) +def test_matching_native_transport_passes_and_conflicts_do_not_install(dsh_profile, tmp_path, monkeypatch, relative): + import powercontext.cli.dsh as dsh + + patch = tmp_path / "dsh" / relative + patch.write_text("- id: powercontext-dsh\n config:\n baseUrl: https://memory.example\n") + assert prepare_setup_transport("dsh", server_url="https://memory.example").server_url == "https://memory.example" + installer = Mock() + monkeypatch.setattr(dsh, "_run_dsh", installer) + result = CliRunner().invoke( + create_cli([setup_app]), ["setup", "dsh", "--server-url", "https://other.example", "--json"] + ) + assert result.exit_code == 1 + assert "baseUrl conflicts" in result.output + installer.assert_not_called() + assert not (tmp_path / "clients.json").exists() + + +def test_setup_uses_web_profile_and_home_layer_wins(dsh_profile, monkeypatch): + write_patch(dsh_profile, "- id: powercontext-dsh\n config:\n baseUrl: https://profile.example\n") + write_patch(dsh_profile.parent.parent, "- id: powercontext-dsh\n config:\n baseUrl: https://home.example\n") + monkeypatch.setenv("DSH_PROFILE", "other") + assert prepare_setup_transport("dsh").server_url == "https://home.example" + + +def test_doctor_reads_the_selected_runtime_profile(dsh_profile, monkeypatch): + profile = dsh_profile.parent / "custom" + profile.mkdir() + (profile / "package.json").write_bytes((dsh_profile / "package.json").read_bytes()) + write_patch( + profile, + "- insert:\n - id: powercontext-dsh\n name: powercontext-dsh\n" + " config:\n baseUrl: https://custom.example\n", + ) + monkeypatch.setenv("DSH_PROFILE", "custom") + assert resolve_host_transport("dsh") == ("https://custom.example", False) + + +@pytest.mark.parametrize( + "patch", + [ + "- id: powercontext-dsh\n config:\n baseUrl: !!js (() => { throw new Error('secret-value') })()\n", + "- id: powercontext-dsh\n config:\n allowInsecureHttp: !!js process.env.SECRET\n", + "- id: powercontext-dsh\n disabled: true\n", + "- insert:\n - id: powercontext-dsh\n name: powercontext-dsh\n", + "- id: powercontext-dsh\n name: wrong-plugin\n config: {}\n", + "- id: powercontext-dsh\n config: [broken: secret-value\n", + ], +) +def test_unverifiable_relevant_configuration_is_redacted(dsh_profile, patch): + path = write_patch(dsh_profile, patch) + with pytest.raises(ValueError) as error: + read_dsh_settings(prospective=True) + assert "secret-value" not in str(error.value) + assert path.read_text() == patch + + +@pytest.mark.parametrize("profile_fixture", ["dsh_profile", "dsh_native_api_profile"]) +@pytest.mark.parametrize( + "group", [{"group": True}, {"name": "@deepseek-ai/cordis-plugin-group"}, {"name": "cordis:group"}] +) +@pytest.mark.parametrize("outer_group", [None, {"group": True}, {"name": "cordis:group"}]) +def test_setup_rejects_duplicate_powercontext_inside_native_groups( + request, profile_fixture, group, outer_group, tmp_path, monkeypatch +): + import powercontext.cli.dsh as dsh + from powercontext.cli.authorization import credential_path, write_stored_authorization + + profile = request.getfixturevalue(profile_fixture) + row = { + "id": "extra-group", + **group, + "config": [ + { + "id": "powercontext-dsh", + "name": "powercontext-dsh", + "config": {"baseUrl": "https://unexpected.example"}, + } + ], + } + if outer_group: + row = {"id": "outer-group", **outer_group, "config": [row]} + patch = write_patch(profile, json.dumps([{"insert": [row]}])) + clients = tmp_path / "clients.json" + clients.write_text('{"version":1,"hosts":{"dsh":{"server_url":"https://saved.example"}}}') + credentials = credential_path("dsh") + write_stored_authorization(credentials, server_url="https://saved.example", value="saved-token") + original = {path: path.read_bytes() for path in (patch, clients, credentials)} + installer = Mock() + monkeypatch.setattr(dsh, "_run_dsh", installer) + monkeypatch.setenv("POWERCONTEXT_DSH_AUTHORIZATION", "Bearer new-token") + + with pytest.raises(ValueError, match="Multiple PowerContext entries"): + read_dsh_settings(prospective=True) + result = CliRunner().invoke( + create_cli([setup_app]), ["setup", "dsh", "--server-url", "https://selected.example", "--json"] + ) + assert result.exit_code == 1 + assert "Multiple PowerContext entries" in result.output + installer.assert_not_called() + assert {path: path.read_bytes() for path in original} == original + + +@pytest.mark.parametrize("profile_fixture", ["dsh_profile", "dsh_native_api_profile"]) +@pytest.mark.parametrize( + "group", [{"group": True}, {"name": "@deepseek-ai/cordis-plugin-group"}, {"name": "cordis:group"}] +) +def test_a_single_powercontext_inside_a_native_group_is_inspected(request, profile_fixture, tmp_path, group): + profile = request.getfixturevalue(profile_fixture) + source = plugin_source(tmp_path) + (source / "cordis.patch.yml").write_text( + json.dumps([ + { + "insert": [ + { + "id": "powercontext-group", + **group, + "config": [ + { + "id": "powercontext-dsh", + "name": "powercontext-dsh", + "config": {"baseUrl": "https://selected.example", "allowInsecureHttp": False}, + } + ], + } + ] + } + ]) + ) + settings = { + "baseUrl": "https://selected.example", + "allowInsecureHttp": False, + } + assert read_dsh_settings(candidate=source) == settings + shutil.copytree(source, profile / "node_modules/powercontext-dsh") + (profile / "package.json").write_text( + json.dumps({ + "dependencies": {"powercontext-dsh": f"link:{source.as_posix()}"}, + "dsh": {"profile": {"bundles": ["powercontext-dsh"]}}, + }) + ) + assert read_dsh_settings(require_installed=True) == settings + assert resolve_host_transport("dsh") == ("https://selected.example", False) + + +@pytest.mark.parametrize( + "group", [{"group": True}, {"name": "@deepseek-ai/cordis-plugin-group"}, {"name": "cordis:group"}] +) +@pytest.mark.parametrize("parent", [False, True]) +@pytest.mark.parametrize("conditional", [False, True]) +def test_native_groups_reject_disabled_and_conditional_powercontext( + dsh_native_api_profile, tmp_path, monkeypatch, group, parent, conditional +): + from powercontext.cli import dsh + from powercontext.cli.authorization import credential_path, write_stored_authorization + + profile = dsh_native_api_profile + child: dict[str, object] = {"id": "powercontext-dsh", "name": "powercontext-dsh", "config": {}} + row: dict[str, object] = {"id": "extra-group", **group, "config": [child]} + (row if parent else child)["disabled"] = {"__jsExpr": "process.env.DISABLED"} if conditional else True + patch = write_patch(profile, json.dumps([{"insert": [row]}])) + clients = tmp_path / "clients.json" + clients.write_text('{"version":1,"hosts":{"dsh":{"server_url":"https://saved.example"}}}') + credentials = credential_path("dsh") + write_stored_authorization(credentials, server_url="https://saved.example", value="saved-token") + original = {path: path.read_bytes() for path in (patch, clients, credentials)} + installer = Mock() + monkeypatch.setattr(dsh, "_run_dsh", installer) + monkeypatch.setenv("POWERCONTEXT_DSH_AUTHORIZATION", "Bearer new-token") + + with pytest.raises(ValueError, match="renamed, disabled, or conditionally enabled"): + read_dsh_settings(prospective=True) + result = CliRunner().invoke( + create_cli([setup_app]), ["setup", "dsh", "--server-url", "https://selected.example", "--json"] + ) + assert result.exit_code == 1 + assert "renamed, disabled, or conditionally enabled" in result.output + installer.assert_not_called() + assert {path: path.read_bytes() for path in original} == original + + +@pytest.mark.parametrize("profile_fixture", ["dsh_profile", "dsh_native_api_profile"]) +@pytest.mark.parametrize("nested_group", [None, "@deepseek-ai/cordis-plugin-group", "cordis:group"]) +def test_setup_rejects_powercontext_in_native_include_trees( + request, profile_fixture, nested_group, tmp_path, monkeypatch +): + from powercontext.cli import dsh + from powercontext.cli.authorization import credential_path, write_stored_authorization + + profile = request.getfixturevalue(profile_fixture) + directory = profile / "custom files" + directory.mkdir() + included = directory / "entries.json" + powercontext = { + "id": "powercontext-dsh", + "name": "powercontext-dsh", + "config": { + "baseUrl": "https://unexpected.example", + }, + } + included.write_text(json.dumps([powercontext] if nested_group is None else [])) + row = { + "id": "custom-include", + "name": "@deepseek-ai/cordis-plugin-include", + "config": { + "path": "./custom files/entries.json", + }, + } + if nested_group: + # Resolve the second include from its containing file, and apply its + # own insert patch before looking through the named group it creates. + outer = directory / "outer.yml" + outer.write_text( + json.dumps([ + { + "id": "inner-include", + "name": "cordis:include", + "config": { + "path": "./entries.json", + "patches": [ + { + "insert": [ + { + "id": "included-group", + "name": nested_group, + "config": [powercontext], + } + ] + } + ], + }, + } + ]) + ) + row["config"]["path"] = "./custom files/outer.yml" + row = {"id": "outer-group", "group": True, "config": [row]} + patch = write_patch(profile, json.dumps([{"insert": [row]}])) + clients = tmp_path / "clients.json" + clients.write_text('{"version":1,"hosts":{"dsh":{"server_url":"https://saved.example"}}}') + credentials = credential_path("dsh") + write_stored_authorization(credentials, server_url="https://saved.example", value="saved-token") + original = {path: path.read_bytes() for path in (patch, included, clients, credentials)} + installer = Mock() + monkeypatch.setattr(dsh, "_run_dsh", installer) + monkeypatch.setenv("POWERCONTEXT_DSH_AUTHORIZATION", "Bearer new-secret-token") + + with pytest.raises(ValueError, match="Multiple PowerContext entries"): + read_dsh_settings(prospective=True) + result = CliRunner().invoke( + create_cli([setup_app]), ["setup", "dsh", "--server-url", "https://selected.example", "--json"] + ) + assert result.exit_code == 1 + assert "Multiple PowerContext entries" in result.output + assert "secret-token" not in result.output + installer.assert_not_called() + assert {path: path.read_bytes() for path in original} == original + + +@pytest.mark.parametrize("profile_fixture", ["dsh_profile", "dsh_native_api_profile"]) +def test_setup_preserves_static_native_ui_includes_and_unevaluated_model_expressions( + request, profile_fixture, tmp_path, monkeypatch +): + from powercontext.cli import dsh + + profile = request.getfixturevalue(profile_fixture) + source = plugin_source(tmp_path) + shutil.copytree(source, profile / "node_modules/powercontext-dsh") + (profile / "package.json").write_text( + json.dumps({ + "dependencies": {"powercontext-dsh": f"link:{source.as_posix()}"}, + "dsh": {"profile": {"bundles": ["powercontext-dsh"]}}, + }) + ) + directory = profile / "custom files" + directory.mkdir() + (directory / "ui.yml").write_text( + "- id: ui\n name: ui\n config:\n title: 自定义界面\n" + " model: !!js (() => { throw new Error('secret-value') })()\n", + encoding="utf-8", + ) + (directory / "outer.json").write_text( + json.dumps([ + { + "id": "nested-include", + "name": "@deepseek-ai/cordis-plugin-include", + "config": { + "path": "./ui.yml", + "patches": [{"id": "ui", "disabled": False}], + }, + } + ]) + ) + # All outer layers resolve include paths from the profile root, even when + # the include itself comes from the home-level patch file. + write_patch( + profile.parent.parent, + json.dumps([ + { + "insert": [ + { + "id": "ui-group", + "name": "@deepseek-ai/cordis-plugin-group", + "config": [ + { + "id": "ui-include", + "name": "cordis:include", + "config": {"path": "./custom files/outer.json"}, + } + ], + } + ] + } + ]), + ) + original = {path: path.read_bytes() for path in profile.parent.parent.rglob("*") if path.is_file()} + monkeypatch.setenv("POWERCONTEXT_HOME", str(tmp_path / "data")) + monkeypatch.setattr(dsh, "_run_dsh", Mock(return_value="id: powercontext-dsh\n")) + for _ in range(2): + result = CliRunner().invoke( + create_cli([setup_app]), + ["setup", "dsh", "--source", str(source), "--server-url", "https://selected.example", "--json"], + ) + assert result.exit_code == 0, result.output + assert read_dsh_settings(require_installed=True) == {} + assert json.loads((tmp_path / "clients.json").read_text())["hosts"]["dsh"]["server_url"] == ( + "https://selected.example" + ) + assert {path: path.read_bytes() for path in original} == original + + +@pytest.mark.parametrize("profile_fixture", ["dsh_profile", "dsh_native_api_profile"]) +def test_installed_native_include_patches_define_powercontext_transport(request, profile_fixture, tmp_path): + profile = request.getfixturevalue(profile_fixture) + included = profile / "transport.yml" + included.write_text( + "- id: powercontext-dsh\n name: powercontext-dsh\n config:\n baseUrl: https://unexpected.example\n" + ) + source = plugin_source(tmp_path) + (source / "cordis.patch.yml").write_text( + json.dumps([ + { + "insert": [ + { + "id": "transport-include", + "name": "@deepseek-ai/cordis-plugin-include", + "config": { + "path": included.as_uri(), + "patches": [ + { + "id": "powercontext-dsh", + "config": { + "baseUrl": "http://selected.example", + "allowInsecureHttp": True, + }, + } + ], + }, + } + ] + } + ]) + ) + shutil.copytree(source, profile / "node_modules/powercontext-dsh") + (profile / "package.json").write_text( + json.dumps({ + "dependencies": {"powercontext-dsh": f"link:{source.as_posix()}"}, + "dsh": {"profile": {"bundles": ["powercontext-dsh"]}}, + }) + ) + settings = {"baseUrl": "http://selected.example", "allowInsecureHttp": True} + assert read_dsh_settings(candidate=source) == settings + assert read_dsh_settings(require_installed=True) == settings + assert prepare_setup_transport("dsh", server_url="http://selected.example", json_output=True).allow_insecure_http + with pytest.raises(ValueError, match="HTTP consent"): + validate_dsh_setup_transport(settings, "http://selected.example", False) + bundle_patch = source / "cordis.patch.yml" + patches = json.loads(bundle_patch.read_text()) + patches[0]["insert"].append({**patches[0]["insert"][0], "id": "second-include"}) + bundle_patch.write_text(json.dumps(patches)) + with pytest.raises(ValueError, match="Multiple PowerContext entries"): + read_dsh_settings(candidate=source) + + +@pytest.mark.parametrize("profile_fixture", ["dsh_profile", "dsh_native_api_profile"]) +@pytest.mark.parametrize("parent", [False, True]) +@pytest.mark.parametrize("conditional", [False, True]) +def test_native_include_checks_inherited_and_conditional_activation(request, profile_fixture, parent, conditional): + profile = request.getfixturevalue(profile_fixture) + (profile / "included.json").write_text( + json.dumps([ + { + "id": "powercontext-dsh", + "name": "powercontext-dsh", + "config": {}, + } + ]) + ) + disabled = {"__jsExpr": "false"} if conditional else True + row = { + "id": "included", + "name": "@deepseek-ai/cordis-plugin-include", + "config": { + "path": "./included.json", + }, + } + if parent: + row = {"id": "named-group", "name": "@deepseek-ai/cordis-plugin-group", "config": [row]} + row = {**row, "disabled": disabled} + write_patch(profile, json.dumps([{"insert": [row]}])) + if conditional: + with pytest.raises(ValueError, match="conditionally enabled"): + read_dsh_settings(prospective=True) + else: + assert read_dsh_settings(prospective=True) == {} + + +@pytest.mark.parametrize("profile_fixture", ["dsh_profile", "dsh_native_api_profile"]) +@pytest.mark.parametrize( + "case", + [ + "dynamic-path", + "dynamic-patches", + "dynamic-target", + "remote", + "initial", + "cycle", + "dynamic-tree", + ], +) +def test_unverifiable_native_include_trees_fail_without_writes_or_expression_evaluation( + request, profile_fixture, case, tmp_path, monkeypatch +): + from powercontext.cli import dsh + + profile = request.getfixturevalue(profile_fixture) + path = profile / "included.yml" + path.write_text("[]\n") + config = {"path": "./included.yml"} + if case == "dynamic-path": + config["path"] = {"__jsExpr": "(() => { throw new Error('secret-value') })()"} + elif case == "dynamic-patches": + config["patches"] = {"__jsExpr": "process.env.SECRET"} + elif case == "dynamic-target": + config["patches"] = [{"id": {"__jsExpr": "process.env.SECRET"}, "config": {}}] + elif case == "remote": + config["path"] = "https://example.test/secret-value.yml" + elif case == "initial": + path.unlink() + config["initial"] = [] + elif case == "cycle": + (profile / "nested").mkdir() + path.write_text( + json.dumps([ + { + "id": "cycle", + "name": "cordis:include", + "config": { + "path": "./nested/../included.yml", + }, + } + ]) + ) + elif case == "dynamic-tree": + path.write_text("- id: dynamic-group\n group: true\n config: !!js process.env.SECRET\n") + patch = write_patch( + profile, + json.dumps([ + { + "insert": [ + { + "id": "included", + "name": "@deepseek-ai/cordis-plugin-include", + "config": config, + } + ] + } + ]), + ) + original = {file: file.read_bytes() for file in profile.rglob("*") if file.is_file()} + installer = Mock() + monkeypatch.setattr(dsh, "_run_dsh", installer) + result = CliRunner().invoke( + create_cli([setup_app]), ["setup", "dsh", "--server-url", "https://selected.example", "--json"] + ) + assert result.exit_code == 1 + assert "secret-value" not in result.output + installer.assert_not_called() + assert not (tmp_path / "clients.json").exists() + assert not (profile.parent.parent / "powercontext/credentials.json").exists() + assert patch.exists() + assert {file: file.read_bytes() for file in original} == original + assert path.exists() is (case != "initial") + + +@pytest.mark.parametrize("profile_fixture", ["dsh_profile", "dsh_native_api_profile"]) +def test_postinstall_include_drift_preserves_saved_connection_and_credentials( + request, profile_fixture, tmp_path, monkeypatch +): + from powercontext.cli import dsh + from powercontext.cli.authorization import credential_path, write_stored_authorization + + profile = request.getfixturevalue(profile_fixture) + source = plugin_source(tmp_path) + clients = tmp_path / "clients.json" + clients.write_text('{"version":1,"hosts":{"dsh":{"server_url":"https://saved.example"}}}') + credentials = credential_path("dsh") + write_stored_authorization(credentials, server_url="https://saved.example", value="saved-token") + original = {path: path.read_bytes() for path in (clients, credentials)} + monkeypatch.setenv("POWERCONTEXT_HOME", str(tmp_path / "data")) + monkeypatch.setenv("POWERCONTEXT_DSH_AUTHORIZATION", "Bearer new-secret-token") + + def install(*_args): + shutil.copytree(source, profile / "node_modules/powercontext-dsh") + (profile / "package.json").write_text(json.dumps({"dsh": {"profile": {"bundles": ["powercontext-dsh"]}}})) + (profile / "included.json").write_text( + json.dumps([ + { + "id": "powercontext-dsh", + "name": "powercontext-dsh", + "config": {"baseUrl": "https://unexpected.example"}, + } + ]) + ) + write_patch( + profile, + json.dumps([ + { + "insert": [ + { + "id": "drift-include", + "name": "cordis:include", + "config": {"path": "./included.json"}, + } + ] + } + ]), + ) + return "" + + # Actual native parsing follows an injected change at the installation boundary. + monkeypatch.setattr(dsh, "_run_dsh", install) + result = CliRunner().invoke( + create_cli([setup_app]), + ["setup", "dsh", "--source", str(source), "--server-url", "https://selected.example", "--json"], + ) + assert result.exit_code == 1 + assert "resulting configuration" in result.output + assert "Multiple PowerContext entries" in result.output + assert "secret-token" not in result.output + assert {path: path.read_bytes() for path in original} == original + + +def test_candidate_bundle_is_checked_before_install(dsh_profile, tmp_path, monkeypatch): + import powercontext.cli.dsh as dsh + + source = plugin_source(tmp_path) + (source / "cordis.patch.yml").write_text( + "- insert:\n - id: powercontext-dsh\n name: powercontext-dsh\n config:\n baseUrl: https://candidate.example\n" + ) + installer = Mock() + monkeypatch.setattr(dsh, "_run_dsh", installer) + monkeypatch.setenv("POWERCONTEXT_HOME", str(tmp_path / "data")) + result = CliRunner().invoke( + create_cli([setup_app]), + ["setup", "dsh", "--source", str(source), "--server-url", "https://selected.example", "--json"], + ) + assert result.exit_code == 1 + assert "baseUrl conflicts" in result.output + installer.assert_not_called() + assert not (tmp_path / "clients.json").exists() + + +def test_candidate_replaces_installed_bundle_without_duplicate_entries(dsh_profile, tmp_path): + source = plugin_source(tmp_path) + installed = dsh_profile / "node_modules/powercontext-dsh" + installed.mkdir(parents=True) + (installed / "package.json").write_bytes((source / "package.json").read_bytes()) + (installed / "cordis.patch.yml").write_bytes((source / "cordis.patch.yml").read_bytes()) + (dsh_profile / "package.json").write_text(json.dumps({"dsh": {"profile": {"bundles": ["powercontext-dsh"]}}})) + write_patch( + dsh_profile, + "- id: powercontext-dsh\n config:\n baseUrl: http://memory.example\n allowInsecureHttp: true\n", + ) + assert read_dsh_settings(candidate=source)["baseUrl"] == "http://memory.example" + assert resolve_host_transport("dsh") == ("http://memory.example", True) + + +def test_unreadable_bundle_is_not_assumed_to_be_unrelated(dsh_profile): + (dsh_profile / "package.json").write_text(json.dumps({"dsh": {"profile": {"bundles": ["missing-bundle"]}}})) + with pytest.raises(ValueError, match="Cannot read"): + read_dsh_settings(prospective=True) + + +def test_remote_http_consent_and_environment_precedence(dsh_profile, monkeypatch): + write_patch( + dsh_profile, + "- id: powercontext-dsh\n config:\n baseUrl: http://memory.example\n allowInsecureHttp: true\n", + ) + assert prepare_setup_transport("dsh", json_output=True).allow_insecure_http is True + monkeypatch.setenv("POWERCONTEXT_DSH_BASE_URL", "http://other.example") + with pytest.raises(RuntimeError, match="Remote HTTP"): + prepare_setup_transport("dsh", server_url="http://other.example", json_output=True) + monkeypatch.setenv("POWERCONTEXT_DSH_ALLOW_INSECURE_HTTP", "true") + assert ( + prepare_setup_transport("dsh", server_url="http://other.example", json_output=True).allow_insecure_http is True + ) + + +@pytest.mark.parametrize( + ("settings", "endpoint", "expected"), + [ + ({"baseUrl": "http://a.example", "allowInsecureHttp": True}, "http://a.example/", True), + ({"baseUrl": "http://a.example", "allowInsecureHttp": True}, "http://b.example", None), + ({"allowInsecureHttp": False}, "http://b.example", False), + ({}, "http://b.example", None), + ], +) +def test_native_consent_is_endpoint_bound(settings, endpoint, expected): + assert matching_dsh_consent(settings, endpoint) is expected + + +def test_explicit_refusal_cannot_be_overridden_by_native_permission(monkeypatch): + for key in list(os.environ): + if key.startswith("POWERCONTEXT_"): + monkeypatch.delenv(key) + with pytest.raises(ValueError, match="HTTP consent"): + validate_dsh_setup_transport( + {"baseUrl": "http://a.example", "allowInsecureHttp": True}, "http://a.example", False + ) + + +@pytest.mark.parametrize("profile_fixture", ["dsh_profile", "dsh_native_api_profile"]) +@pytest.mark.parametrize( + ("patch", "reason"), + [ + ("- id: powercontext-dsh\n config:\n baseUrl: https://unexpected.example\n", "baseUrl conflicts"), + *[ + ( + json.dumps([ + { + "insert": [ + { + "id": "extra-group", + "name": name, + "config": [{"id": "powercontext-dsh", "name": "powercontext-dsh", "config": {}}], + } + ] + } + ]), + "Multiple PowerContext entries", + ) + for name in ("@deepseek-ai/cordis-plugin-group", "cordis:group") + ], + ], +) +def test_setup_checks_actual_installed_transport_before_saving_connection( + request, profile_fixture, tmp_path, monkeypatch, patch, reason +): + import powercontext.cli.dsh as dsh + from powercontext.cli.authorization import credential_path, write_stored_authorization + + profile = request.getfixturevalue(profile_fixture) + source = plugin_source(tmp_path) + clients = tmp_path / "clients.json" + clients.write_text('{"version":1,"hosts":{"dsh":{"server_url":"https://saved.example"}}}') + credentials = credential_path("dsh") + write_stored_authorization(credentials, server_url="https://saved.example", value="saved-token") + original = {path: path.read_bytes() for path in (clients, credentials)} + monkeypatch.setenv("POWERCONTEXT_HOME", str(tmp_path / "data")) + monkeypatch.setenv("POWERCONTEXT_DSH_AUTHORIZATION", "Bearer new-token") + + def install(*_args): + shutil.copytree(source, profile / "node_modules/powercontext-dsh") + (profile / "package.json").write_text(json.dumps({"dsh": {"profile": {"bundles": ["powercontext-dsh"]}}})) + write_patch(profile, patch) + return "" + + # Inject configuration drift at the native installation boundary. The native + # parser reads the resulting files; this does not simulate native add itself. + monkeypatch.setattr(dsh, "_run_dsh", install) + result = CliRunner().invoke( + create_cli([setup_app]), + ["setup", "dsh", "--source", str(source), "--server-url", "https://selected.example", "--json"], + ) + assert result.exit_code == 1 + assert "resulting configuration" in result.output + assert reason in result.output + assert {path: path.read_bytes() for path in original} == original + + +def test_current_transport_does_not_activate_an_inactive_dependency(dsh_profile, tmp_path): + source = plugin_source(tmp_path) + shutil.copytree(source, dsh_profile / "node_modules/powercontext-dsh") + inactive = dsh_profile / "node_modules/transport-override" + inactive.mkdir() + (inactive / "package.json").write_text( + json.dumps({"name": "transport-override", "dsh": {"bundle": {"patch": "cordis.patch.yml"}}}) + ) + (inactive / "cordis.patch.yml").write_text( + "- id: powercontext-dsh\n config:\n baseUrl: https://inactive.example\n" + ) + manifest = dsh_profile / "package.json" + manifest.write_text( + json.dumps({ + "dependencies": {"powercontext-dsh": f"link:{source.as_posix()}", "transport-override": "1.0.0"}, + "dsh": {"profile": {"bundles": ["powercontext-dsh"]}}, + }) + ) + original = manifest.read_bytes() + assert read_dsh_settings(require_installed=True) == {} + assert resolve_host_transport("dsh") == ("http://127.0.0.1:8000", False) + assert manifest.read_bytes() == original + + +@pytest.mark.parametrize( + ("settings", "arguments", "reason"), + [ + ({"baseUrl": "https://native.example"}, ["--server-url", "https://selected.example"], "baseUrl conflicts"), + ({"baseUrl": "http://remote.example"}, [], "Remote HTTP"), + ( + {"baseUrl": "http://remote.example", "allowInsecureHttp": False}, + ["--allow-insecure-http"], + "HTTP consent", + ), + ( + {"baseUrl": "https://selected.example", "allowInsecureHttp": True}, + ["--no-allow-insecure-http"], + "HTTP consent", + ), + ], +) +def test_setup_transport_refusal_preserves_saved_state_without_a_native_runtime( + tmp_path, monkeypatch, settings, arguments, reason +): + from powercontext.cli import dsh, dsh_transport + from powercontext.cli.authorization import credential_path, write_stored_authorization + + for key in list(os.environ): + if key.startswith("POWERCONTEXT_"): + monkeypatch.delenv(key) + clients = tmp_path / "clients.json" + clients.write_text('{"version":1,"hosts":{"dsh":{"server_url":"https://saved.example"}}}') + monkeypatch.setenv("POWERCONTEXT_CLIENT_CONFIG_FILE", str(clients)) + monkeypatch.setattr(dsh_transport, "read_dsh_settings", lambda **_options: settings) + installer = Mock() + monkeypatch.setattr(dsh, "install_dsh_plugin", installer) + original = clients.read_bytes() + credentials = credential_path("dsh") + write_stored_authorization(credentials, server_url="https://saved.example", value="saved-token") + original_credentials = credentials.read_bytes() + + if "--server-url" not in arguments: + arguments = ["--server-url", settings["baseUrl"], *arguments] + result = CliRunner().invoke(create_cli([setup_app]), ["setup", "dsh", "--json", *arguments]) + + assert result.exit_code == 1 + assert reason in result.output + installer.assert_not_called() + assert clients.read_bytes() == original + assert credentials.read_bytes() == original_credentials + + +def test_matching_native_transport_is_accepted_without_a_native_runtime(monkeypatch): + from powercontext.cli import dsh_transport + + for key in list(os.environ): + if key.startswith("POWERCONTEXT_"): + monkeypatch.delenv(key) + monkeypatch.setenv("DSH_PROFILE", "custom") + monkeypatch.setattr( + dsh_transport, + "read_dsh_settings", + lambda **_options: {"baseUrl": "http://remote.example", "allowInsecureHttp": True}, + ) + transport = prepare_setup_transport("dsh", server_url="http://remote.example/", json_output=True) + assert transport.server_url == "http://remote.example" + assert transport.allow_insecure_http is True + + +def compatibility_profile(profile, tmp_path, *, incompatible="custom-bundle"): + source = plugin_source(tmp_path) + metadata = json.loads((source / "package.json").read_text()) + metadata["version"] = "1.0.0" + if incompatible == "powercontext-dsh": + metadata["peerDependencies"] = {"@deepseek-ai/dsh-app-boot": "999.0.0"} + (source / "package.json").write_text(json.dumps(metadata)) + shutil.copytree(source, profile / "node_modules/powercontext-dsh") + custom = profile / "node_modules/custom-bundle" + custom.mkdir() + metadata = {"name": "custom-bundle", "version": "1.0.0", "dsh": {"bundle": {"patch": "cordis.patch.yml"}}} + if incompatible == "custom-bundle": + metadata["peerDependencies"] = {"@deepseek-ai/dsh-app-boot": "999.0.0"} + (custom / "package.json").write_text(json.dumps(metadata)) + (custom / "cordis.patch.yml").write_text( + "- id: powercontext-dsh\n config:\n baseUrl: https://unexpected.example\n" + ) + (profile / "package.json").write_text( + json.dumps({ + "dependencies": {"powercontext-dsh": f"link:{source.as_posix()}", "custom-bundle": "1.0.0"}, + "dsh": {"profile": {"bundles": ["powercontext-dsh", "custom-bundle"]}}, + }) + ) + return source + + +def test_incompatible_third_party_bundle_is_excluded_from_first_and_repeated_setup( + dsh_native_api_profile, tmp_path, monkeypatch +): + from powercontext.cli import dsh + + profile = dsh_native_api_profile + source = compatibility_profile(profile, tmp_path) + original = {path: path.read_bytes() for path in profile.rglob("*") if path.is_file()} + monkeypatch.setattr(dsh, "_run_dsh", Mock(return_value="id: powercontext-dsh\n")) + for _ in range(2): + result = CliRunner().invoke( + create_cli([setup_app]), + ["setup", "dsh", "--source", str(source), "--server-url", "https://selected.example", "--json"], + ) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["plugin"] == "powercontext-dsh" + assert "incompatible third-party bundle" in result.stderr + assert json.loads((tmp_path / "clients.json").read_text())["hosts"]["dsh"]["server_url"] == ( + "https://selected.example" + ) + assert {path: path.read_bytes() for path in profile.rglob("*") if path.is_file()} == original + + +def test_incompatible_powercontext_still_fails_before_installation(dsh_native_api_profile, tmp_path, monkeypatch): + from powercontext.cli import dsh + + source = compatibility_profile(dsh_native_api_profile, tmp_path, incompatible="powercontext-dsh") + installer = Mock() + monkeypatch.setattr(dsh, "_run_dsh", installer) + result = CliRunner().invoke( + create_cli([setup_app]), + ["setup", "dsh", "--source", str(source), "--server-url", "https://selected.example", "--json"], + ) + assert result.exit_code == 1 + assert "PowerContext is incompatible" in result.output + installer.assert_not_called() + assert not (tmp_path / "clients.json").exists() + + +def test_version_exemption_keeps_third_party_transport_overrides(dsh_native_api_profile, tmp_path, monkeypatch): + from powercontext.cli import dsh + + source = compatibility_profile(dsh_native_api_profile, tmp_path) + (dsh_native_api_profile / "compatibility.json").write_text(json.dumps({"custom-bundle@1.0.0": ["0.2.0-rc.2"]})) + installer = Mock() + monkeypatch.setattr(dsh, "_run_dsh", installer) + result = CliRunner().invoke( + create_cli([setup_app]), + ["setup", "dsh", "--source", str(source), "--server-url", "https://selected.example", "--json"], + ) + assert result.exit_code == 1 + assert "baseUrl conflicts" in result.output + installer.assert_not_called() + assert not (tmp_path / "clients.json").exists() + + +@pytest.mark.parametrize("available", ["module", "missing"]) +def test_unavailable_host_configuration_apis_give_recovery_guidance(dsh_native_api_profile, tmp_path, available): + package = tmp_path / "carrier/node_modules/@deepseek-ai/dsh-app-boot" + (package / "package.json").write_text(json.dumps({"type": "module", "main": "index.js"})) + entry = package / "index.js" + if available == "module": + entry.write_text("export {}\n") + with pytest.raises(ValueError, match="upgrade or reinstall DSH"): + read_dsh_settings() diff --git a/tests/test_native_transport_diagnostics.py b/tests/test_native_transport_diagnostics.py index faf571954..3e052203d 100644 --- a/tests/test_native_transport_diagnostics.py +++ b/tests/test_native_transport_diagnostics.py @@ -172,30 +172,6 @@ def test_workbuddy_divergent_mcp_and_hook_urls_are_reported_unknown(tmp_path): _resolve("workbuddy") -@pytest.mark.parametrize( - "relative_path", ["cordis.patch.yml", "profiles/web/cordis.patch.yml", "profiles/custom/cordis.patch.yml"] -) -def test_dsh_runtime_overlays_are_not_assumed_to_use_loopback(tmp_path, monkeypatch, relative_path): - path = tmp_path / "DSH_HOME" / relative_path - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text("plugins:\n powercontext:\n baseUrl: http://memory.example\n") - if "custom" in relative_path: - monkeypatch.setenv("DSH_PROFILE", "custom") - with pytest.raises(ValueError, match="determine"): - _resolve("dsh") - - -@pytest.mark.parametrize( - "contents", ["# No profile overrides\n[]\n", "# No profile overrides\n", "[] # empty patches\n"] -) -def test_dsh_empty_generated_overlay_does_not_hide_saved_transport(tmp_path, contents): - path = tmp_path / "DSH_HOME/profiles/web/cordis.patch.yml" - path.parent.mkdir(parents=True) - path.write_text(contents) - _save_shared(tmp_path, "dsh", "http://memory.example", True) - assert _resolve("dsh") == ("http://memory.example", True) - - @pytest.mark.parametrize( "contents", ["{secret-token: 'secret-value'}", '{"plugins": []}', '{"$include": "private.json"}'] ) @@ -235,20 +211,3 @@ def test_missing_native_config_preserves_shared_endpoint_and_consent(tmp_path): def test_openclaw_without_an_endpoint_is_unconfigured_instead_of_loopback(): with pytest.raises(ValueError, match="not configured"): _resolve("openclaw") - - -@pytest.mark.parametrize( - "contents", ["# default profile\n[]\n", "- id: powercontext\n config:\n baseUrl: http://old.example\n"] -) -def test_dsh_setup_preflight_accepts_inert_defaults_and_requires_manual_custom_configuration(tmp_path, contents): - from powercontext.cli.native_transport import validate_dsh_setup_transport - - path = tmp_path / "DSH_HOME/profiles/web/cordis.patch.yml" - path.parent.mkdir(parents=True) - path.write_text(contents) - if "old.example" in contents: - with pytest.raises(ValueError, match="manually"): - validate_dsh_setup_transport() - else: - validate_dsh_setup_transport() - assert path.read_text() == contents diff --git a/tests/test_setup_transport.py b/tests/test_setup_transport.py index 0e84b4086..bb360d19f 100644 --- a/tests/test_setup_transport.py +++ b/tests/test_setup_transport.py @@ -471,15 +471,3 @@ def test_doctor_does_not_claim_safety_for_unreadable_native_configuration(tmp_pa monkeypatch.setenv("OPENCLAW_CONFIG_PATH", str(path)) path.write_text("{ this is not plain JSON }") assert transport_diagnostic("openclaw").status != "ok" - - -def test_dsh_setup_checks_the_web_profile_even_with_another_runtime_profile(tmp_path, monkeypatch): - from powercontext.cli.transport import prepare_setup_transport - - monkeypatch.setenv("DSH_HOME", str(tmp_path)) - monkeypatch.setenv("DSH_PROFILE", "custom") - patch = tmp_path / "profiles/web/cordis.patch.yml" - patch.parent.mkdir(parents=True) - patch.write_text("- id: powercontext-dsh\n config:\n baseUrl: https://old.example\n") - with pytest.raises(RuntimeError, match="DSH"): - prepare_setup_transport("dsh", server_url="https://new.example", json_output=True) diff --git a/tests/test_system_cli.py b/tests/test_system_cli.py index af2694c30..be516522c 100644 --- a/tests/test_system_cli.py +++ b/tests/test_system_cli.py @@ -1835,6 +1835,7 @@ def test_claude_runner_uses_the_resolved_executable(monkeypatch) -> None: def test_setup_dsh_adds_plugin_from_a_local_checkout(tmp_path: Path, monkeypatch) -> None: import powercontext.cli.dsh as dsh_cli + import powercontext.cli.dsh_transport as dsh_transport checkout = tmp_path / "powercontext" plugin = checkout / "integrations" / "dsh" / "plugins" / "powercontext" @@ -1846,6 +1847,7 @@ def test_setup_dsh_adds_plugin_from_a_local_checkout(tmp_path: Path, monkeypatch monkeypatch.setattr(dsh_cli, "which", lambda _name: "/usr/bin/dsh") run_dsh = Mock(return_value="id: powercontext-dsh\n") monkeypatch.setattr(dsh_cli, "_run_dsh", run_dsh) + monkeypatch.setattr(dsh_transport, "read_dsh_settings", lambda **_kwargs: {}) result = CliRunner().invoke( create_cli([setup_app]),