diff --git a/AGENTS.md b/AGENTS.md index 45b4c11..bb75df6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -585,6 +585,11 @@ `resolveHostModule`(inline-tools 导出)是宿主包解析的唯一通道(loader 优先 + createRequire 兜底)。bashPath 显式配置只认 `gitbash-shell` 行(executor 行已不存在, `executorConfiguredBashPath` 二段式读取保留作老 profile 兼容层)。 + **工作目录兼容(issue #27)**:自有 Bash 不得硬 inject `workingDirectory`; + 统一经 `src/session-workdir.js` 逐调用 `ctx.get()`,有服务用官方 ensure,失败不降级; + 缺服务使用旧官方 `policy.workspaceRoot ?? session.header.cwd` 接缝。不要补装 alpha.2 + Core 服务覆盖旧宿主。挂载成功必须同时确认 apply 已执行及 Bash 在工具面可见, + await fiber 本身不能证明注册成功;缺服务/无注册必须明确报错并释放 scope。 ## 验证清单(改动后) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e8c1d0..32e32ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,44 @@ > 倒序排列,新版本条目在最上面。条目格式:`## vX.Y.Z — YYYY-MM-DD` + 类型(feat / fix / docs / chore)+ 要点 + 相关链接。 > 纪律见 AGENTS.md「变更记录纪律」:发版前先更新本文件并随版本提交;事故复盘、复现与真机验证记录也记在这里。 +## Unreleased — 2026-10-13 + +**类型**:fix(旧宿主 Bash 注册与会话 cwd、激活日志) / test / docs。 + +- 移除自有 Bash 对 workingDirectory 的硬 inject;新增共享目录读取适配器,逐次使用官方 ensure。 + 仅缺服务时沿用旧官方 policy.workspaceRoot 优先于 session.header.cwd 的接缝;相对 workdir + 按该目录解析,绝对路径保持。现有服务错误/取消原样传播,不降级到旧目录,不补装 Core 服务。 +- 每次挂载同时确认 apply 实际执行且 Bash 在工具面可见,才输出 activated;INACTIVE/未注册 + 明确输出 activation failed,并释放 agent scope。官方 root PowerShell 与既有沙箱行为保持。 +- 新增普通回归覆盖旧接缝、实时目录变化、服务失败、取消;实际 Core preview 覆盖缺服务注册、 + 默认/相对/绝对 cwd、策略优先级、后台工作目录、静默 INACTIVE 清理和现代服务验证失败零 spawn。 +- 验证:普通完整回归 **159/159**、实际 Core 完整 preview **17/17**,exit0、无 skip/cancel; + Node 24.13.0 / Windows / Core 0.2.1-alpha.2。旧宿主以缺服务及旧契约形状验证,尚未在 + Desktop 0.2.0-rc.2 安装本体或生产 GUI 复验;报告者本机补丁证据归属原 issue。 +- 本 PR 独立基于上游 main,不包含开放中的实验 PR;native 仍在实验分支,后续应复用同一适配器。 +- 相关:[issue #27](https://github.com/KannaKuron/dsh-gitbash-shell/issues/27)。 + +### Issue #27:workingDirectory 修复前核查(复现 / 验证) + +- [问题报告](https://github.com/KannaKuron/dsh-gitbash-shell/issues/27)指出旧 Desktop 无 workingDirectory 服务; + 已核对正式版源码与当前实验分支,两者 Bash 模块都将它列为硬 inject,且 await fiber 后未经工具面回读就打印 activated。 +- 在本机实际 Core 0.2.1-alpha.2 的隔离 Context 中移除该服务,复现:工具 schema 为空,但日志含 activated, + 没有 activation failed;后续 provide 该服务后 Bash 才注册。没有启动真实命令或修改生产 Profile。 + 这验证了缺服务时的失败链;未在报告者 Desktop 0.2.0-rc.2 安装本体独立复验“服务不存在”。 +- 内存中试验可选 ctx.get 读取与旧接缝回退:缺服务时 policy.workspaceRoot 优先于 session.header.cwd, + 相对 workdir 按所得会话目录拼接,绝对 workdir 保留;服务存在时沿用 ensure。实际 ToolRuntime + 内存执行器六例通过。 + 仅移除 inject 不够,原 ctx.workingDirectory 属性访问也必须替换。该试验未写入产品源码,不声称生产 GUI 已验收。 +- 现有测试夹具始终提供 workingDirectory,因此此前全绿未覆盖旧宿主缺服务。当前实验 native 模块也硬 inject 同一服务, + 执行器也直接 ensure,需同步复核。建议兼容修复独立于 PR #26 的实验行为,补无服务注册/cwd/假阳性日志回归。 +- 版本历史复核:该包在 [引入会话工作目录服务的提交](https://github.com/deepseek-ai/deepseek-harness/commit/79bd3d8da7d53adf28042b3a33fbf98e8c0c5ee7) + 新增,发布标签中仅 0.2.1-alpha.2 包含;0.2.0-rc.2 与 0.2.1-alpha.1 都无该包。 + 其官方模块需要 fs/sessionProjections/systemPrompt,发布包的 Core peers 精确锁 0.2.1-alpha.2,安装依赖本身不注册服务。 +- 直接下放的具体反证:旧 systemPrompt 的 getContextOrder 没有 WORKING_DIRECTORY,返回 undefined,context 拒绝非有限 order。 + 在隔离 Context 中挂官方 alpha.2 WorkingDirectoryService 并使用旧版完整排序表,确定性复现 + `prompt context "working-directory:current" order must be a finite number`,未执行进程或文件操作。 + 因而直接补装 alpha.2 包不能作为旧宿主兼容保证;需要独立适配及旧版验收。新宿主仍应优先使用其真实 ensure 语义。 + + ## v0.39.2 — 2026-10-13 **类型**:fix(route B 工具/预设生命周期、动态配置与客户端回归) / test。 diff --git a/README.md b/README.md index 040a973..1390026 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,11 @@ dsh-ptc-cordis-preset 的 `PTC 创造模式` 用户 preset 均不受影响、自 ### 宿主要求与 preset 对齐 +**工作目录兼容修复(Unreleased)**:Core `0.2.1-alpha.2` 的 `workingDirectory` 服务存在时, +每次 Bash 调用沿用官方当前目录验证与恢复;缺少服务的旧宿主使用策略工作区或会话初始目录, +相对 `workdir` 按该目录解析。无需补装新 Core 服务;服务自身报错会如实返回,不退回旧目录。 +参见 [issue #27](https://github.com/KannaKuron/dsh-gitbash-shell/issues/27)。 + 自 v0.32.0 起宿主下限为 **dsh >= 0.1.7-rc**(经 peer `@deepseek-ai/dsh` 声明, prerelease 含入求值;旧宿主不再支持)。四个变体的行集是 `src/compositions.js` 里已提交的 JS 数据,镜像官方 0.1.7 的 standard/minimal/ptc/cordis 行切分并叠加 diff --git a/README_EN.md b/README_EN.md index 644a712..47d5eb2 100644 --- a/README_EN.md +++ b/README_EN.md @@ -16,6 +16,13 @@ ### Host requirement & preset alignment +**Working-directory compatibility fix (Unreleased):** when Core `0.2.1-alpha.2`'s +`workingDirectory` service exists, each Bash call retains official current-directory +validation and recovery. Hosts without it use the policy workspace root or original +session directory, resolving relative `workdir` values against that directory. +No new Core service installation is required; errors from an existing service propagate. +See [issue #27](https://github.com/KannaKuron/dsh-gitbash-shell/issues/27). + Since v0.32.0 the host floor is **dsh >= 0.1.7-rc** (declared through the `@deepseek-ai/dsh` peer, prereleases included; older hosts are no longer supported). The four variants are committed JS row sets in diff --git a/src/bash-tool.js b/src/bash-tool.js index f4bd5fb..ab1c1de 100644 --- a/src/bash-tool.js +++ b/src/bash-tool.js @@ -40,6 +40,7 @@ * @module dsh-gitbash-shell/bash-tool */ +import { resolveSessionCwd } from './session-workdir.js' import { ESCALATION_TARGETS, parseExitStatus, @@ -319,7 +320,7 @@ export function createBashToolModule({ executor, toolsModule, sandboxModule, llm : (subject) => `The narrowest wider sandbox mode for a one-shot retry of the exact ${subject} the sandbox just denied; the retry asks the user for approval.` return { - inject: ['tools', 'systemPrompt', 'shellEnv', 'workingDirectory'], + inject: ['tools', 'systemPrompt', 'shellEnv'], apply(ctx, config = {}) { const backgroundEnabled = config?.enableRunInBackground ?? true const promoteOnTimeout = (config?.promoteOnTimeout ?? true) && backgroundEnabled @@ -486,7 +487,7 @@ export function createBashToolModule({ executor, toolsModule, sandboxModule, llm const policy = approvedMode === undefined ? standingPolicy : { ...standingPolicy, mode: approvedMode } - const cwd = exec.agent === undefined ? undefined : await ctx.workingDirectory.ensure(exec.agent, exec.signal) + const cwd = await resolveSessionCwd(ctx, exec, policy) const workdir = resolveWorkdir(args.workdir, cwd) const dshEnv = ctx.shellEnv.collect(exec) const request = { diff --git a/src/inline-tools.js b/src/inline-tools.js index 5cbf1d3..3641d9a 100644 --- a/src/inline-tools.js +++ b/src/inline-tools.js @@ -282,9 +282,17 @@ export function mountInlineTools(ctx, deps) { if (!isLive(agent, state)) return const registry = scope.ctx?.get('tools') if (!registry || typeof scope.ctx.plugin !== 'function') return - const fiber = scope.ctx.plugin(module) + let applied = false + const fiber = scope.ctx.plugin({ ...module, async apply(toolCtx, config) { + await module.apply(toolCtx, config) + applied = true + } }) await untilDisposed(fiber, state.controller.signal) if (!isLive(agent, state)) return + // Cordis can resolve an INACTIVE fiber without ever calling apply(). + if (!applied || !faceNames(agent).includes('bash')) { + throw new Error('bash tool did not register for this agent (module inactive, a required service missing, or tool visibility restricted)') + } retained = true log('own bash tool activated for this agent (Git Bash execution; official pwsh tool real but hidden by default)') } catch (error) { diff --git a/src/session-workdir.js b/src/session-workdir.js new file mode 100644 index 0000000..bfafff6 --- /dev/null +++ b/src/session-workdir.js @@ -0,0 +1,12 @@ +/** Read the host's current directory, retaining the pre-alpha.2 execution seam. */ +export async function resolveSessionCwd(ctx, exec, policy) { + if (exec.agent === undefined) return undefined + exec.signal?.throwIfAborted() + const service = ctx.get('workingDirectory') + if (service !== undefined) { + if (typeof service?.ensure !== 'function') throw new TypeError('workingDirectory service does not expose ensure()') + // Do not hide validation, recovery, cancellation or provider failures. + return service.ensure(exec.agent, exec.signal) + } + return policy?.workspaceRoot ?? exec.agent.session?.header?.cwd +} diff --git a/tests/runtime-preview.mjs b/tests/runtime-preview.mjs index eafae3e..e7692db 100644 --- a/tests/runtime-preview.mjs +++ b/tests/runtime-preview.mjs @@ -30,7 +30,7 @@ function createScope(parent) { return { fiber, get ctx() { return ctx }, dispose: () => fiber.dispose() } } -async function fixture(t, { existingBash = false, pwshVisible = false, pauseModules = false, pauseAgentScope = false, failAgentMount = false, subprocessRuntime } = {}) { +async function fixture(t, { existingBash = false, pwshVisible = false, pauseModules = false, pauseAgentScope = false, failAgentMount = false, subprocessRuntime, workingDirectory = { ensure: async () => process.cwd() }, policyWorkspaceRoot = process.cwd(), sessionCwd, inactiveBashMount = false } = {}) { const root = new Context() const cleanups = [] const logs = [] @@ -40,14 +40,14 @@ async function fixture(t, { existingBash = false, pwshVisible = false, pauseModu const failed = deferred() const agentScopes = [] const official = { kind: 'official-pwsh' } - const policy = { mode: 'danger-full-access', workspaceRoot: process.cwd() } + const policy = { mode: 'danger-full-access', ...(policyWorkspaceRoot === null ? {} : { workspaceRoot: policyWorkspaceRoot }) } const readFrom = () => ({ text: 'runtime-preview-ok', nextOffset: 18, lossy: false }) for (const [name, value] of Object.entries({ shell: official, subprocess: subprocessRuntime ?? { spawn() { return { done: Promise.resolve({ exitCode: 0, signal: null }), collected: { stdout: { readFrom }, stderr: { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) } }, terminate() {} } } }, sandbox: {}, sandboxPolicy: { defaultMode: policy.mode, resolve: request => request?.session?.header.policy ?? policy }, - workingDirectory: { ensure: async () => process.cwd() }, + ...(workingDirectory === null ? {} : { workingDirectory }), loader: { internal: { async import(name) { if (pauseModules && name === '@deepseek-ai/dsh-tools') { entered.resolve(); await release.promise } return import(name) @@ -67,7 +67,7 @@ async function fixture(t, { existingBash = false, pwshVisible = false, pauseModu ctx.tools.register(defineTool({ name, description: name, parameters: {}, output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] }, execute: () => name })) } } }) - const agent = { id: 'runtime-preview-agent', session: { header: {} } } + const agent = { id: 'runtime-preview-agent', session: { header: { ...(sessionCwd === undefined ? {} : { cwd: sessionCwd }) } } } const agentScope = createAgentScope(agentParent, agent) agent.ctx = agentScope.ctx cleanups.push(() => agentScope.dispose()) @@ -90,6 +90,12 @@ async function fixture(t, { existingBash = false, pwshVisible = false, pauseModu if (failAgentMount) { return { fiber: scope.fiber, get ctx() { return { get: name => scope.ctx.get(name), plugin() { throw new Error('isolated agent plugin mount failure') } } }, dispose: () => scope.dispose() } } + if (inactiveBashMount) { + return { fiber: scope.fiber, get ctx() { return { + get: name => scope.ctx.get(name), + plugin: module => scope.ctx.plugin({ ...module, inject: [...module.inject, 'missingBashService'] }), + } }, dispose: () => scope.dispose() } + } } return scope }, @@ -260,3 +266,71 @@ test('runtime preview: private execution retains deadlines, background no-expiry await assert.rejects(failed.result(), /isolated provider failure/) assert.equal(f.root.get('shell'), f.official) }) + + +test('runtime preview: missing workingDirectory still mounts Bash and resolves the old session cwd seam', { timeout: 10000 }, async t => { + const spawned = []; + const f = await fixture(t, { workingDirectory: null, policyWorkspaceRoot: null, sessionCwd: 'D:/session-project', + subprocessRuntime: { spawn(spec) { spawned.push(spec); return { done: Promise.resolve({ exitCode: 0, signal: null }), collected: { stdout: { readFrom: () => ({ text: 'ok', nextOffset: 2, lossy: false }) }, stderr: { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) } }, terminate() {} }; } } }); + await bounded(f.activated.promise, 'Bash on an old host'); + assert.ok(f.names().includes('bash')); + for (const [workdir, expected] of [[undefined, 'D:/session-project'], ['scripts', 'D:/session-project/scripts'], ['E:/explicit', 'E:/explicit']]) { + const response = await f.execute('bash', { command: 'memory-only command', description: 'Verify old host cwd', ...(workdir === undefined ? {} : { workdir }) }); + assert.equal(response.isError, false, JSON.stringify(response)); + assert.equal(spawned.at(-1).cwd, expected); + assert.equal(response.value.cwd, expected); + } + f.agent.session.header.policy = { mode: 'danger-full-access', workspaceRoot: 'F:/policy-project' }; + const response = await f.execute('bash', { command: 'memory-only command', description: 'Verify policy precedence', workdir: 'scripts' }); + assert.equal(response.isError, false, JSON.stringify(response)); + assert.equal(spawned.at(-1).cwd, 'F:/policy-project/scripts'); + let background; + const removeJobs = f.root.provide('jobs', { start(definition) { + background = definition.run(); + return 'issue27-background'; + } }); + try { + await new Promise(setImmediate); + const response = await f.execute('bash', { command: 'memory-only command', description: 'Verify old host background cwd', workdir: 'scripts', run_in_background: true }); + assert.equal(response.isError, false, JSON.stringify(response)); + assert.equal(response.value.kind, 'background'); + assert.equal(response.value.cwd, 'F:/policy-project/scripts'); + await bounded(background.done, 'old host background execution'); + assert.equal(spawned.at(-1).cwd, 'F:/policy-project/scripts'); + } finally { await removeJobs(); } + assert.equal(f.root.get('shell'), f.official); + await f.patch.dispose(); + assert.deepEqual(f.names(), ['pwsh']); +}); + +test('runtime preview: inactive Bash fiber reports failure and disposes instead of logging success', { timeout: 10000 }, async t => { + const f = await fixture(t, { inactiveBashMount: true }); + await bounded(f.failed.promise, 'inactive module failure'); + await new Promise(setImmediate); + assert.ok(!f.names().includes('bash')); + assert.ok(!f.logs.some(line => line.includes('own bash tool activated'))); + assert.ok(f.logs.some(line => line.includes('bash tool did not register'))); + assert.ok(f.agentScopes.every(scope => scope.ctx.fiber.state === 4)); + assert.equal(f.root.get('shell'), f.official); +}); + + +test('runtime preview: modern directory changes stay live and validation failures never spawn', { timeout: 10000 }, async t => { + let directory = 'E:/current-project', reject = false, calls = 0, spawns = 0; + const f = await fixture(t, { sessionCwd: 'D:/original-project', workingDirectory: { + async ensure(agent, signal) { calls++; assert.equal(agent, f.agent); assert.ok(signal instanceof AbortSignal); if (reject) throw new Error('current directory validation failed'); return directory; }, + }, subprocessRuntime: { spawn(spec) { spawns++; assert.equal(spec.cwd, directory); return { done: Promise.resolve({ exitCode: 0, signal: null }), collected: { stdout: { readFrom: () => ({ text: 'ok', nextOffset: 2, lossy: false }) }, stderr: { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) } }, terminate() {} }; } } }); + await bounded(f.activated.promise, 'modern Bash activation'); + for (directory of ['E:/current-project', 'F:/changed-project']) { + const response = await f.execute('bash', { command: 'memory-only command', description: 'Verify current directory' }); + assert.equal(response.isError, false, JSON.stringify(response)); + assert.equal(response.value.cwd, directory); + } + reject = true; + const response = await f.execute('bash', { command: 'memory-only command', description: 'Preserve provider refusal' }); + assert.equal(response.isError, true); + assert.match(response.error.message, /current directory validation failed/); + assert.equal(calls, 3); + assert.equal(spawns, 2); + assert.equal(f.root.get('shell'), f.official); +}); diff --git a/tests/runtime-regressions.mjs b/tests/runtime-regressions.mjs index f7ac505..a52bdb4 100644 --- a/tests/runtime-regressions.mjs +++ b/tests/runtime-regressions.mjs @@ -3,6 +3,7 @@ import assert from 'node:assert/strict' import { Context, Service } from '@deepseek-ai/cordis' import { mountInlineTools } from '../src/inline-tools.js' import { createBashToolModule } from '../src/bash-tool.js' +import { resolveSessionCwd } from '../src/session-workdir.js' function inlineFixture({ visible = false, names = ['bash', 'pwsh'], externalDeny = [] } = {}) { const listeners = new Map() @@ -176,3 +177,35 @@ test('runtime: missing approval helper still passes the agent\'s standing mode a assert.equal(value.sandbox.mode, 'read-only') await assert.rejects(() => definition.execute({ command: 'echo ok', description: 'Reject unavailable escalation', sandbox_permissions: 'danger-full-access', justification: 'Need broader mode.' }, exec), /no sandbox helper/) }) + + +test('cwd compatibility: missing service uses the old policy/header seam and keeps unscoped calls unchanged', async () => { + const ctx = { get: () => undefined }; + const exec = { agent: { session: { header: { cwd: 'D:/session' } } }, signal: new AbortController().signal }; + assert.equal(await resolveSessionCwd(ctx, exec, { workspaceRoot: 'E:/policy' }), 'E:/policy'); + assert.equal(await resolveSessionCwd(ctx, exec, {}), 'D:/session'); + assert.equal(await resolveSessionCwd(ctx, { agent: { session: { header: {} } } }, {}), undefined); + assert.equal(await resolveSessionCwd({ get() { throw new Error('unscoped calls do not inspect services'); } }, {}, { workspaceRoot: 'E:/policy' }), undefined); +}); + +test('cwd compatibility: a live official service remains authoritative and failures never downgrade', async () => { + const agent = { session: { header: { cwd: 'D:/original' } } }; + const signal = new AbortController().signal; + let directory = 'E:/current'; + const service = { ensure(value, receivedSignal) { assert.equal(value, agent); assert.equal(receivedSignal, signal); return Promise.resolve(directory); } }; + const ctx = { get: () => service }; + assert.equal(await resolveSessionCwd(ctx, { agent, signal }, { workspaceRoot: 'F:/policy' }), 'E:/current'); + directory = 'G:/changed'; + assert.equal(await resolveSessionCwd(ctx, { agent, signal }, {}), 'G:/changed'); + const failure = new Error('official directory validation failed'); + service.ensure = () => Promise.reject(failure); + await assert.rejects(resolveSessionCwd(ctx, { agent, signal }, {}), error => error === failure); + await assert.rejects(resolveSessionCwd({ get: () => ({}) }, { agent, signal }, {}), /does not expose ensure/); +}); + +test('cwd compatibility: pre-cancelled calls do not inspect services', async () => { + const control = new AbortController(); + const reason = new Error('cancelled directory lookup'); + control.abort(reason); + await assert.rejects(resolveSessionCwd({ get() { throw new Error('must not inspect'); } }, { agent: {}, signal: control.signal }, {}), error => error === reason); +}); diff --git a/tests/smoke.mjs b/tests/smoke.mjs index dd2819c..360b818 100644 --- a/tests/smoke.mjs +++ b/tests/smoke.mjs @@ -3070,7 +3070,7 @@ test('bash tool: the module refuses a non-executor, declares no shell inject, an execute: async () => { throw new Error('not used by this test') }, } const mod = createBashToolModule({ executor, toolsModule: undefined, sandboxModule: undefined, llmModule: undefined, log: () => {} }) - assert.deepEqual(mod.inject, ['tools', 'systemPrompt', 'shellEnv', 'workingDirectory'], + assert.deepEqual(mod.inject, ['tools', 'systemPrompt', 'shellEnv'], 'ctx.shell must NOT be declared: the official executor owns that service') mod.apply(ctx, {}) assert.equal(registered.length, 1)