dsh-gitbash-shell 0.35.1 → 0.35.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,29 @@
3
3
  > 倒序排列,新版本条目在最上面。条目格式:`## vX.Y.Z — YYYY-MM-DD` + 类型(feat / fix / docs / chore)+ 要点 + 相关链接。
4
4
  > 纪律见 AGENTS.md「变更记录纪律」:发版前先更新本文件并随版本提交;事故复盘、复现与真机验证记录也记在这里。
5
5
 
6
+ ## v0.35.3 — 2026-10-12
7
+
8
+ **类型**:fix(issue [#18](https://github.com/KannaKuron/dsh-gitbash-shell/issues/18) 三个独立根因;Windows 真机 v0.35.0 四面全灭)。
9
+
10
+ 外部用户(zjh1159086960-crypto / Lntano)的高质量报告:v0.35.0 四个新面(行内补位 bash、假 pwsh 遮蔽、`gitbash_pwsh`、`gb_*`)在 Windows 真机**一个都没生效**,且定位到行。三个根因相互独立,v0.35.2 的挂载时扫描只是让「断得更早的 wiring」多了一次运行机会——**根因仍在,四面依然全灭**。本版逐一修复,修法全部对照 dsh-ops 已在真机验证的实现:
11
+
12
+ - **根因一:`resolveHostPwsh7()` 零参调用 → 整个行内补位 wiring 流产**(主凶)。`exists` 是必填注入项,win32 下对象字面量求值即抛 `exists is not a function`,被 `mountInlineTools` 外层 catch 吞成一行日志——bash 补位、假 pwsh 遮蔽、`gitbash_pwsh` 三个面同死。修复:调用点注入 `{ exists: existsSync }`(`node:fs`,候选矩阵与 PATH 兜底逻辑本身无恙,报告者已验证)。
13
+ - **根因二:工具定义缺 host 必填的 `output` 契约 → `gitbash_pwsh` 与 `gb_*` 注册全被拒**。host `ToolRuntime.register` 硬校验 `output { schema, render }`(官方工具全部经 `defineTool` 包装补出)。修复:新增冻结的 `TOOL_OUTPUT_SCHEMA` + `renderTextContent`(dsh-ops `lib/tools.js` 的 OUTPUT_SCHEMA 同款形状,两个工具家族共享),`gitbashPwshDefinition` 与 `gb_*` 注册全部补上。
14
+ - **根因三:`restrict()` 从根 ctx 调用必然抛错 → 假 pwsh 的 deny 从未建立**。host 硬校验「requires a scoped context」,根 ctx 无 scope 键,异常被 catch 吞成 `fake-pwsh deny failed` 日志。修复:改走 **`agent.ctx.tools.restrict({ deny: ['pwsh'] })`**(dsh-ops `lib/index.js` `installHostShellDenial` 的同款调用;host 文档原文「Hiding tools from one agent is ctx.tools.restrict() on that agent's context」)。
15
+ - **冒烟**:116 项(115 过 + 1 跳)。新增两个测试:「the three issue-#18 root causes stay fixed」(三个根因的源码防回归锁,含 `doesNotMatch` 禁止零参调用与根 ctx restrict 回潮)与「the tool definitions speak the host output contract」(两家族共享契约 + render 行为:多块拼接、空 content 渲染单个空文本块)。
16
+ - **致谢**:三个根因全部由报告者先行定位,本版修复与其复现脚本逐一对齐;真机验证(四面生效)待报告者与用户复验。
17
+
18
+ ## v0.35.2 — 2026-10-12
19
+
20
+ **类型**:fix(Windows 真机报告;行内补位的时序洞 + 调用层兜底)。
21
+
22
+ - **修复:已存在的 agent 拿不到补位(用户 Windows 真机报告)**。症状:AI 调 `pwsh` 工具收到 `/usr/bin/bash: line 1: Select-String: command not found`——假 pwsh 的执行器在本机就是 `ctx.shell`(Git Bash),PowerShell 命令进了 bash。根因:补位判定此前**只挂在 `agent/created`** 上,本插件行**激活晚于 agent 存在**时(插件热重载、宿主先恢复会话后本 bundle 行才挂完),全部已存在的 agent 保持假 pwsh 且没有 bash,且永不再触发判定。修复为 **dsh-ops 验证过的双重独立层**(`installHostShellDenial` 同款形态):
23
+ - **层 1 — 挂载时补扫**:`mountInlineTools` 新增 `agents(ctx)` 依赖(index.js 传入 `ctx.get('agents')`),挂载那一刻对 `agents.list()` 里的每个**已存活 agent** 跑一遍 `reconcile`——与 created 事件同一条路径,250ms×5 有界重试等同样生效;
24
+ - **层 2 — 调用层守卫**:新增 `tools/pre-execute` 监听,凡 `pwsh` 调用到达分发层一律 deny,理由句直接指路替换品(bash 工具 / `gitbash_pwsh`)。两层层层独立:即便 per-agent 的 `restrict({ deny })` 输了竞态,谎工具也到不了执行器。
25
+ - **顺手修复:重名声明 SyntaxError**。v0.35.2 开发中引入的 `agents` 依赖与 `mountInlineTools` 内部的 agent 状态 Map 同名(`const` 重复声明,模块整体加载失败)。内部 Map 改名 `tracked`;冒烟加防回归断言(源码不得再现 `const agents = new Map()`)。
26
+ - **冒烟**:新增「inline tools: the mount-time sweep and the call-layer guard exist (v0.35.2)」;「…wires the patch…」补 `agents` 依赖接线断言。合计 114 项(113 过 + 1 跳)。
27
+ - **Windows 真机复验清单(升级后)**:①官方模式新会话有 bash 工具;②假 pwsh 调用被拒(deny 理由指路);③`gitbash_pwsh` 跑真 pwsh 7;④`gb_grep` 出参 /c/ 方言。v0.35.0 的真机验证项(见该条目)顺延至此版一起验。
28
+
6
29
  ## v0.35.1 — 2026-10-12
7
30
 
8
31
  **类型**:fix(dsh-ptc-cordis-preset 联动显示;真机反馈)。
package/dsh.plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "dsh-external/dsh-gitbash-shell",
3
- "version": "0.35.1",
3
+ "version": "0.35.3",
4
4
  "main": "./src/index.js",
5
5
  "description": "Windows 全模式 Git Bash:用 Git for Windows bash 替换 PowerShell 执行器;官方四个模式经行内工具补位直接获得 Git Bash(v0.35.0,默认),四个 *·Git Bash 变体 preset 可开关;附带 gitbash_pwsh(系统 PowerShell 7)与 gb_* FastCtx 文件工具,检测到 dsh-ops 自动让位。联动:dsh-ptc-cordis-preset(PTC 创造模式带 Git Bash 行且可去重)+ dsh-better-sidebar(终端标签自动切 Git Bash)。",
6
6
  "engines": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-gitbash-shell",
3
- "version": "0.35.1",
3
+ "version": "0.35.3",
4
4
  "description": "Windows 上让 agent 的每条 shell 命令改经 Git for Windows bash 执行;物化四个内置预设的 Git Bash 变体,统一 MSYS 盘根路径方言,侧栏终端接管可开关。· Run every agent shell command through Git for Windows bash on Windows; materialize Git Bash variants of the built-in presets, unify the MSYS drive-root path dialect, and adopt the sidebar terminal behind a switch.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -42,6 +42,11 @@ export const FASTCTX_FILE_TOOLS = Object.freeze(['inspect_local_file', 'grep', '
42
42
  export const GB_PREFIX = 'gb_'
43
43
  export const gbName = (raw) => GB_PREFIX + raw
44
44
 
45
+ // The HOST registry rejects any tool without an `output` contract (issue #18,
46
+ // root cause 2) — the frozen schema + text renderer the gitbash_pwsh
47
+ // definition ships, reused so both families speak one tool-result contract.
48
+ import { TOOL_OUTPUT_SCHEMA, renderTextContent } from './inline-tools.js'
49
+
45
50
  /** Payload packages probed, in order: upstream first, the pinned fork second. */
46
51
  export const PAYLOAD_PACKAGES = Object.freeze([
47
52
  { pkg: '@fastctx/win32-x64', exe: 'bin/fastctx.exe' },
@@ -323,6 +328,10 @@ export function mountFastctx(ctx, deps) {
323
328
  name: gbName(rawName),
324
329
  description: `${String(tool.description ?? rawName)}\n${FASTCTX_SANDBOX_NOTE}`,
325
330
  parameters: schema,
331
+ output: {
332
+ schema: TOOL_OUTPUT_SCHEMA,
333
+ render: (_args, value) => renderTextContent(value),
334
+ },
326
335
  async execute(args) {
327
336
  const translated = translateFastctxArgs(args, msysToWin)
328
337
  const result = await callWithReconnect(rawName, translated)
package/src/index.js CHANGED
@@ -2608,6 +2608,7 @@ export async function apply(ctx, config = {}) {
2608
2608
  try {
2609
2609
  mountInlineTools(ctx, {
2610
2610
  tools: (c) => { try { return c.get('tools') } catch { return undefined } },
2611
+ agents: (c) => { try { return c.get('agents') } catch { return undefined } },
2611
2612
  createScope: (agentCtx) => {
2612
2613
  let childCtx
2613
2614
  const fiber = agentCtx.plugin((c) => { childCtx = c })
@@ -2624,7 +2625,7 @@ export async function apply(ctx, config = {}) {
2624
2625
  if (!subprocess || typeof subprocess.spawn !== 'function') throw new Error('the host subprocess service is not available on this deployment')
2625
2626
  return subprocess.spawn(spec)
2626
2627
  },
2627
- pwshPath: resolveHostPwsh7(),
2628
+ pwshPath: resolveHostPwsh7({ exists: existsSync }),
2628
2629
  pwshEnabled: () => liveSettings.pwshTool(),
2629
2630
  msysToWin: (p) => translateMsysPath(p, buildTranslateEnv(readConfiguredBashPath())),
2630
2631
  echo: (text) => (liveSettings.dialect().posixPaths === true ? windowsToMsys(text) : text),
@@ -37,6 +37,44 @@
37
37
  * loads but mounts nothing.
38
38
  */
39
39
 
40
+ /**
41
+ * The tool-result contract the HOST registry requires from every registered
42
+ * tool (issue #18, root cause 2): `register()` throws
43
+ * `must declare output { schema, render, presentationMeta? }` unless the
44
+ * definition carries an `output` whose `schema` passes
45
+ * `assertSupportedJsonSchema` and whose `render` is a function. Both of this
46
+ * plugin's tool families return `{ content: [{ type: 'text', text }] }` from
47
+ * `execute`, so the schema and the text renderer below are shared verbatim
48
+ * with the FastCtx bridge — the same frozen shape dsh-ops ships for its own
49
+ * MCP-backed tools (proven on real Windows hosts).
50
+ */
51
+ export const TOOL_OUTPUT_SCHEMA = Object.freeze({
52
+ type: 'object',
53
+ properties: {
54
+ content: { type: 'array', items: {} },
55
+ structuredContent: {},
56
+ },
57
+ required: ['content'],
58
+ additionalProperties: false,
59
+ })
60
+
61
+ /**
62
+ * Render the `{ content: [...] }` execute value into text blocks. Non-text
63
+ * blocks and non-string text fields are skipped; empty content renders as one
64
+ * empty text block rather than an empty array, so the card never loses its
65
+ * placeholder.
66
+ * @param {unknown} value - the execute return value.
67
+ * @returns {Array<{ type: string, text: string }>} content blocks for the host renderer.
68
+ */
69
+ export function renderTextContent(value) {
70
+ const blocks = Array.isArray(value?.content) ? value.content : []
71
+ const text = blocks
72
+ .map((block) => (typeof block?.text === 'string' ? block.text : ''))
73
+ .filter((line) => line !== '')
74
+ .join('\n')
75
+ return [{ type: 'text', text }]
76
+ }
77
+
40
78
  /** Answer for one observed tool face. Pure. */
41
79
  export function inlineNeeds(names) {
42
80
  const set = new Set(Array.isArray(names) ? names : [])
@@ -223,6 +261,7 @@ export async function activateOfficialBash(agent, toolBashHref, createScopeFn, l
223
261
  /**
224
262
  * Mount the per-agent reconcile. `deps`:
225
263
  * - tools(ctx) -> the host tool registry (or undefined)
264
+ * - agents(ctx) -> the host agents service (or undefined)
226
265
  * - createScope(fn) -> cordis createScope
227
266
  * - allowed(session) -> danger-full-access verdict for the pwsh channel
228
267
  * - echo(text) -> result dialect echo (Windows -> MSYS), or undefined
@@ -231,15 +270,20 @@ export async function activateOfficialBash(agent, toolBashHref, createScopeFn, l
231
270
  * - pwshEnabled() -> the row switch for publishing the pwsh tool
232
271
  * - log(message) -> the plugin's tagged logger
233
272
  *
234
- * Registration decisions re-run on EVERY agent creation: a per-agent tool
235
- * face is per-agent truth, and the observed state (variant presets on, a
236
- * future official change, dsh-ops appearing) is absorbed automatically
237
- * instead of being encoded as a boot-time snapshot.
273
+ * Registration decisions re-run on EVERY agent creation AND once at mount
274
+ * time for every ALREADY-LIVE agent (the dsh-ops-proven shape,
275
+ * `installHostShellDenial`): this row can activate long after agents exist —
276
+ * a plugin hot-reload, or a host whose sessions resumed before this bundle
277
+ * row finished mounting — and an agent/created-only listener leaves every
278
+ * such agent with the fake pwsh and no bash, which is exactly the Windows
279
+ * field report behind this block. A second, INDEPENDENT layer guards the
280
+ * call itself through `tools/pre-execute`: visibility restriction can lose a
281
+ * race, and a lying pwsh that reaches its executor still runs Git Bash.
238
282
  */
239
283
  export function mountInlineTools(ctx, deps) {
240
- const { tools, createScope, allowed, spawn, pwshPath, echo, log } = deps
241
- /** agent -> { timer, scope, denyPwsh } */
242
- const agents = new Map()
284
+ const { tools, agents, createScope, allowed, spawn, pwshPath, echo, log } = deps
285
+ /** agent -> { timer, scope, denyPwsh }; named `tracked` because `agents` above is the host service */
286
+ const tracked = new Map()
243
287
  let pwshPublished = false
244
288
  let pwshDisposer = undefined
245
289
  let toolBashHrefPromise
@@ -287,24 +331,27 @@ export function mountInlineTools(ctx, deps) {
287
331
  if (attempt < 5 && !names.includes('bash') && !names.includes('pwsh')) {
288
332
  const timer = setTimeout(() => { try { reconcile(agent, { attempt: attempt + 1 }) } catch { /* never crash on a timer */ } }, 250)
289
333
  timer.unref?.()
290
- const state = agents.get(agent)
334
+ const state = tracked.get(agent)
291
335
  if (state) state.timer = timer
292
- else agents.set(agent, { timer, scope: undefined, denyPwsh: undefined })
336
+ else tracked.set(agent, { timer, scope: undefined, denyPwsh: undefined })
293
337
  return
294
338
  }
295
339
  const needs = inlineNeeds(names)
296
- let state = agents.get(agent)
340
+ let state = tracked.get(agent)
297
341
  if (state?.timer) { clearTimeout(state.timer); state.timer = undefined }
298
342
  if (!state) {
299
343
  state = { timer: undefined, scope: undefined, denyPwsh: undefined }
300
- agents.set(agent, state)
344
+ tracked.set(agent, state)
301
345
  }
302
346
  if (needs.denyPwsh && !state.denyPwsh) {
303
347
  // Deny the fake pwsh first: restrict is per-agent and idempotent, and
304
348
  // the lying tool must never be callable, not even for the milliseconds
305
- // before the bash tool lands.
349
+ // before the bash tool lands. The call MUST go through the AGENT's
350
+ // scoped context (`agent.ctx.tools`, the dsh-ops-proven shape): on the
351
+ // ROOT context the host throws "requires a scoped context" and the
352
+ // deny silently never exists (issue #18, root cause 3).
306
353
  try {
307
- const deny = tools(ctx)?.restrict?.({ deny: ['pwsh'] })
354
+ const deny = agent.ctx?.tools?.restrict?.({ deny: ['pwsh'] })
308
355
  if (typeof deny === 'function') state.denyPwsh = deny
309
356
  } catch (error) {
310
357
  log(`fake-pwsh deny failed: ${error?.message ?? error}`)
@@ -318,7 +365,7 @@ export function mountInlineTools(ctx, deps) {
318
365
  return
319
366
  }
320
367
  const scope = await activateOfficialBash(agent, href, createScope, log)
321
- if (scope && agents.get(agent) === state) {
368
+ if (scope && tracked.get(agent) === state) {
322
369
  state.scope = scope
323
370
  log('official bash tool activated for this agent (Git Bash host; fake pwsh denied)')
324
371
  }
@@ -334,9 +381,9 @@ export function mountInlineTools(ctx, deps) {
334
381
  }
335
382
 
336
383
  const teardown = (agent) => {
337
- const state = agents.get(agent)
384
+ const state = tracked.get(agent)
338
385
  if (!state) return
339
- agents.delete(agent)
386
+ tracked.delete(agent)
340
387
  if (state.timer) clearTimeout(state.timer)
341
388
  if (state.denyPwsh) { try { state.denyPwsh() } catch { /* already lifted */ } }
342
389
  if (state.scope) { void state.scope.dispose().catch(() => {}) }
@@ -345,14 +392,34 @@ export function mountInlineTools(ctx, deps) {
345
392
  try {
346
393
  ctx.on('agent/created', ({ agent } = {}) => { try { reconcile(agent) } catch { /* never break agent creation */ } })
347
394
  ctx.on('agent/disposed', ({ agent } = {}) => { try { teardown(agent) } catch { /* never break disposal */ } })
395
+ // MOUNT-TIME SWEEP (v0.35.2, the Windows field report): agents that
396
+ // already exist when this row mounts never fire agent/created — a plugin
397
+ // hot-reload or a host that resumed sessions before this bundle row
398
+ // finished mounting would otherwise keep the fake pwsh and no bash on
399
+ // every live agent. Same shape the proven dsh-ops denial uses.
400
+ for (const agent of agents?.(ctx)?.list?.() ?? []) {
401
+ try { reconcile(agent) } catch { /* one broken agent never blocks the rest */ }
402
+ }
403
+ // CALL-LAYER GUARD (v0.35.2, second independent layer): even when the
404
+ // per-agent visibility restriction loses a race, a `pwsh` call that
405
+ // reaches its executor runs Git Bash (ctx.shell IS Git Bash on this
406
+ // host). Deny at dispatch and name the replacements.
407
+ ctx.on('tools/pre-execute', (exec, next) => {
408
+ try {
409
+ if (exec?.name === 'pwsh') {
410
+ return { kind: 'deny', reason: 'the host pwsh tool would run Git Bash on this host (its executor was replaced). Use the bash tool for commands, or gitbash_pwsh for Windows-native work (registry, services, WMI).' }
411
+ }
412
+ } catch { /* never block the waterfall */ }
413
+ return next()
414
+ })
348
415
  } catch (error) {
349
416
  log(`agent lifecycle wiring failed: ${error?.message ?? error}`)
350
417
  return
351
418
  }
352
419
 
353
420
  ctx.effect(() => () => {
354
- for (const agent of [...agents.keys()]) teardown(agent)
355
- agents.clear()
421
+ for (const agent of [...tracked.keys()]) teardown(agent)
422
+ tracked.clear()
356
423
  unpublishPwsh()
357
424
  }, 'dsh-gitbash-shell: inline tool patching')
358
425
  }
@@ -387,6 +454,10 @@ export function gitbashPwshDefinition({ pwsh, spawn, allowed, msysToWin, echo })
387
454
  required: ['command'],
388
455
  additionalProperties: false,
389
456
  },
457
+ output: {
458
+ schema: TOOL_OUTPUT_SCHEMA,
459
+ render: (_args, value) => renderTextContent(value),
460
+ },
390
461
  async execute(args, exec) {
391
462
  const invalid = validatePwshArgs(args)
392
463
  if (invalid) throw new Error(invalid)