Skip to content
Closed
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
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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。

## 验证清单(改动后)

Expand Down
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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。
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 行切分并叠加
Expand Down
7 changes: 7 additions & 0 deletions README_EN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions src/bash-tool.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
* @module dsh-gitbash-shell/bash-tool
*/

import { resolveSessionCwd } from './session-workdir.js'
import {
ESCALATION_TARGETS,
parseExitStatus,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 = {
Expand Down
10 changes: 9 additions & 1 deletion src/inline-tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
12 changes: 12 additions & 0 deletions src/session-workdir.js
Original file line number Diff line number Diff line change
@@ -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
}
82 changes: 78 additions & 4 deletions tests/runtime-preview.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand All @@ -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)
Expand All @@ -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())
Expand All @@ -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
},
Expand Down Expand Up @@ -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);
});
Loading
Loading