diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0d183ac --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index c8da33b..45b4c11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` 旁路整体退役; diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c996bb..f47e28f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `;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 收录合规)。 diff --git a/package.json b/package.json index e4bd2c5..55f802e 100644 --- a/package.json +++ b/package.json @@ -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": [ diff --git a/src/bash-tool.js b/src/bash-tool.js index c9715e9..6becd51 100644 --- a/src/bash-tool.js +++ b/src/bash-tool.js @@ -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 diff --git a/src/inline-tools.js b/src/inline-tools.js index 606ded6..db8a7e5 100644 --- a/src/inline-tools.js +++ b/src/inline-tools.js @@ -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 @@ -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) @@ -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 { @@ -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 diff --git a/src/shell.js b/src/shell.js index ded16ec..e327da4 100644 --- a/src/shell.js +++ b/src/shell.js @@ -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 @@ -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' diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..15f8f15 --- /dev/null +++ b/tests/README.md @@ -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 = '' +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 ` 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. diff --git a/tests/host-loader.mjs b/tests/host-loader.mjs new file mode 100644 index 0000000..e022c68 --- /dev/null +++ b/tests/host-loader.mjs @@ -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) + }, +}) diff --git a/tests/preview.mjs b/tests/preview.mjs new file mode 100644 index 0000000..481e2d4 --- /dev/null +++ b/tests/preview.mjs @@ -0,0 +1,146 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createRequire } from 'node:module' +import { Context } from '@deepseek-ai/cordis' +import { createScope as createAgentScope } from '@deepseek-ai/dsh-scope' +import { LocalSubprocessRuntime } from '@deepseek-ai/dsh-subprocess-local' +import { SandboxPwshExecutor } from '@deepseek-ai/dsh-pwsh-sandbox' +import { SystemPrompt } from '@deepseek-ai/dsh-system-prompt' +import { ToolRuntime } from '@deepseek-ai/dsh-tools' +import * as shellEnv from '@deepseek-ai/dsh-shell-env' +import * as toolPwsh from '@deepseek-ai/dsh-tool-pwsh' +import { GitBashSandboxExecutor } from '../src/shell.js' +import { mountInlineTools } from '../src/inline-tools.js' + +function createScope(parent) { + let childCtx + const fiber = parent.plugin((ctx) => { childCtx = ctx }) + return { fiber, get ctx() { return childCtx }, dispose: () => fiber.dispose() } +} + +// This is a headless runtime preview, not a UI preview: actual installed Core +// services and real Win32 processes, with an isolated Context and temporary cwd. +// Policy/session wiring is fixture-owned; no production profile or model calls. +test('preview: real host registers and executes Git Bash without replacing PowerShell', { timeout: 30000 }, async (t) => { + assert.equal(process.platform, 'win32', 'this preview requires Windows and Git for Windows') + const cwd = mkdtempSync(join(tmpdir(), 'gitbash-preview-')) + const root = new Context() + const cleanups = [] + const logs = [] + const own = (dispose) => { cleanups.push(dispose); return dispose } + t.after(async () => { + for (const cleanup of cleanups.reverse()) await cleanup() + rmSync(cwd, { recursive: true, force: true }) + }) + async function mount(module, config) { + const fiber = root.plugin(module, config) + own(() => fiber.dispose()) + await fiber + return fiber + } + const policy = { mode: 'danger-full-access', workspaceRoot: cwd } + own(root.provide('sandboxPolicy', { defaultMode: policy.mode, resolve: () => policy })) + own(root.provide('sandbox', { confine() { throw new Error('preview never requests confinement') } })) + own(root.provide('workingDirectory', { ensure: async () => cwd })) + own(root.provide('loader', { internal: { import: (name) => import(name) } })) + await mount(SystemPrompt) + await mount(LocalSubprocessRuntime) + await mount(SandboxPwshExecutor) + await mount(ToolRuntime) + await mount(shellEnv) + await mount(toolPwsh) + const official = root.reflect.store[root[Context.isolate].shell] + assert.ok(root.get('shell'), 'the actual official pwsh executor is active') + const tools = root.get('tools') + assert.ok(tools.schemas().some((schema) => schema.name === 'pwsh')) + + // Reproduce the original failure with the actual executor superclass chain. + const plain = createScope(root) + own(() => plain.dispose()) + await plain.fiber + await assert.rejects(async () => { + const fiber = plain.ctx.plugin(GitBashSandboxExecutor) + await fiber + }, /service "shell" has been registered at /) + await plain.dispose() + + let agentParent + await mount({ inject: ['tools'], apply(ctx) { agentParent = ctx } }) + const agent = { session: { header: {} } } + const agentScope = createAgentScope(agentParent, agent) + agent.ctx = agentScope.ctx + own(() => agentScope.dispose()) + assert.ok(tools.schemas(agent).some((schema) => schema.name === 'pwsh')) + async function startPatch(pwshVisible) { + let resolveReady + let rejectReady + const ready = new Promise((resolve, reject) => { resolveReady = resolve; rejectReady = reject }) + const timer = setTimeout(() => rejectReady(new Error('bash registration did not settle: ' + logs.join('\n'))), 15000) + own(() => clearTimeout(timer)) + const patch = root.plugin((ctx) => mountInlineTools(ctx, { + tools: (c) => c.get('tools'), + agents: () => ({ list: () => [agent] }), + createScope, + pwshEnabled: () => pwshVisible, + log(message) { + logs.push(message) + if (message.includes('activation failed') || message.includes('hide failed')) rejectReady(new Error(message)) + if (message.includes('own bash tool activated')) resolveReady() + }, + })) + own(() => patch.dispose()) + await patch + await ready + clearTimeout(timer) + return patch + } + const patch = await startPatch(true) + t.diagnostic('own bash tool activated; checking the actual schema list') + const names = tools.schemas(agent).map((schema) => schema.name) + assert.ok(names.includes('bash'), 'bash reaches the actual host tool schema list') + assert.ok(names.includes('pwsh'), 'the opt-in real PowerShell tool is preserved') + assert.equal(root.reflect.store[root[Context.isolate].shell], official) + assert.ok(!tools.schemas().some((schema) => schema.name === 'bash'), 'bash is per-agent, not global') + + const run = (name, command) => tools.execute({ + agent, name, callId: `preview-${name}-${Date.now()}`, + arguments: { description: `Verify isolated ${name} execution`, command, workdir: cwd }, + signal: new AbortController().signal, + }) + t.diagnostic('dispatching real bash through ToolRuntime') + const bash = await run('bash', 'command -v bash; uname -s; printf "GITBASH_PREVIEW_OK\\n"') + assert.equal(bash.isError, false, JSON.stringify(bash)) + const text = bash.content.map((block) => block.text ?? '').join('\n') + assert.match(text, /MINGW(?:32|64)_NT-/) + assert.match(text, /GITBASH_PREVIEW_OK/) + assert.equal(bash.value.exitCode, 0) + const pwsh = await run('pwsh', 'Write-Output "POWERSHELL_PREVIEW_OK"; Get-Process -Id $PID | Select-Object -ExpandProperty ProcessName') + assert.equal(pwsh.isError, false, JSON.stringify(pwsh)) + assert.match(pwsh.content.map((block) => block.text ?? '').join('\n'), /POWERSHELL_PREVIEW_OK/) + assert.equal(root.reflect.store[root[Context.isolate].shell], official) + await patch.dispose() + assert.ok(!tools.schemas(agent).some((schema) => schema.name === 'bash'), 'plugin disposal unregisters bash') + assert.ok(tools.schemas(agent).some((schema) => schema.name === 'pwsh')) + + // Also verify the default OFF switch: bash is visible, pwsh is hidden, and + // removing the inline patch restores the official tool without replacing it. + const hiddenPatch = await startPatch(false) + const defaultNames = tools.schemas(agent).map((schema) => schema.name) + assert.ok(defaultNames.includes('bash')) + assert.ok(!defaultNames.includes('pwsh')) + const defaultBash = await run('bash', 'printf "DEFAULT_BASH_OK\\n"') + assert.equal(defaultBash.isError, false, JSON.stringify(defaultBash)) + assert.equal(defaultBash.value.exitCode, 0) + await hiddenPatch.dispose() + assert.ok(tools.schemas(agent).some((schema) => schema.name === 'pwsh')) + assert.ok(!tools.schemas(agent).some((schema) => schema.name === 'bash')) + assert.equal(root.reflect.store[root[Context.isolate].shell], official) + + const requireHost = createRequire(join(process.env.DSH_PREVIEW_HOST_DIR, 'package.json')) + const core = JSON.parse(readFileSync(requireHost.resolve('@deepseek-ai/dsh/package.json'), 'utf8')) + console.log(JSON.stringify({ preview: 'passed', core: core.version, node: process.version, platform: process.platform, + bashVisible: true, bashExitCode: bash.value.exitCode, pwshExecuted: true, defaultPwshHidden: true, officialShellPreserved: true, bashRemovedOnDispose: true })) +}) diff --git a/tests/private-executor.mjs b/tests/private-executor.mjs new file mode 100644 index 0000000..fc1e016 --- /dev/null +++ b/tests/private-executor.mjs @@ -0,0 +1,112 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { Context, Service } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import { mountPrivateExecutor } from '../src/inline-tools.js' + +function createScope(parent) { + let childCtx + const fiber = parent.plugin((ctx) => { childCtx = ctx }) + return { fiber, get ctx() { return childCtx }, dispose: () => fiber.dispose() } +} + +async function fixture(t) { + const root = new Context() + const disposers = [] + for (const [name, value] of Object.entries({ + shell: { kind: 'official-pwsh', execute: () => 'PowerShell' }, + subprocess: { kind: 'host-subprocess' }, + sandbox: { kind: 'host-sandbox' }, + sandboxPolicy: { defaultMode: 'danger-full-access' }, + })) disposers.push(root.provide(name, value)) + const owner = createScope(root) + await owner.fiber + t.after(async () => { + await owner.dispose() + for (const dispose of disposers.reverse()) await dispose() + }) + return { root, ctx: owner.ctx } +} + +class TestExecutor extends Service { + static inject = ['subprocess', 'sandbox', 'sandboxPolicy'] + static Config = z.object({ timeoutMs: z.number().default(120000).volatile() }) + constructor(ctx, config) { + super(ctx, 'shell') + this.config = config + this.dependencies = [ctx.subprocess.kind, ctx.sandbox.kind, ctx.sandboxPolicy.defaultMode] + } + resolve(request) { return { ...request, timeoutMs: this.config.timeoutMs.get() } } + async execute() { return 'Git Bash' } +} + +test('private executor: a plain child fiber reproduces the route-B service collision', async (t) => { + const { ctx, root } = await fixture(t) + const child = createScope(ctx) + await child.fiber + t.after(() => child.dispose()) + assert.equal(child.ctx[Context.isolate].shell, root[Context.isolate].shell) + assert.throws(() => new Service(child.ctx, 'shell'), /service "shell" has been registered/) +}) + +test('private executor: class Config/inject work in an isolated shell scope and teardown preserves the host', async (t) => { + const { ctx, root } = await fixture(t) + const { executor, executorScope } = await mountPrivateExecutor(ctx, TestExecutor, createScope) + t.after(() => executorScope.dispose()) + assert.notEqual(executorScope.ctx[Context.isolate].shell, root[Context.isolate].shell) + assert.equal(executorScope.ctx[Context.isolate].subprocess, root[Context.isolate].subprocess) + assert.deepEqual(executor.dependencies, ['host-subprocess', 'host-sandbox', 'danger-full-access']) + assert.equal(executor.resolve({ command: 'pwd' }).timeoutMs, 120000) + assert.equal(await executor.execute(), 'Git Bash') + assert.equal(root.get('shell').execute(), 'PowerShell') + assert.throws(() => executorScope.ctx.shell, /without inject/, 'get(shell), not .shell, is required here') + await executorScope.dispose() + assert.equal(executorScope.ctx.get('shell'), undefined) + assert.equal(root.get('shell').execute(), 'PowerShell') + assert.equal(root.get('subprocess').kind, 'host-subprocess') +}) + +test('private executor: a failed constructor is disposed and a subsequent mount can succeed', async (t) => { + const { ctx, root } = await fixture(t) + let failedScope + class BrokenExecutor extends TestExecutor { + constructor(ctx, config) { + super(ctx, config) + throw new Error('preview constructor failure') + } + } + await assert.rejects(mountPrivateExecutor(ctx, BrokenExecutor, (parent) => { + failedScope = createScope(parent) + return failedScope + }), /preview constructor failure/) + assert.equal(failedScope.ctx.get('shell', false), undefined, 'partial service registration is released') + assert.equal(root.get('shell').execute(), 'PowerShell') + const mounted = await mountPrivateExecutor(ctx, TestExecutor, createScope) + t.after(() => mounted.executorScope.dispose()) + assert.equal(await mounted.executor.execute(), 'Git Bash') +}) + +test('private executor: missing required services fail closed and dispose the pending scope', async (t) => { + const { ctx, root } = await fixture(t) + let pendingScope + class PendingExecutor extends TestExecutor { + static inject = [...TestExecutor.inject, 'missingPreviewService'] + } + await assert.rejects(mountPrivateExecutor(ctx, PendingExecutor, (parent) => { + pendingScope = createScope(parent) + return pendingScope + }), /executor instance did not expose resolve\(\)\/execute\(\)/) + assert.equal(pendingScope.ctx.get('shell', false), undefined) + assert.equal(root.get('shell').execute(), 'PowerShell') +}) + +test('private executor: an unsettled scope hits the deadline and is disposed', async (t) => { + const { ctx, root } = await fixture(t) + let disposed = false + await assert.rejects(mountPrivateExecutor(ctx, TestExecutor, () => ({ + fiber: new Promise(() => {}), + dispose: async () => { disposed = true }, + }), { timeoutMs: 25 }), /executor scope mount timed out/) + assert.equal(disposed, true) + assert.equal(root.get('shell').execute(), 'PowerShell') +}) diff --git a/tests/smoke.mjs b/tests/smoke.mjs index a8fa6ff..790fced 100644 --- a/tests/smoke.mjs +++ b/tests/smoke.mjs @@ -205,6 +205,8 @@ test('error-content dialect: path diagnostics translated, data never (v0.17.1)', assert.equal(ERROR_CONTENT_TOOLS.has('read'), true) // the red line: file CONTENT in successful results is never rewritten const rd = rewriteResultPaths('read', { path: 'C:' + BS + 'x' + BS + 'f.txt', lines: [{ n: 1, text: 'content mentions C:' + BS + 'Users' + BS + 'kanna inside' }] }) + assert.equal(rd.lines[0].text, 'content mentions C:' + BS + 'Users' + BS + 'kanna inside') +}) test('leading variable shorthands + NUL guidance (v0.18.0)', async () => { const { _internal } = await import('../src/index.js') @@ -237,8 +239,6 @@ test('glob patterns may open with a variable shorthand (v0.18.1)', async () => { const rel = { pattern: '*.ts' } assert.equal(tg(rel, env), rel, 'relative patterns still return the same reference') }) - assert.equal(rd.lines[0].text, 'content mentions C:' + BS + 'Users' + BS + 'kanna inside') -}) test('client half is a ModuleLoader bundle with baseline requires only', () => { const text = readFileSync(new URL('../src/client.js', import.meta.url), 'utf8') @@ -1943,11 +1943,12 @@ test('minimal PTY shellPath rides the resolution chain; unresolved keeps the vis for (const bashPath of [undefined, '', null]) { assert.equal(shellPathOf(minimalPluginsFor({ bashPath })), 'C:/Program Files/Git/bin/bash.exe', 'unresolved keeps the visible default (' + String(bashPath) + ')') } + // Explicitly simulate the non-Windows branch, even on a Windows runner. + Object.defineProperty(process, 'platform', { value: 'linux' }) + assert.equal(shellPathOf(minimalPluginsFor({ bashPath: 'D:/git/Git/bin/bash.exe' })), '/bin/bash') } finally { Object.defineProperty(process, 'platform', platform) } - // off Windows the row never pins a Windows binary — resolved or not - assert.equal(shellPathOf(minimalPluginsFor({ bashPath: 'D:/git/Git/bin/bash.exe' })), '/bin/bash') // the reader adapter: only a VERIFIED resolution passes; a failed verdict, // a throwing reader or a missing one degrades to the visible default assert.equal(minimalPtyBashPath(() => ({ ok: true, path: 'D:/git/Git/bin/bash.exe' })), 'D:/git/Git/bin/bash.exe') @@ -2962,7 +2963,9 @@ test('inline tools: the executor is PRIVATE, and both guard layers exist (route // The executor mounts through cordis's class-plugin path in a PRIVATE // scope — never as the root ctx.shell service. assert.match(source, /executorScope\.ctx\.plugin\(GitBashSandboxExecutor\)/) - assert.match(source, /executorScope\.ctx\.shell/, 'the instance is read out of the private scope') + assert.match(source, /createScope\(ctx\.isolate\('shell'\)\)/, 'a child fiber alone does not isolate services') + assert.match(source, /executorScope\.ctx\.get\('shell'\)/, 'the empty scope reads the active provider without property-access inject') + assert.doesNotMatch(source, /return executorScope\.ctx\.shell/, 'the scope has no shell inject declaration') assert.doesNotMatch(source, /ctx\.plugin\('\.\/shell'\)/) assert.doesNotMatch(source, /name: 'dsh-gitbash-shell\/shell'/, 'no executor ROW is inserted from the runtime layer') // Layer 1 — the mount-time sweep (v0.35.2): an agent that exists before