Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: ci

on:
push:
branches: [main, 'fix/**']
pull_request:
workflow_dispatch:

permissions:
contents: read

jobs:
test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
node: [22, 24]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: npm
package-manager-cache: false
- name: Install locked test dependencies (no lifecycle scripts or optional payloads)
run: npm ci --ignore-scripts --omit=optional
- name: Smoke and real Cordis regression tests
run: npm test
- name: Check published file list without running package scripts
run: npm pack --dry-run --ignore-scripts
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -577,8 +577,8 @@
契约本质冲突,用户裁决改造架构而非钻字面。新事实:**patch 纯新增**(不再撤
`pwsh-sandbox`、不再插执行器行;冒烟有「市场门禁自检」断言锁死);**自有 bash 工具**
(`src/bash-tool.js`,对官方 tool-bash 逐调用镜像)执行在**私有执行器实例**上
(inline-tools 在私有 scope `executorScope.ctx.plugin(GitBashSandboxExecutor)` 构造,
`static inject` 走 cordis 官方链,§4k schemastery 语义自动满足,**永不注册为
(inline-tools 经 `mountPrivateExecutor` 在 `ctx.isolate('shell')` 隔离后的 scope 构造,
`static inject` 走 cordis 官方链,§4k schemastery 语义自动满足,**永不注册为根
`ctx.shell`**);官方 `pwsh` 重新为真(官方解析器自动优先 PowerShell 7),`pwshTool`
开关语义 = 「显示 pwsh 工具」(默认关 = `tools.restrict` 隐藏 + pre-execute 兜底拒,
理由句 `hiddenPwshDenyReason()` 指路设置卡),`pwsh7` 旁路整体退役;
Expand Down
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,37 @@
> 倒序排列,新版本条目在最上面。条目格式:`## vX.Y.Z — YYYY-MM-DD` + 类型(feat / fix / docs / chore)+ 要点 + 相关链接。
> 纪律见 AGENTS.md「变更记录纪律」:发版前先更新本文件并随版本提交;事故复盘、复现与真机验证记录也记在这里。

## Unreleased — 2026-10-13

**类型**:fix(route B 私有执行器的真实服务隔离与读取授权) / test / ci。

- **根因**:普通 `ctx.plugin()` 子 fiber 只隔离副作用所有权,不隔离服务命名空间。
`GitBashSandboxExecutor` 仍经 `SandboxBashExecutor → LocalBashExecutor → ShellExecutor → Service`
注册 `shell`,与 route B 保留的 `SandboxPwshExecutor` 冲突,报
`service "shell" has been registered at <SandboxPwshExecutor>`;bash 工具尚未注册即中断。
Git Bash 路径已成功解析时同样发生,不是工具发现或路径探测问题。
- **修复**:`mountPrivateExecutor` 仅对 `shell` 调用 `ctx.isolate('shell')`,其余宿主依赖原样继承;
类插件仍走官方 `plugin()` 通道,Config/volatile/static inject 与生命周期不变。
空 scope 通过 `get('shell')` 读取活跃私有实例,工具就绪检查用 `get('tools')`,避免
缺少 inject 声明的属性访问再度中断补位。失败时销毁已建 scope,成功/失败均清理挂载计时器。
- **边界**:不撤官方 PowerShell 执行器、不改 bundle patch、不改 shell 执行/沙箱/后台协议,
不抬 Core 兼容下限,不发版、不触发 npm 发布。
- **回归**:新增 5 项真实 Cordis 测试,覆盖普通子 scope 冲突、显式隔离后的 Config/inject 与
根 shell 保留、卸载、构造失败清理/再挂载、缺服务失败与挂载超时清理;不是仅正则源码检查。
既有 minimal PTY 测试的非 Windows 断言现在显式模拟 Linux,不再在恢复 win32 后误断言 `/bin/bash`。
首轮远端 CI 发现两项独立 error-content 测试误嵌套在父测试中,Node 22 会取消未等待的子测试;
恢复独立结构并保留全部断言,不跳过测试、不移除 Node 22 矩阵。
- **验证入口**:`npm test` 合并 smoke 与真实 Cordis 回归;新增 `npm run test:preview`,通过
显式 `DSH_PREVIEW_HOST_DIR` 只读解析目标宿主包,在新 Context + 临时 cwd 做无头运行时 preview。
Windows 本机已通过:Core **0.2.1-alpha.2**,宿主 Cordis **4.0.5-alpha.1**,Node **24.13.0**;
原路径真实重现服务冲突,修复后 bash 出现在实际 ToolRuntime schema 中并执行
`command -v bash`/`uname -s`/sentinel(退出码 0),官方 pwsh 执行 `Get-Process`,
`pwshTool` 开/关两态与卸载恢复均通过。**不等同于完整 GUI、PTY、后台任务或受限模式验收**。
- **CI**:新增非发布 `ci` 工作流(push 修复分支 / PR / 手动触发),Windows/Linux × Node 22/24,
锁定依赖、禁止生命周期脚本与可选载荷、完整测试及 `npm pack --dry-run --ignore-scripts`。
本机完整测试 **127/127 通过**,打包预检与 `git diff --check` 通过;远端矩阵结果见对应 PR 检查。
复验命令和覆盖边界见 `tests/README.md`。

## v0.39.0 — 2026-10-13

**类型**:feat(架构转向:route B——插件自带 bash 工具,不再替换宿主执行器;DSH STORE 收录合规)。
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@
"icon.svg"
],
"scripts": {
"test": "node --test tests/smoke.mjs",
"test": "node --test tests/smoke.mjs tests/private-executor.mjs",
"test:preview": "node --import ./tests/host-loader.mjs --test tests/preview.mjs",
"version": "node -e \"const fs=require('fs');const m=JSON.parse(fs.readFileSync('dsh.plugin.json','utf8'));const v=JSON.parse(fs.readFileSync('package.json','utf8')).version;m.version=v;fs.writeFileSync('dsh.plugin.json',JSON.stringify(m,null,2)+'\\n')\" && git add dsh.plugin.json"
},
"keywords": [
Expand Down
4 changes: 2 additions & 2 deletions src/bash-tool.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
* instead ships an OWN tool:
*
* - the executor is a private `GitBashSandboxExecutor` instance constructed
* through cordis's own plugin path in a PRIVATE scope (never registered as
* `ctx.shell`, which stays the official PowerShell executor); it inherits
* through cordis's own plugin path in a `shell`-isolated scope (never
* registered as the ROOT `ctx.shell`, which stays PowerShell); it inherits
* the shipped bash-sandbox semantics (deadline, output capture, argv
* execution) and points them at Git for Windows' bash.exe;
* - the tool definition mirrors `@deepseek-ai/dsh-tool-bash` call-for-call
Expand Down
56 changes: 40 additions & 16 deletions src/inline-tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
* 1. `pwsh` present + `bash` absent -> register the plugin's OWN `bash`
* tool (src/bash-tool.js) in an agent-owned scope. It executes through a
* PRIVATE GitBashSandboxExecutor instance (constructed once per process
* in a private cordis scope, never registered as a service), and mirrors
* in a shell-isolated cordis scope, never registered as the ROOT shell), and mirrors
* the official tool's schema, background jobs, escalation and rendering
* call-for-call.
* 2. `ops_*` tools present (dsh-ops installed) -> our FastCtx bridge stands
Expand Down Expand Up @@ -137,6 +137,43 @@ const HOST_MODULES = [
['llmModule', '@deepseek-ai/dsh-llm'],
]

/**
* Mount the executor through the host class-plugin path. A plugin fiber owns
* effects, but does NOT create a service namespace: ShellExecutor's constructor
* still provides `shell`. Isolate ONLY that service, retaining the host's
* subprocess/sandbox/policy dependencies and leaving the official shell intact.
*
* The empty scope does not inject shell, so read its active provider through
* get(), not the property-access path that requires an inject declaration.
*/
export async function mountPrivateExecutor(ctx, GitBashSandboxExecutor, createScope, { timeoutMs = 8000 } = {}) {
const executorScope = createScope(ctx.isolate('shell'))
let timer
try {
const mountTimeout = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error('executor scope mount timed out (a required service may be absent in this composition)')), timeoutMs)
})
const executor = await Promise.race([
(async () => {
if (executorScope.fiber) await executorScope.fiber
const fiber = executorScope.ctx.plugin(GitBashSandboxExecutor)
await fiber
return executorScope.ctx.get('shell')
})(),
mountTimeout,
])
if (!executor || typeof executor.resolve !== 'function' || typeof executor.execute !== 'function') {
throw new Error('executor instance did not expose resolve()/execute()')
}
return { executor, executorScope }
} catch (error) {
await executorScope.dispose()
throw error
} finally {
clearTimeout(timer)
}
}

/**
* Mount the per-agent reconcile. `deps`:
* - tools(ctx) -> the host tool registry (or undefined)
Expand Down Expand Up @@ -188,20 +225,7 @@ export function mountInlineTools(ctx, deps) {
throw new Error('executor Config unavailable (schemastery unresolved; see the schemastery log lines above)')
}
if (typeof createScope !== 'function') throw new Error('no createScope seam')
const executorScope = createScope(ctx)
if (executorScope.fiber) { try { await executorScope.fiber } catch { /* disposed */ } }
const mountTimeout = new Promise((_, reject) => {
const timer = setTimeout(() => reject(new Error('executor scope mount timed out (a required service may be absent in this composition)')), 8000)
timer.unref?.()
})
const fiber = executorScope.ctx.plugin(GitBashSandboxExecutor)
const executor = await Promise.race([
(async () => { await fiber; return executorScope.ctx.shell })(),
mountTimeout,
])
if (!executor || typeof executor.resolve !== 'function' || typeof executor.execute !== 'function') {
throw new Error('executor instance did not expose resolve()/execute()')
}
const { executor, executorScope } = await mountPrivateExecutor(ctx, GitBashSandboxExecutor, createScope)
const hostModules = {}
for (const [key, name] of HOST_MODULES) {
try {
Expand All @@ -226,7 +250,7 @@ export function mountInlineTools(ctx, deps) {
// Same asynchronous-fiber shape the official module needed: wait for
// the apply callback to capture scope.ctx before touching its tools.
if (scope.fiber) { try { await scope.fiber } catch { /* disposed */ } }
const registry = scope.ctx?.tools
const registry = scope.ctx?.get('tools')
if (!registry || typeof scope.ctx.plugin !== 'function') {
void scope.dispose().catch(() => {})
return
Expand Down
7 changes: 4 additions & 3 deletions src/shell.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* dsh-gitbash-shell/shell — the Git Bash executor INSTANCE (route B, v0.39.0).
*
* Since route B this class is NOT mounted as the host `ctx.shell` service:
* Since route B this class is NOT mounted as the ROOT `ctx.shell` service:
* the official PowerShell executor keeps that slot untouched (the DSH-Store
* catalog gate forbids bundle patches that disable official components, and
* replacing the shell executor IS overriding one). Instead the plugin
Expand Down Expand Up @@ -36,8 +36,9 @@
* executor did.
*
* Constructed as a cordis class plugin (static inject/Config honoured) —
* inside the PRIVATE scope only, so the base class's service registration
* lands there and never touches the root `ctx.shell` slot.
* inside an explicitly `ctx.isolate('shell')`-isolated scope, so the base
* class's service registration never touches the root `ctx.shell` slot.
* A plain child plugin fiber is NOT a service isolation boundary.
*/

import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
Expand Down
49 changes: 49 additions & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Runtime validation

## CI / portable regression suite

```sh
npm ci --ignore-scripts --omit=optional
npm test
npm pack --dry-run --ignore-scripts
```

The `ci` workflow runs the smoke suite and real Cordis lifecycle regressions on
Windows/Linux with Node 22/24. The lockfile supplies Cordis 4.0.4; the new tests
use its real `Context`, `Service`, `plugin`, `isolate`, inject, Config and disposal
contracts, not a mocked service registry. Optional FastCtx payloads and all npm
lifecycle scripts are deliberately excluded from the test dependency install.

## Installed-host preview (Windows, Node >= 22.15)

Select the **actual host directory** that contains its `package.json` and
`node_modules`; do not substitute a global CLI installation for Desktop's Core.
In PowerShell:

```powershell
$env:DSH_PREVIEW_HOST_DIR = '<actual installed host directory>'
npm run test:preview
```

This is a **headless runtime preview**, not a browser/UI deployment. The resolver
loads `@deepseek-ai/*` from the selected installed host without changing that
installation. The preview creates a fresh Cordis context and temporary cwd,
loads the real SystemPrompt, ToolRuntime, subprocess runtime, shell-env,
SandboxPwshExecutor and official pwsh tool, and activates the actual inline
Git Bash patch. Policy and session/working-directory wiring are test fixtures;
only danger-full-access commands are exercised.

Assertions cover:

- The old plain child-scope path fails with the actual
`service "shell" has been registered at <SandboxPwshExecutor>` error.
- Fixed bash is present in the host's per-agent schema list, not globally.
- Real bash executes `command -v bash`, `uname -s`, and a sentinel successfully.
- With pwsh enabled, the original executor still executes `Get-Process`.
- With the default pwsh switch off, bash stays callable and pwsh is hidden.
- Disposing the inline patch removes bash, lifts its restrictions, and preserves
the exact original shell provider.

The preview makes no model calls, starts no web server, installs no plugin into
a profile, does not edit production config, and does not restart Desktop. It is
not a full end-to-end GUI/preset-picker/PTY/background-job/confinement test.
17 changes: 17 additions & 0 deletions tests/host-loader.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Read-only resolution into an explicitly selected installed DSH host. Nothing
// is installed into, patched in, or started from that host's production profile.
import { registerHooks, createRequire } from 'node:module'
import { join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'

const hostDir = process.env.DSH_PREVIEW_HOST_DIR
if (!hostDir) throw new Error('Set DSH_PREVIEW_HOST_DIR to the installed DSH host directory containing package.json and node_modules')
const hostRequire = createRequire(pathToFileURL(join(resolve(hostDir), 'package.json')))
registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier.startsWith('@deepseek-ai/')) {
return { url: pathToFileURL(hostRequire.resolve(specifier)).href, shortCircuit: true }
}
return nextResolve(specifier, context)
},
})
Loading