dsh-gitbash-shell 0.40.5 → 0.41.1

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,25 @@
3
3
  > 倒序排列,新版本条目在最上面。条目格式:`## vX.Y.Z — YYYY-MM-DD` + 类型(feat / fix / docs / chore)+ 要点 + 相关链接。
4
4
  > 纪律见 AGENTS.md「变更记录纪律」:发版前先更新本文件并随版本提交;事故复盘、复现与真机验证记录也记在这里。
5
5
 
6
+ ## v0.41.1 — 2026-10-19
7
+
8
+ ### fix:桌面版设置页「安装 / 升级选中」无反应(栅栏 no-origin 误伤 Electron + 前端静默吞 403)
9
+
10
+ - 报告:[issue #33](https://github.com/KannaKuron/dsh-gitbash-shell/issues/33)(@AncherEven,报告自带完整根因定位,感谢)。dsh 桌面版(Electron)设置 → 插件 → 终端工具,点「安装 / 升级选中」不报错、不出进度、按钮复原。
11
+ - **fix(栅栏侧,tool-runner.js)**:`fenceToolRequest` 原要求改写类请求必须携带同源 Origin/Referer,而桌面版 Electron 渲染进程的同源 fetch **不携带**这两个头 → 恒 403 `no-origin`。现改为:缺 Origin 时放行 origin 半(信任围栏的真防线是 loopback 限定,保持不变);**携带** Origin/Referer 时仍必须同源,跨站一律拒。安全性论证:浏览器对一切改写请求(同站与跨站)都附加 Origin,缺头请求不可能来自网页 CSRF,只能来自非浏览器客户端——而后者本就能伪造同源头,栅栏对其原本就不构成额外防线。
12
+ - **fix(前端侧,client.js)**:`runTools()` 对非 2xx 响应直接 `res.json()` 解析错误体、取不到 `job` 静默吞掉;现与 `loadTools()` 一致先判 `res.ok`,栅栏拒绝等失败会显示「HTTP 403」类错误而不是无反应。
13
+ - 验证:`npm test` 263 项全绿;栅栏测试矩阵更新(缺头 POST 放行 / 携带跨源 POST 仍拒 / LAN 携带同源头仍 not-loopback / 前端 res.ok 守卫断言)。
14
+
15
+ ## v0.41.0 — 2026-10-19
16
+
17
+ ### feat:官方「持久终端」实验包 Git Bash 化自动接管;dsh-ops 并存时剔除其 shell 工具;terminal_* 方言说明
18
+
19
+ - **持久终端自动接管(v0.41.0 主特性)**:官方实验包 `@deepseek-ai/dsh-experimental-terminal-bundle` 在 win32 上默认 `optional-terminal-bash` 禁 / `optional-terminal-pwsh` 启——开启「持久终端」后终端是 PowerShell,与本插件的 bash-first 语义相反。v0.41 起检测到 bundle 挂载后,由插件维护 profile patch(bundle 组子行按 id 覆盖,官方 `applyEntryPatches` 通道):`optional-terminal-bash` → `disabled: false` + `shellPath` 指 Git Bash;`optional-terminal-pwsh` → `disabled: true`(仅当该行不存在时写入,已存在则不动并提示并存)。用户显式配置(bash 行 `disabled: true` 或 `shellPath` 指向别处)一律尊重,不覆盖。
20
+ - **为什么直写 patch 文件**:官方 config-editor 两个硬限制(实测 packages/boot/config-editor)——`entries()` 只寻址 include 挂载的顶层行(bundle 组内子行不可见);`edit()` 只写 config 键,写不了行级 `disabled`。bundle 子行覆盖只能落在 profile patch 文件上。安全链:`parseDocument` 保注释保 `!!js` → 变更后 re-parse 自检 → 原子写(`@deepseek-ai/dsh-atomic-write` 优先,降级 tmp+rename,mode 0600)→ 回读验证 → 失败回滚原字节。守卫测试随之演进:index.js 豁免「no fs path to the profile patch」整版禁令(bundle 接管是有意的直写,安全特征有断言),**侧栏终端接管仍走 configEditor 不变**。
21
+ - **terminal_* 方言说明**:bundle 接管生效后(`terminalDialect.gitBash`),工具路由说明进入持久终端通道——`terminal_send`/`terminal_signal` 的命令按 Git Bash/MSYS 方言书写,与单次 bash 工具同一套提示。
22
+ - **dsh-ops 并存时的 shell 工具剔除(用户决策定案)**:安装了本插件 = bash 用本插件方案;`ops_bash` / `ops_run` 在本插件 bash 工具在场的 agent 上默认 deny(`tools.restrict` + `tools/pre-execute` 双层),不做设置开关;ops 的 file / 后台 / PTC 增强不受影响。
23
+ - 验证:`npm test` 263 项全绿(新增 tests/terminal-bundle.mjs 26 项:plan 矩阵 12 + writer 12 + report + detect);守卫测试演进 2 处(compatibility assemble 段参数面补 `terminalDialect`;smoke 侧栏守卫按上述范围收窄)。
24
+
6
25
  ## v0.40.5 — 2026-10-18
7
26
 
8
27
  ### 工具行字号跟随宿主字体设置;半像素字号归整(fix)
package/dsh.plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "dsh-external/dsh-gitbash-shell",
3
- "version": "0.40.5",
3
+ "version": "0.41.1",
4
4
  "main": "./src/index.js",
5
5
  "description": "Windows 全模式 Git Bash:自带 bash 工具(私有 Git Bash 执行器实例,官方 PowerShell 执行器原样保留),官方四个模式经行内工具补位直接获得 Git Bash(v0.35.0 起;v0.39.0 起不再替换宿主执行器),四个 *·Git Bash 变体 preset 可开关;官方 pwsh 工具默认隐藏、开关可见(真 PowerShell);附带 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.40.5",
3
+ "version": "0.41.1",
4
4
  "description": "Windows 上给 agent 一个真正的 Git Bash 命令工具:私有 Git Bash 执行器实例(官方 PowerShell 执行器原样保留),统一 MSYS 盘根路径方言,官方 pwsh 工具默认隐藏可开关,物化四个内置预设的 Git Bash 变体,侧栏终端接管可开关。· Give agents a real Git Bash tool on Windows: a private Git Bash executor instance (the official PowerShell executor stays untouched), a unified MSYS drive-root path dialect, an opt-in official pwsh tool, and four Git Bash preset variants.",
5
5
  "type": "module",
6
6
  "repository": {
package/src/client.js CHANGED
@@ -2551,7 +2551,12 @@ window.__ModuleLoader__.load({
2551
2551
  headers: { "content-type": "application/json" },
2552
2552
  body: JSON.stringify({ action: "auto", ids: ids }),
2553
2553
  })
2554
- .then(function (res) { return res.json(); })
2554
+ // Issue #33: a non-2xx answer (e.g. a 403 fence refusal) is a JSON
2555
+ // body too — parsing it and finding no job silently ate the error.
2556
+ .then(function (res) {
2557
+ if (!res.ok) throw new Error("HTTP " + res.status);
2558
+ return res.json();
2559
+ })
2555
2560
  .then(function (data) { setToolJob(data !== null && typeof data === "object" && data.job ? data.job : null); })
2556
2561
  .catch(function (err) { setToolErr(String((err && err.message) || err)); })
2557
2562
  .then(function () { setToolBusy(false); });
package/src/index.js CHANGED
@@ -49,6 +49,7 @@ import {
49
49
  mkdtempSync,
50
50
  readFileSync,
51
51
  readdirSync,
52
+ renameSync,
52
53
  rmSync,
53
54
  writeFileSync,
54
55
  } from 'node:fs'
@@ -59,6 +60,8 @@ import { fileURLToPath } from 'node:url'
59
60
  import { PRESET_META, minimalPluginsFor, pluginsFor } from './compositions.js'
60
61
  import { loadSchemastery } from './schemastery.js'
61
62
  import { applyToolRouting, mountInlineTools } from './inline-tools.js'
63
+ import { adoptTerminalBundlePatch, detectTerminalBundle, terminalBundleReport, terminalDialect } from './terminal-bundle.js'
64
+ import { resolveHostModule } from './bash-tool.js'
62
65
  import { mountFastctx } from './fastctx-bridge.js'
63
66
 
64
67
  /**
@@ -2250,6 +2253,7 @@ export async function apply(ctx, config = {}) {
2250
2253
  applyToolRouting(assembly.tools, {
2251
2254
  pwshVisible: liveSettings.pwshTool() === true,
2252
2255
  msys: applies && dialect.posixPaths === true,
2256
+ terminalGitBash: terminalDialect.gitBash === true,
2253
2257
  })
2254
2258
  }
2255
2259
  if (applies && dialect.posixPaths === true && assembly && typeof assembly === 'object') {
@@ -2726,6 +2730,69 @@ export async function apply(ctx, config = {}) {
2726
2730
  }
2727
2731
  }
2728
2732
 
2733
+ // ── OFFICIAL persistent-terminal bundle adoption (v0.41.0) ─────────────
2734
+ // The experimental terminal bundle mounts its shell backends behind a
2735
+ // bundle patch that on Windows enables the pwsh backend and disables the
2736
+ // bash one — exactly backwards for a Git Bash house. The fix is a
2737
+ // PROFILE-PATCH row edit (the host patch algorithm addresses group
2738
+ // children by id, and bundle patches are read before the profile patch
2739
+ // file) because configEditor can neither see bundle children nor write a
2740
+ // row-level `disabled`. See src/terminal-bundle.js for the full
2741
+ // rationale and safety rails. The terminal_* dialect sentence rides the
2742
+ // existing routing block once terminalDialect.gitBash turns true.
2743
+ if (process.platform === 'win32') {
2744
+ try {
2745
+ ctx.inject(['profileContext'], (patchCtx) => {
2746
+ const run = async () => {
2747
+ const patchPath = patchCtx.get('profileContext')?.patchPath
2748
+ if (typeof patchPath !== 'string' || patchPath.trim() === '') {
2749
+ console.log(`${TAG} persistent terminal: no profile patch path — nothing to adopt`)
2750
+ return
2751
+ }
2752
+ let mounted = false
2753
+ try {
2754
+ mounted = await detectTerminalBundle(ctx)
2755
+ } catch (error) {
2756
+ console.log(`${TAG} persistent terminal: bundle detection failed: ${error?.message ?? error}`)
2757
+ return
2758
+ }
2759
+ const resolution = readBashResolution()
2760
+ const result = await adoptTerminalBundlePatch(
2761
+ {
2762
+ readFile: (file) => readFileSync(file, 'utf8'),
2763
+ writeFileAtomic: async (file, data) => {
2764
+ // The host's atomic write when resolvable; a same-directory
2765
+ // tmp+rename otherwise — never a torn patch file.
2766
+ try {
2767
+ const atomic = await resolveHostModule(ctx, '@deepseek-ai/dsh-atomic-write')
2768
+ if (typeof atomic?.writeFileAtomic === 'function') return atomic.writeFileAtomic(file, data, { mode: 0o600 })
2769
+ } catch { /* fallback below */ }
2770
+ const tmp = `${file}.gitbash-${Date.now()}.tmp`
2771
+ writeFileSync(tmp, data, { mode: 0o600 })
2772
+ renameSync(tmp, file)
2773
+ },
2774
+ yaml: () => resolveHostModule(ctx, 'yaml'),
2775
+ },
2776
+ {
2777
+ platform: process.platform,
2778
+ enabled: liveSettings.autoTerminalShell(),
2779
+ mounted,
2780
+ resolved: resolution.ok ? resolution.path : '',
2781
+ patchPath,
2782
+ },
2783
+ )
2784
+ if (result.status === 'adopted' || result.status === 'updated' || result.status === 'unchanged') {
2785
+ terminalDialect.gitBash = true
2786
+ }
2787
+ console.log(`${TAG} ${terminalBundleReport(result)}`)
2788
+ }
2789
+ run().catch((error) => console.log(`${TAG} persistent terminal adoption failed: ${error?.message ?? error}`))
2790
+ })
2791
+ } catch (error) {
2792
+ console.log(`${TAG} persistent terminal adoption wiring failed: ${error?.message ?? error}`)
2793
+ }
2794
+ }
2795
+
2729
2796
  // ── dsh-better-sidebar terminal adoption (Windows only) ────────────────
2730
2797
  // The sidebar resolves its terminal shell through the settings seam per
2731
2798
  // open; adopt it through that seam (see adoptSidebarShell for rationale).
@@ -91,6 +91,27 @@ export function inlineNeeds(names, { pwshVisible } = {}) {
91
91
  }
92
92
  }
93
93
 
94
+ /**
95
+ * The dsh-ops shell pair (v0.41.0, owner decision): while this plugin's own
96
+ * bash owns a session, those two names are DENIED for the agent. The list is
97
+ * dsh-ops's own published surface - lib/policy.js builds every public name as
98
+ * TOOL_PREFIX + raw with TOOL_PREFIX = "ops_", and the shell-class raw names
99
+ * are "run" (one-shot shell) and "bash" (in-process shell). Their file/search
100
+ * (ops_inspect_local_file, ops_grep, ops_glob, ops_replace), background-job
101
+ * (ops_run_background, ops_job_*) and batch surfaces stay untouched - the
102
+ * owner's call: installing dsh-ops buys its enhancements, not a second bash.
103
+ */
104
+ export const OPS_SHELL_TOOLS = Object.freeze(['ops_bash', 'ops_run'])
105
+
106
+ /**
107
+ * The deny reason for an ops shell call on an agent our bash owns (route B
108
+ * wording): the tool exists, the host just routes shells elsewhere - the
109
+ * sentence names the owner and keeps the agent inside the offered face.
110
+ */
111
+ export function deniedOpsShellReason() {
112
+ return 'the dsh-ops shell tools are denied on this host: dsh-gitbash-shell owns the shell (its own Git Bash bash tool). Use the bash tool for commands; the other ops_* tools (files, search, background jobs) are unaffected.'
113
+ }
114
+
94
115
  /**
95
116
  * The routing addendum appended to the `bash` tool's description through the
96
117
  * system-prompt assemble waterfall. TWO variants (v0.37.0, user decision):
@@ -124,10 +145,16 @@ const ROUTING_SAFETY = 'Choose the execution route before running. A policy/acce
124
145
  const PTC_ROUTING = 'Inside run_code, call only bindings actually offered by the current SDK with await tools.<name>(...). Select by their descriptions: shell execution for shell syntax/pipelines; an offered typed native-executable binding for exact Windows argv or explicitly known per-stream encodings; an offered complete-path conversion binding before passing an MSYS value to Node fs/path. These are different operations, not substitutes for permissions. Never pass routes, search text or source code to a path converter. Program strings do not perform shell expansion. If a needed binding is absent, report the missing capability; do not invent its name. ' + ROUTING_SAFETY
125
146
 
126
147
  /** Model guidance names only tools in this assembly, not a switch's intent. */
127
- export function toolRoutingHintFor(tools, { pwshVisible = false } = {}) {
148
+ export function toolRoutingHintFor(tools, { pwshVisible = false, terminalGitBash = false } = {}) {
128
149
  const names = new Set((Array.isArray(tools) ? tools : []).map(tool => tool?.name))
129
150
  if (!names.has('bash')) return ''
130
151
  const lines = [pwshVisible && names.has('pwsh') ? GITBASH_ROUTE_ADDENDUM : GITBASH_ROUTE_ADDENDUM_NO_PWSH]
152
+ // v0.41.0: the persistent-terminal dialect fact. Said ONLY when the host
153
+ // actually carries the terminal tools AND our terminal-bundle adoption has
154
+ // settled the rows to Git Bash (the caller passes the settled flag) - a
155
+ // bundle left at its official defaults runs pwsh there, and a sentence
156
+ // must never describe a shell the button will not start.
157
+ if (terminalGitBash === true && names.has('terminal_open')) lines.push('The persistent terminal tools (terminal_open/read/send/...) run Git Bash (MSYS) on this host: the same drive-root paths (/c/...) and MINGW64 semantics as this bash tool; they are NOT PowerShell.')
131
158
  if (names.has('gitbash_native')) lines.push('Use gitbash_native before execution when a Windows .exe needs exact literal argv such as /all or /c without MSYS conversion, or independently declared stdout/stderr encodings. Supply an absolute executable, mark each argument literal or path, and select only a known encoding (raw for unknown/mixed bytes). It does not implement shell pipelines, expansion or .cmd/.bat wrappers; only resolved danger-full-access can execute it.')
132
159
  if (names.has('gitbash_to_native_path')) lines.push('Use gitbash_to_native_path only for a complete caller-declared MSYS filesystem path that a native Node fs/path API needs, especially a runtime-built value. Read native_path from the result. Never convert routes, search strings or shell/program source. Already-native or cwd-relative Node paths need no conversion; ordinary tool path fields already use their own translation. Conversion grants no access.')
133
160
  lines.push(ROUTING_SAFETY)
@@ -135,7 +162,7 @@ export function toolRoutingHintFor(tools, { pwshVisible = false } = {}) {
135
162
  }
136
163
 
137
164
  /** Add a replaceable, idempotent block; a reused assembly follows visibility. */
138
- export function applyToolRouting(tools, { pwshVisible = false, msys = false } = {}) {
165
+ export function applyToolRouting(tools, { pwshVisible = false, msys = false, terminalGitBash = false } = {}) {
139
166
  if (!Array.isArray(tools)) return
140
167
  for (const tool of tools) {
141
168
  if (!tool || typeof tool.description !== 'string' || !['bash', 'run_code'].includes(tool.name)) continue
@@ -144,7 +171,7 @@ export function applyToolRouting(tools, { pwshVisible = false, msys = false } =
144
171
  const end = start < 0 ? -1 : description.indexOf(ROUTING_END, start)
145
172
  if (end >= 0) description = description.slice(0, start) + description.slice(end + ROUTING_END.length)
146
173
  const hint = tool.name === 'bash'
147
- ? toolRoutingHintFor(tools, { pwshVisible }) + (msys ? '\n' + GITBASH_MSYS_ADDENDUM : '')
174
+ ? toolRoutingHintFor(tools, { pwshVisible, terminalGitBash }) + (msys ? '\n' + GITBASH_MSYS_ADDENDUM : '')
148
175
  : PTC_ROUTING
149
176
  tool.description = description + ROUTING_START + hint + ROUTING_END
150
177
  }
@@ -234,7 +261,7 @@ export function mountInlineTools(ctx, deps) {
234
261
  const controller = new AbortController()
235
262
  let closed = false
236
263
  const isLive = (agent, state) => !closed && tracked.get(agent) === state && !state.controller.signal.aborted
237
- const newState = () => ({ timer: undefined, scope: undefined, denyPwsh: undefined, pending: undefined, controller: new AbortController() })
264
+ const newState = () => ({ timer: undefined, scope: undefined, denyPwsh: undefined, denyOpsShell: undefined, pending: undefined, controller: new AbortController() })
238
265
  const experimentalEnabled = () => deps.experimentalEnabled?.() !== false
239
266
  // Loader imports cannot be cancelled, but no continuation may outlive its
240
267
  // owner. Join our cancelled continuations without waiting on a stuck loader.
@@ -384,6 +411,26 @@ export function mountInlineTools(ctx, deps) {
384
411
  log(`${extra.name} activation failed; bash remains available: ${error?.message ?? error}`)
385
412
  }
386
413
  }
414
+ // v0.41.0 (owner decision): while OUR bash owns the session, the ops
415
+ // shell pair is denied by name - installing dsh-ops buys its file,
416
+ // search and background enhancements, not a second bash. Through the
417
+ // AGENT's scoped context, the exact shape the pwsh hide uses (issue
418
+ // #18: the root context throws "requires a scoped context"). Only the
419
+ // names actually in the face are listed, so a future dsh-ops rename
420
+ // degrades to "no denial", never to a wrong deny list. On failure the
421
+ // ops tools stay - our bash remains the routing answer either way.
422
+ const opsShell = faceNames(agent).filter((name) => OPS_SHELL_TOOLS.includes(name))
423
+ if (opsShell.length > 0) {
424
+ try {
425
+ const deny = agent.ctx?.tools?.restrict?.({ deny: [...opsShell] })
426
+ if (typeof deny === 'function') {
427
+ state.denyOpsShell = deny
428
+ log('ops shell tools denied for this agent (' + opsShell.join(', ') + '): this host\'s bash is ours; their file/search/background tools stay')
429
+ }
430
+ } catch (error) {
431
+ log('ops shell denial failed (their shell tools stay available): ' + (error?.message ?? error))
432
+ }
433
+ }
387
434
  retained = true
388
435
  log('own bash tool activated for this agent (Git Bash execution; official pwsh tool real but hidden by default)')
389
436
  } catch (error) {
@@ -414,6 +461,7 @@ export function mountInlineTools(ctx, deps) {
414
461
  const previous = tracked.get(agent)
415
462
  if (previous?.timer) { clearTimeout(previous.timer); previous.timer = undefined }
416
463
  if (previous?.denyPwsh) { previous.denyPwsh(); previous.denyPwsh = undefined }
464
+ if (previous?.denyOpsShell) { previous.denyOpsShell(); previous.denyOpsShell = undefined }
417
465
  const names = faceNames(agent)
418
466
  // The composed face may not be wired yet at created-time. Retry bounded:
419
467
  // a face with NEITHER tool carries no verdict. Five tries at 250ms, then
@@ -457,6 +505,26 @@ export function mountInlineTools(ctx, deps) {
457
505
  if (needs.needsBash && !state.scope) {
458
506
  startBash(agent, state)
459
507
  }
508
+ // v0.41.0: keep the ops shell denial attached while our bash owns the
509
+ // agent. The mask was undone above for observation; an agent that is
510
+ // already owned (state.scope, including one still mounting - provideBash
511
+ // claims the scope before its asynchronous stages) gets the denial
512
+ // re-attached here, so a reconcile fired by a switch flip or a re-show
513
+ // never leaves the pair visible.
514
+ if (state.scope && !state.denyOpsShell) {
515
+ const opsShell = names.filter((name) => OPS_SHELL_TOOLS.includes(name))
516
+ if (opsShell.length > 0) {
517
+ try {
518
+ const deny = agent.ctx?.tools?.restrict?.({ deny: [...opsShell] })
519
+ if (typeof deny === 'function') {
520
+ state.denyOpsShell = deny
521
+ log('ops shell tools denied for this agent (' + opsShell.join(', ') + ')')
522
+ }
523
+ } catch (error) {
524
+ log('ops shell denial failed (their shell tools stay available): ' + (error?.message ?? error))
525
+ }
526
+ }
527
+ }
460
528
  }
461
529
 
462
530
  const teardown = async (agent) => {
@@ -466,6 +534,7 @@ export function mountInlineTools(ctx, deps) {
466
534
  state.controller.abort(new Error('inline bash agent disposed'))
467
535
  if (state.timer) clearTimeout(state.timer)
468
536
  if (state.denyPwsh) { state.denyPwsh(); state.denyPwsh = undefined }
537
+ if (state.denyOpsShell) { state.denyOpsShell(); state.denyOpsShell = undefined }
469
538
  await Promise.all([state.scope?.dispose(), state.pending])
470
539
  }
471
540
 
@@ -510,6 +579,13 @@ export function mountInlineTools(ctx, deps) {
510
579
  if (exec?.name === 'pwsh' && !opsSeen && deps.pwshEnabled?.() !== true) {
511
580
  return { kind: 'deny', reason: hiddenPwshDenyReason() }
512
581
  }
582
+ // v0.41.0, second independent layer for the ops shell pair: while our
583
+ // bash owns this agent, a call that slips past the visibility
584
+ // restriction is denied by name - same reasoning as the pwsh guard
585
+ // above.
586
+ if (OPS_SHELL_TOOLS.includes(exec?.name) && tracked.get(exec?.agent)?.scope) {
587
+ return { kind: 'deny', reason: deniedOpsShellReason() }
588
+ }
513
589
  } catch { /* never block the waterfall */ }
514
590
  return next()
515
591
  })
@@ -0,0 +1,346 @@
1
+ /**
2
+ * dsh-gitbash-shell — OFFICIAL persistent-terminal bundle adoption (v0.41.0).
3
+ *
4
+ * The experimental terminal bundle (@deepseek-ai/dsh-experimental-terminal-bundle)
5
+ * ships its shell backends behind a bundle patch whose own win32 gating reads:
6
+ * the bash backend row is DISABLED on win32 and the pwsh backend row is enabled
7
+ * — its README states this verbatim ("POSIX 使用 Bash,Windows 使用 PowerShell").
8
+ * On a Git Bash house that is exactly backwards: the official terminal_* tools
9
+ * would open PowerShell while every other shell surface on this host runs Git
10
+ * Bash (session tool, sidebar terminal, PTC translation).
11
+ *
12
+ * Fixing it needs a PROFILE-PATCH edit, not the settings service:
13
+ * - the bundle rows are group CHILDREN, and the host patch algorithm addresses
14
+ * children by id (buildMap recurses into group config arrays), so a profile
15
+ * patch row `- id: optional-terminal-bash {disabled: false, config:{...}}`
16
+ * overrides the bundle row (bundle patches are read BEFORE the profile patch
17
+ * file — packages/boot/app-boot/src/profile-context.ts:57);
18
+ * - configEditor CANNOT do this (measured, packages/boot/config-editor):
19
+ * entries() only sees include-mounted TOP-LEVEL rows, and edit() only
20
+ * writes the config key, never a row-level `disabled`.
21
+ *
22
+ * So this module edits the profile patch file directly, with the same care the
23
+ * official editor takes: `yaml`'s parseDocument (comments and !!js expressions
24
+ * survive untouched), targeted setIn on the two rows, a re-parse self-check,
25
+ * an atomic write, a verify-by-reread and a rollback to the previous bytes on
26
+ * any mismatch. HARD RULES (inherited from terminal-shell.js): never overwrite
27
+ * an explicit user choice, only ever write a VERIFIED Git Bash path, and
28
+ * re-read after writing.
29
+ *
30
+ * The bundle not being mounted is the normal state for most hosts: nothing is
31
+ * written then, and the session-tool inline patch keeps owning the shell story.
32
+ *
33
+ * @module dsh-gitbash-shell/terminal-bundle
34
+ */
35
+
36
+ import { normalizeBashPath } from './bash-path.js'
37
+
38
+ /** Bundle row that mounts the terminal_* tool suite — its presence (enabled) IS the bundle. */
39
+ export const TERMINAL_BUNDLE_TOOL_ROW = 'optional-tool-terminal'
40
+ /** Bundle row for the persistent bash backend (disabled on win32 by the bundle itself). */
41
+ export const TERMINAL_BUNDLE_BASH_ROW = 'optional-terminal-bash'
42
+ /** Bundle row for the persistent pwsh backend (enabled on win32 by the bundle itself). */
43
+ export const TERMINAL_BUNDLE_PWSH_ROW = 'optional-terminal-pwsh'
44
+
45
+ /** Live dialect fact for the prompt-assembly waterfall (read by index.js).
46
+ * True only when the persistent terminal provably runs this host's Git Bash. */
47
+ export const terminalDialect = { gitBash: false }
48
+
49
+ /**
50
+ * Is the official terminal bundle mounted with its tool row active? The tool
51
+ * row's presence is what turns terminal_open on — the shell backends without
52
+ * it are inert. Reads through the loader service the same way the ops
53
+ * presence probe does (an async fiber, so this awaits).
54
+ * @param {object} ctx - host context carrying `inject`.
55
+ * @returns {Promise<boolean>}
56
+ */
57
+ export async function detectTerminalBundle(ctx) {
58
+ let present = false
59
+ await ctx.inject(['loader'], (loaderCtx) => {
60
+ const entries = loaderCtx?.get?.('loader')?.entries?.()
61
+ if (typeof entries?.[Symbol.iterator] !== 'function') return
62
+ for (const entry of entries) {
63
+ if (entry?.options?.id !== TERMINAL_BUNDLE_TOOL_ROW) continue
64
+ if (entry.options.disabled === true) continue
65
+ present = true
66
+ break
67
+ }
68
+ })
69
+ return present
70
+ }
71
+
72
+ /**
73
+ * Read the plugin config entry for one patch row (the fields this module owns).
74
+ * Takes the RAW parsed patch entry (`config` wrapper included) and returns the
75
+ * FLATTENED facts; undefined when the input is not a row-shaped object.
76
+ * @param {unknown} row - a parsed patch entry.
77
+ * @returns {{disabled?: boolean, shellPath?: string}|undefined}
78
+ */
79
+ export function readPatchRow(row) {
80
+ if (!row || typeof row !== 'object' || Array.isArray(row)) return undefined
81
+ const out = {}
82
+ if (row.disabled === true) out.disabled = true
83
+ else if (row.disabled === false) out.disabled = false
84
+ const config = row.config
85
+ if (config && typeof config === 'object' && !Array.isArray(config)) {
86
+ if (typeof config.shellPath === 'string') out.shellPath = config.shellPath
87
+ }
88
+ return out
89
+ }
90
+
91
+ /**
92
+ * Pure decision: should (and how exactly) the profile patch adopt the bundle's
93
+ * shell rows for this host? Mirrors terminal-shell's planTerminalAdopt shape —
94
+ * one action + one human reason, every state enumerable and testable.
95
+ * @param {{platform?: string, enabled?: boolean, mounted?: boolean, resolved?: string, rows?: {bash?: {disabled?: boolean, shellPath?: string}, pwsh?: {disabled?: boolean}}}} input - rows are the FLATTENED row facts (readPatchRow/currentBundleRows output).
96
+ * @returns {{action: string, reason: string, bash?: {disabled: boolean, shellPath?: string}, pwsh?: {disabled: boolean}}}
97
+ */
98
+ export function planTerminalBundleAdopt(input) {
99
+ const { platform, enabled, mounted, resolved, rows } = input ?? {}
100
+ if (platform !== 'win32') {
101
+ return { action: 'skip-platform', reason: 'the persistent terminal bundle keeps its own POSIX bash backend; nothing to adopt off-Windows' }
102
+ }
103
+ if (mounted !== true) {
104
+ return { action: 'bundle-absent', reason: 'the persistent terminal bundle is not mounted (or its tool row is disabled) — nothing to adopt' }
105
+ }
106
+ if (enabled !== true) {
107
+ return { action: 'skip-disabled', reason: 'autoTerminalShell is off — the bundle keeps its official shell backends' }
108
+ }
109
+ if (typeof resolved !== 'string' || resolved.trim() === '') {
110
+ return { action: 'skip-unresolved', reason: 'no verified Git Bash executable on this host — the bundle keeps its official backends' }
111
+ }
112
+ const shellPath = resolved.trim()
113
+ // `rows` carries the FLATTENED facts (readPatchRow/currentBundleRows output):
114
+ // { disabled?, shellPath? } — not the raw patch row with its config wrapper.
115
+ const bash = rows?.bash
116
+ const pwsh = rows?.pwsh
117
+ if (bash === undefined) {
118
+ // No user/bundle override row yet: write ours, and disable the pwsh backend
119
+ // ONLY when no row of its own exists (a user-written row is never touched).
120
+ const pwshWrite = pwsh === undefined ? { disabled: true } : undefined
121
+ return {
122
+ action: 'adopt',
123
+ reason: pwshWrite
124
+ ? 'writing the persistent-terminal overrides: bash backend on the verified Git Bash, pwsh backend off'
125
+ : 'writing the persistent-terminal bash-backend override on the verified Git Bash; a pwsh-backend row already exists and is left alone',
126
+ bash: { disabled: false, shellPath },
127
+ ...(pwshWrite ? { pwsh: pwshWrite } : {}),
128
+ }
129
+ }
130
+ if (bash.disabled === true) {
131
+ return { action: 'kept-user-choice', reason: 'the profile patch explicitly disables the persistent bash backend — not touching an explicit user choice' }
132
+ }
133
+ const configured = typeof bash.shellPath === 'string' ? bash.shellPath.trim() : ''
134
+ if (configured === '') {
135
+ // Row exists, backend on, but shellPath left at the bundle's POSIX default
136
+ // (/bin/bash) — that cannot work on Windows. Completing it is not a user
137
+ // override: the field is EMPTY.
138
+ return {
139
+ action: 'update-shellpath',
140
+ reason: 'the bash backend row is enabled but shellPath is unset (the bundle default /bin/bash cannot work on Windows); completing it with the verified Git Bash',
141
+ bash: { disabled: false, shellPath },
142
+ }
143
+ }
144
+ if (normalizeBashPath(configured) !== normalizeBashPath(shellPath)) {
145
+ return { action: 'kept-user-choice', reason: `the persistent bash backend already points at "${configured}" — not overwriting an explicit user choice` }
146
+ }
147
+ if (pwsh !== undefined && pwsh.disabled !== true) {
148
+ return { action: 'unchanged', reason: 'the persistent terminal already runs the verified Git Bash; the pwsh backend row is also enabled (a user choice we do not touch)' }
149
+ }
150
+ return { action: 'unchanged', reason: 'the persistent terminal already runs the verified Git Bash (bash row on it, pwsh backend off)' }
151
+ }
152
+
153
+ /**
154
+ * Extract the current override rows for our two ids from a parsed patch document.
155
+ * @param {object} yaml - the eemeli yaml module (isMap/getIn surface).
156
+ * @param {object} document - a yaml parseDocument result.
157
+ * @returns {{bash?: object, pwsh?: object}}
158
+ */
159
+ export function currentBundleRows(yaml, document) {
160
+ const out = {}
161
+ const items = document?.contents?.items
162
+ if (!Array.isArray(items)) return out
163
+ const read = (id) => {
164
+ const index = findRow(yaml, document, id)
165
+ if (index < 0) return undefined
166
+ const item = items[index]
167
+ const row = {}
168
+ const disabled = item.getIn?.(['disabled'])
169
+ if (disabled === true || disabled === false) row.disabled = disabled
170
+ const shellPath = item.getIn?.(['config', 'shellPath'])
171
+ if (typeof shellPath === 'string') row.shellPath = shellPath
172
+ return row
173
+ }
174
+ const bash = read(TERMINAL_BUNDLE_BASH_ROW)
175
+ if (bash !== undefined) out.bash = bash
176
+ const pwsh = read(TERMINAL_BUNDLE_PWSH_ROW)
177
+ if (pwsh !== undefined) out.pwsh = pwsh
178
+ return out
179
+ }
180
+
181
+ /**
182
+ * Last matching row index for an id (later rows win, mirroring the host patch
183
+ * algorithm); skips insert rows, same guards as config-editor's lookup.
184
+ * @returns {number} index or -1.
185
+ */
186
+ export function findRow(yaml, document, id) {
187
+ const items = document?.contents?.items
188
+ if (!Array.isArray(items)) return -1
189
+ for (let i = items.length - 1; i >= 0; i -= 1) {
190
+ const item = items[i]
191
+ if (!yaml.isMap?.(item)) continue
192
+ if (item.getIn?.(['insert']) !== undefined) continue
193
+ if (item.getIn?.(['id']) !== id) continue
194
+ return i
195
+ }
196
+ return -1
197
+ }
198
+
199
+ /**
200
+ * Rewrite the profile patch file so the official persistent terminal runs the
201
+ * verified Git Bash. Reads, plans, writes ONLY on adopt/update-shellpath,
202
+ * re-parses its own output as a self-check, writes atomically, verifies by
203
+ * re-reading and rolls back to the previous bytes on any mismatch.
204
+ * @param {{readFile: Function, writeFileAtomic: Function, yaml: Function}} io - injected IO (fs + yaml loader).
205
+ * @param {{platform?: string, enabled?: boolean, mounted?: boolean, resolved?: string, patchPath: string}} input
206
+ * @returns {Promise<{action: string, status: string, reason?: string, shellPath?: string, pwshRowWritten?: boolean}>}
207
+ */
208
+ export async function adoptTerminalBundlePatch(io, input) {
209
+ const done = (action, status, reason, extra = {}) => ({ action, status, reason, ...extra })
210
+ const { patchPath } = input
211
+ let yamlModule
212
+ try {
213
+ yamlModule = await io.yaml()
214
+ } catch (error) {
215
+ return done('skip-modules', 'skip-modules', undefined, { detail: `the yaml document module is unavailable (${error?.message ?? error})` })
216
+ }
217
+ if (typeof yamlModule?.parseDocument !== 'function' || typeof yamlModule?.isSeq !== 'function' || typeof yamlModule?.isMap !== 'function') {
218
+ return done('skip-modules', 'skip-modules', undefined, { detail: 'the yaml document module lacks parseDocument/isSeq/isMap' })
219
+ }
220
+
221
+ let before
222
+ try {
223
+ before = await io.readFile(patchPath)
224
+ } catch (error) {
225
+ if (error?.code !== 'ENOENT') return done('skip-read', 'skip-read', undefined, { detail: `the profile patch could not be read (${error?.message ?? error})` })
226
+ before = '[]\n'
227
+ }
228
+
229
+ const parse = (text) => yamlModule.parseDocument(text, {
230
+ customTags: [{ tag: 'tag:yaml.org,2002:js', resolve: (value) => value }],
231
+ })
232
+
233
+ const document = parse(before)
234
+ if (Array.isArray(document?.errors) && document.errors.length > 0) {
235
+ return done('skip-parse', 'skip-parse', undefined, { detail: 'the profile patch has YAML errors; refusing to edit a broken document' })
236
+ }
237
+ if (!yamlModule.isSeq(document?.contents)) {
238
+ return done('skip-shape', 'skip-shape', undefined, { detail: 'the profile patch is not a YAML sequence; refusing to edit an unknown shape' })
239
+ }
240
+
241
+ // The plan runs AFTER the current rows are known: it decides between
242
+ // writing and standing down based on what the profile patch already says.
243
+ const rows = currentBundleRows(yamlModule, document)
244
+ const plan = planTerminalBundleAdopt({ ...input, rows })
245
+ if (plan.action !== 'adopt' && plan.action !== 'update-shellpath') {
246
+ return done(plan.action, plan.action, plan.reason)
247
+ }
248
+
249
+ // Explicit QUOTE_DOUBLE scalars: the patch stays human-readable and the
250
+ // path keeps its quotes (the plain style a bare createNode produces is
251
+ // valid but nothing like what a hand-written row looks like).
252
+ const quotedScalar = (value) => {
253
+ const node = new yamlModule.Scalar(value)
254
+ node.type = 'QUOTE_DOUBLE'
255
+ return node
256
+ }
257
+ const newMap = (...pairs) => {
258
+ const map = document.createNode({})
259
+ for (const [key, value] of pairs) map.set(key, value)
260
+ return map
261
+ }
262
+
263
+ // An empty patch file parses as a FLOW sequence ([]); once rows are added
264
+ // it must print in block style or every new row comes out one-line flow.
265
+ document.contents.flow = false
266
+
267
+ // A brand-new row is added by parsing a handwritten-looking FRAGMENT and
268
+ // pushing its node: what lands on disk then reads exactly like the row a
269
+ // careful human would write (block style, quoted path), instead of the
270
+ // flow-style shape a bare createNode serializes to.
271
+ const rowFromFragment = (text) => {
272
+ const fragment = parse(text)
273
+ if (Array.isArray(fragment?.errors) && fragment.errors.length > 0) return undefined
274
+ return fragment?.contents?.items?.[0]
275
+ }
276
+ const bashIndex = findRow(yamlModule, document, TERMINAL_BUNDLE_BASH_ROW)
277
+ const bashNode = plan.bash
278
+ if (bashIndex >= 0) {
279
+ document.setIn([bashIndex, 'disabled'], bashNode.disabled)
280
+ document.setIn([bashIndex, 'config'], newMap(['shellPath', quotedScalar(bashNode.shellPath)]))
281
+ } else {
282
+ const row = rowFromFragment(`- id: ${TERMINAL_BUNDLE_BASH_ROW}\n disabled: ${bashNode.disabled}\n config:\n shellPath: ${JSON.stringify(bashNode.shellPath)}\n`)
283
+ if (row === undefined) {
284
+ return done(plan.action, 'write-failed', plan.reason, { detail: 'the new bash row fragment did not parse; nothing was written' })
285
+ }
286
+ document.add(row)
287
+ }
288
+ let pwshRowWritten = false
289
+ if (plan.pwsh && findRow(yamlModule, document, TERMINAL_BUNDLE_PWSH_ROW) < 0) {
290
+ const row = rowFromFragment(`- id: ${TERMINAL_BUNDLE_PWSH_ROW}\n disabled: ${plan.pwsh.disabled}\n`)
291
+ if (row === undefined) {
292
+ return done(plan.action, 'write-failed', plan.reason, { detail: 'the new pwsh row fragment did not parse; nothing was written' })
293
+ }
294
+ document.add(row)
295
+ pwshRowWritten = true
296
+ }
297
+
298
+ // Self-check before touching disk: the serialized document must re-parse clean.
299
+ const serialized = String(document)
300
+ const check = parse(serialized)
301
+ if (Array.isArray(check?.errors) && check.errors.length > 0) {
302
+ return done(plan.action, 'write-failed', plan.reason, { detail: 'the edited patch did not re-parse cleanly; nothing was written' })
303
+ }
304
+ if (findRow(yamlModule, check, TERMINAL_BUNDLE_BASH_ROW) < 0) {
305
+ return done(plan.action, 'write-failed', plan.reason, { detail: 'the edited patch lost the bash row during serialization; nothing was written' })
306
+ }
307
+
308
+ try {
309
+ await io.writeFileAtomic(patchPath, serialized)
310
+ } catch (error) {
311
+ return done(plan.action, 'write-failed', plan.reason, { detail: `the atomic write failed (${error?.message ?? error}); the previous patch bytes stand` })
312
+ }
313
+
314
+ // Verify by re-reading what actually landed on disk.
315
+ try {
316
+ const reread = parse(await io.readFile(patchPath))
317
+ const rows = currentBundleRows(yamlModule, reread)
318
+ const bash = rows.bash
319
+ const pathOk = bash !== undefined
320
+ && bash.disabled === false
321
+ && typeof bash.shellPath === 'string'
322
+ && normalizeBashPath(bash.shellPath) === normalizeBashPath(bashNode.shellPath)
323
+ const pwshOk = !pwshRowWritten || readPatchRow(rows.pwsh)?.disabled === true
324
+ if (!pathOk || !pwshOk) {
325
+ await io.writeFileAtomic(patchPath, before)
326
+ return done(plan.action, 'write-failed', plan.reason, { detail: 'the written patch did not read back as intended; rolled back to the previous bytes' })
327
+ }
328
+ } catch (error) {
329
+ try { await io.writeFileAtomic(patchPath, before) } catch { /* report the primary failure */ }
330
+ return done(plan.action, 'write-failed', plan.reason, { detail: `the verification read failed (${error?.message ?? error}); rolled back to the previous bytes` })
331
+ }
332
+
333
+ return done(plan.action, plan.action === 'adopt' ? 'adopted' : 'updated', plan.reason, { shellPath: bashNode.shellPath, pwshRowWritten })
334
+ }
335
+
336
+ /**
337
+ * One line per outcome, terminal-shell style.
338
+ * @param {{action: string, status: string, reason?: string, detail?: string, shellPath?: string}} result
339
+ * @returns {string}
340
+ */
341
+ export function terminalBundleReport(result) {
342
+ if (!result || typeof result !== 'object') return 'persistent terminal adoption returned nothing'
343
+ if (typeof result.detail === 'string' && result.detail !== '') return `persistent terminal: ${result.status} — ${result.detail}`
344
+ if (typeof result.reason === 'string' && result.reason !== '') return `persistent terminal: ${result.reason}`
345
+ return `persistent terminal: ${result.status}`
346
+ }
@@ -290,9 +290,16 @@ export function isSameOriginValue(value, host) {
290
290
  /**
291
291
  * The fence for the tools route. Installing is a side effect, so:
292
292
  * - the peer must be loopback (a LAN or tunnel client never gets to install);
293
- * - a mutating request must carry an Origin or Referer that matches Host.
293
+ * - a mutating request that CARRIES an Origin or Referer must match Host.
294
294
  * A same-origin GET from the settings page passes; a form/fetch posted by any
295
295
  * other page fails on the origin check.
296
+ * A mutating request with NO origin header passes the origin half (issue #33):
297
+ * the desktop Electron renderer's same-origin fetch sends no Origin/Referer,
298
+ * and browsers attach Origin to every mutating request — same-site and
299
+ * cross-site alike — so a missing header cannot be web-page CSRF; it can only
300
+ * come from a non-browser client, which could forge a same-origin header just
301
+ * as easily. The loopback requirement stays (the real fence) and a carried
302
+ * cross-origin value is still refused.
296
303
  * @param {{ method?: string, headers?: object, socket?: object }} req
297
304
  * @returns {{ ok: boolean, reason: string }}
298
305
  */
@@ -304,7 +311,7 @@ export function fenceToolRequest(req) {
304
311
  const headers = (req && req.headers) || {}
305
312
  const host = headers.host || ''
306
313
  const origin = headers.origin || headers.referer || ''
307
- if (origin === '') return { ok: false, reason: 'no-origin' }
314
+ if (origin === '') return { ok: true, reason: '' }
308
315
  if (!isSameOriginValue(origin, host)) return { ok: false, reason: 'cross-origin' }
309
316
  return { ok: true, reason: '' }
310
317
  }