dsh-gitbash-shell 0.41.0 → 0.42.0

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.42.0 — 2026-10-19
7
+
8
+ ### feat:dsh-ops 的 ops_bash 从模型 face 彻底移除(可见面,不再只是拒绝);插件停用/卸载自动还原该配置
9
+
10
+ - **为什么 v0.41 的 deny 不够**:宿主 tools.restrict({deny}) 只过滤**继承面**(全局层+祖先层;core/tools view() 源码),而 ops_bash 由 dsh-ops 注册进每个 agent 的**自有层**——自有层注册明确在过滤范围之外,restrict 对其名甚至直接报「names unknown global tool」。所以 v0.41 的 deny 注定只剩 pre-execute 守卫兜底:工具仍可见、调用才被拒。用户要求「直接看不见,不增加模型心智负担」⇒ 可见面移除唯一路径 = 让 dsh-ops 不注册。
11
+ - **机制**:dsh-ops 自带精确开关 publishBashTool(config 键,false ⇒ mountSessionBash 首行返回、resolveShells 报 disabled、提示词工具表自动少一行)。本插件经官方 configEditor 通道把 dsh-ops-shell 行的该键写为 false(**不碰其余键、不碰别人的设置**),ops_bash 从模型 face 消失;ops 的 file / 后台 / PTC 增强不受影响。无设置开关——产品语义:本插件在场,bash 归本插件;想用 ops_bash 就停用本插件(还原是自动的)。
12
+ - **生命周期还原(本次主诉求)**:写 false 的同时在本插件 fiber 上注册 dispose 钩子——**停用或卸载**插件的瞬间,覆盖被撤回:override 键删除(无继承值)或赋回继承值(有),同 row 里用户自己的其它覆盖原样保留;还原后回读验证。唯一盖不到的残余:关机状态下卸载(进程已不在,钩子没机会跑)⇒ dsh-ops 设置卡「恢复默认」一键可撤,README 已写明。若 patch 覆盖是用户后来手写的 true(我们的 false 之后),停用时**不动它**——那是用户的选择,不是我们的残余。
13
+ - **为什么写 true 的用户选择不还原、而我们的 false 要还原**:还原钩子只在「移除确实生效(adopted/present)」后才注册;运行时无法区分「present 的 false 是我们上次写的还是用户手写的」,保守取「撤掉」,README 说明边界。
14
+ - **写通道与安全规则**:与侧栏终端接管同一纪律(官方 configEditor.edit,禁 fs 直写 patch;本插件模块守卫测试断言 ops-adopt.js 零 fs 面);edit 失败(如「overridden by a home patch」)如实报 write-failed 并给出手工路径,绝不谎报成功;每次写后回读,读到 false 才算 adopted。
15
+ - 验证:tests/ops-adopt.mjs 11 项全绿(计划矩阵 7 态 / adopted 回读 / 三类 no-write 零编辑 / write-failed×2 / restore 继承回赋与删键 / 用户后选 true 不动 / 报告文案含安全网指引 / 模块守卫 / index.js 接线守卫),npm test 全套纳入该文件。
16
+ ## v0.41.1 — 2026-10-19
17
+
18
+ ### fix:桌面版设置页「安装 / 升级选中」无反应(栅栏 no-origin 误伤 Electron + 前端静默吞 403)
19
+
20
+ - 报告:[issue #33](https://github.com/KannaKuron/dsh-gitbash-shell/issues/33)(@AncherEven,报告自带完整根因定位,感谢)。dsh 桌面版(Electron)设置 → 插件 → 终端工具,点「安装 / 升级选中」不报错、不出进度、按钮复原。
21
+ - **fix(栅栏侧,tool-runner.js)**:`fenceToolRequest` 原要求改写类请求必须携带同源 Origin/Referer,而桌面版 Electron 渲染进程的同源 fetch **不携带**这两个头 → 恒 403 `no-origin`。现改为:缺 Origin 时放行 origin 半(信任围栏的真防线是 loopback 限定,保持不变);**携带** Origin/Referer 时仍必须同源,跨站一律拒。安全性论证:浏览器对一切改写请求(同站与跨站)都附加 Origin,缺头请求不可能来自网页 CSRF,只能来自非浏览器客户端——而后者本就能伪造同源头,栅栏对其原本就不构成额外防线。
22
+ - **fix(前端侧,client.js)**:`runTools()` 对非 2xx 响应直接 `res.json()` 解析错误体、取不到 `job` 静默吞掉;现与 `loadTools()` 一致先判 `res.ok`,栅栏拒绝等失败会显示「HTTP 403」类错误而不是无反应。
23
+ - 验证:`npm test` 263 项全绿;栅栏测试矩阵更新(缺头 POST 放行 / 携带跨源 POST 仍拒 / LAN 携带同源头仍 not-loopback / 前端 res.ok 守卫断言)。
24
+
6
25
  ## v0.41.0 — 2026-10-19
7
26
 
8
27
  ### feat:官方「持久终端」实验包 Git Bash 化自动接管;dsh-ops 并存时剔除其 shell 工具;terminal_* 方言说明
package/README.md CHANGED
@@ -47,6 +47,16 @@ POSIX 宿主上完整保留;
47
47
  `/c/` 方言统一:`ops_*` 的入参(含 `root`/`operations[].path`/`protect`)
48
48
  自动转换、返回与报错路径自动回流 `/c/`。
49
49
 
50
+ - **与 dsh-ops 并存:bash 只留一份(v0.42.0)**:宿主机制决定「可见但拒绝」到不了「看不见」——
51
+ `tools.restrict` 只能过滤继承面,而 `ops_bash` 注册在 agent 自有层。于是本插件直接接管 dsh-ops
52
+ 自带的 `publishBashTool` 开关(经官方 configEditor 通道写为 `false`):安装本插件时 `ops_bash`
53
+ 不再出现在模型工具面;**ops 的 pwsh7 overlay(给官方 pwsh 工具供路径)与文件/后台增强不受影响**
54
+ ——publishBashTool 的门只套在 `mountSessionBash` 上,pwsh 走 `mountBundledPwsh` 独立路径。
55
+ **停用或卸载本插件的瞬间自动还原**(dispose 钩子撤回覆盖,同 row 的其它设置原样保留),重新启用
56
+ 又会自动接管。两个边界:①关机状态下卸载的残余覆盖,用 dsh-ops 设置卡「恢复默认」一键撤回;
57
+ ②停用时若覆盖值已被你手动改回 `true`,视为你的选择、不动它。无设置开关——想用 `ops_bash`
58
+ 就停用本插件,还原是自动的。
59
+
50
60
  四个「· Git Bash」**变体 preset 仍保留,默认关闭**(`variantPresets` 开关,
51
61
  设置卡可开):开启后注册 `standard-gitbash`/`minimal-gitbash`/`code-gitbash`/
52
62
  `cordis-gitbash` 四个变体并停用官方模式的工具补位(两者互斥)。
package/dsh.plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "dsh-external/dsh-gitbash-shell",
3
- "version": "0.41.0",
3
+ "version": "0.42.0",
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.41.0",
3
+ "version": "0.42.0",
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": {
@@ -57,7 +57,7 @@
57
57
  "icon.svg"
58
58
  ],
59
59
  "scripts": {
60
- "test": "node --test tests/smoke.mjs tests/private-executor.mjs tests/client-regressions.mjs tests/bridge-regressions.mjs tests/runtime-regressions.mjs tests/host-regressions.mjs tests/compatibility-regressions.mjs tests/path-tool.mjs tests/native-output.mjs tests/native-executor.mjs",
60
+ "test": "node --test tests/smoke.mjs tests/private-executor.mjs tests/client-regressions.mjs tests/bridge-regressions.mjs tests/runtime-regressions.mjs tests/host-regressions.mjs tests/compatibility-regressions.mjs tests/path-tool.mjs tests/native-output.mjs tests/native-executor.mjs tests/ops-adopt.mjs",
61
61
  "test:preview": "node --import ./tests/host-loader.mjs --import ./tests/preview-env.mjs --test tests/preview.mjs tests/bridge-preview.mjs tests/runtime-preview.mjs tests/host-preview.mjs tests/path-preview.mjs tests/native-output-preview.mjs tests/native-preview.mjs tests/native-integration-preview.mjs",
62
62
  "version": "node -e \"const fs=require('fs');const m=JSON.parse(fs.readFileSync('dsh.plugin.json','utf8'));const v=JSON.parse(fs.readFileSync('package.json','utf8')).version;m.version=v;fs.writeFileSync('dsh.plugin.json',JSON.stringify(m,null,2)+'\\n')\" && git add dsh.plugin.json"
63
63
  },
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
@@ -301,6 +301,12 @@ import {
301
301
  effectiveConfiguredBashPath, executorConfiguredBashPath, resolveGitBashCached,
302
302
  } from './bash-path.js'
303
303
  import { adoptTerminalShell, terminalAdoptReport } from './terminal-shell.js'
304
+ import {
305
+ adoptOpsBashRemoval,
306
+ opsAdoptReport,
307
+ opsRestoreReport,
308
+ restoreOpsBash,
309
+ } from './ops-adopt.js'
304
310
  import {
305
311
  clearToolJob, currentToolJob, defaultRunnerIo, fenceToolRequest,
306
312
  probeToolchain, readJsonBody, startToolJob,
@@ -2730,6 +2736,57 @@ export async function apply(ctx, config = {}) {
2730
2736
  }
2731
2737
  }
2732
2738
 
2739
+ // ── dsh-ops bash removal from the agent face (v0.42.0) ────────────────
2740
+ // ops_bash registers in each agent's OWN layer; the host restrict() only
2741
+ // filters INHERITED surfaces (core/tools view()), so "visible but denied"
2742
+ // (v0.41.0) can never become hidden — dsh-ops must simply not register
2743
+ // the tool. It ships the precise switch (publishBashTool); we hold it
2744
+ // false ONLY while this plugin is mounted, through the official
2745
+ // configEditor channel, and the dispose hook below lifts the override
2746
+ // when our fiber stops (disable / uninstall => dsh-ops gets its bash
2747
+ // back). See src/ops-adopt.js for the full rationale and safety rails.
2748
+ if (process.platform === 'win32') {
2749
+ try {
2750
+ ctx.inject(['configEditor'], (opsEditorCtx) => {
2751
+ // The dsh-ops rows may activate after this plugin; retry a few
2752
+ // times (bounded) instead of silently giving up, like the terminal row.
2753
+ let attempts = 0
2754
+ let watched = false
2755
+ const run = async () => {
2756
+ attempts += 1
2757
+ let result
2758
+ try {
2759
+ result = await adoptOpsBashRemoval(opsEditorCtx.configEditor)
2760
+ } catch (error) {
2761
+ console.log(`${TAG} dsh-ops bash removal crashed: ${error?.message ?? error}`)
2762
+ return
2763
+ }
2764
+ if (result.status === 'skip-row-absent' && attempts < 6) {
2765
+ setTimeout(() => { run().catch((error) => console.log(`${TAG} dsh-ops bash removal retry failed: ${error?.message ?? error}`)) }, 3000)
2766
+ return
2767
+ }
2768
+ console.log(`${TAG} ${opsAdoptReport(result)}`)
2769
+ // Restore wiring: only once the removal is actually in force.
2770
+ // The hook is idempotent (a second run finds nothing to lift),
2771
+ // so an inject-callback re-run cannot double-lift.
2772
+ if (!watched && (result.status === 'adopted' || result.status === 'present')) {
2773
+ watched = true
2774
+ ctx.effect(() => async () => {
2775
+ try {
2776
+ console.log(`${TAG} ${opsRestoreReport(await restoreOpsBash(opsEditorCtx.configEditor))}`)
2777
+ } catch (error) {
2778
+ console.log(`${TAG} dsh-ops bash restore crashed: ${error?.message ?? error}`)
2779
+ }
2780
+ }, 'gitbash: dsh-ops bash removal restore')
2781
+ }
2782
+ }
2783
+ run().catch((error) => console.log(`${TAG} dsh-ops bash removal failed: ${error?.message ?? error}`))
2784
+ })
2785
+ } catch (error) {
2786
+ console.log(`${TAG} dsh-ops bash removal wiring failed: ${error?.message ?? error}`)
2787
+ }
2788
+ }
2789
+
2733
2790
  // ── OFFICIAL persistent-terminal bundle adoption (v0.41.0) ─────────────
2734
2791
  // The experimental terminal bundle mounts its shell backends behind a
2735
2792
  // bundle patch that on Windows enables the pwsh backend and disables the
@@ -0,0 +1,258 @@
1
+ /**
2
+ * dsh-gitbash-shell — dsh-ops bash removal from the agent face (v0.42.0).
3
+ *
4
+ * THE PROBLEM (user report 2026-10-19, owner decision the same day): with
5
+ * dsh-ops installed, the model sees BOTH a bash tool (ours) and ops_bash
6
+ * (theirs). v0.41.0 answered calls with a pre-execute denial, but the tool
7
+ * stayed VISIBLE — the user: "ops_bash 和 ops_run 直接就看不见了, 不会增加模型
8
+ * 心智负担". Visibility, not denial, is the requirement.
9
+ *
10
+ * WHY DENIAL CANNOT BECOME HIDING (host source, dsh 0.2.1-rc.2, measured):
11
+ * tools.restrict({ deny }) filters only the INHERITED surface — the global
12
+ * layer and every ancestor layer on the scope chain. A scope's OWN
13
+ * registrations sit outside the filter by design ("The scope's own
14
+ * registrations last ... outside the filter above", packages/core/tools
15
+ * src/index.ts view()), and restrict even REFUSES to name them ("names
16
+ * unknown global tool"). dsh-ops publishes ops_bash into each agent's own
17
+ * layer (mountSessionBash -> per-agent registration), so no amount of
18
+ * restricting from our side can hide it. Not registering is the only path.
19
+ *
20
+ * THE SWITCH: dsh-ops ships exactly that, as first-class config —
21
+ * publishBashTool (default true). false => mountSessionBash returns before
22
+ * registering, resolveShells reports the bash rung as 'disabled', and the
23
+ * prompt-policy tool table drops the bash row. Setting it is therefore not a
24
+ * hack into someone else's plugin; it is their own supported configuration.
25
+ *
26
+ * WHY THIS MODULE TALKS TO configEditor (same discipline as
27
+ * terminal-shell.js): the write must survive restarts and must be undoable.
28
+ * configEditor.edit(entry, change) is the channel the official settings UI
29
+ * itself persists through — it lands in the profile patch document (official
30
+ * YAML editor, file-locked, comment-preserving) and change returning the
31
+ * INHERITED config makes the editor DELETE the override row again. That gives
32
+ * us a real restore primitive instead of a hand-rolled file edit.
33
+ *
34
+ * OWNERSHIP AND RESTORE (the owner requirement: uninstalling this plugin must
35
+ * give dsh-ops its bash back):
36
+ * - plugin disabled / uninstalled while dsh runs => our fiber stops => the
37
+ * dispose hook below runs and lifts the override through the same editor;
38
+ * - plugin re-enabled => boot re-adopts (idempotent: the plan is a no-op
39
+ * when the override already reads false);
40
+ * - the one uncovered path is uninstalling while dsh is SHUT DOWN (no fiber
41
+ * ever stops): the override survives, the official settings card shows
42
+ * "已覆盖 / restore defaults" for exactly this, and flipping it back (or
43
+ * re-installing and disabling us) restores the row. Documented in the
44
+ * README; nothing else can be done from inside a stopped process.
45
+ *
46
+ * PRODUCT DECISION (owner, 2026-10-19): while this plugin is installed, the
47
+ * bash tool is OURS — dsh-ops is kept for its file, search and background
48
+ * enhancements only, and there is NO settings switch for this behavior. A
49
+ * user who flips publishBashTool back to true in the settings card is
50
+ * overridden again on our next boot; the supported way to use ops_bash is to
51
+ * disable this plugin (which also restores the value — the dispose hook).
52
+ *
53
+ * HARD RULES:
54
+ * 1. NEVER write when the row is absent — no dsh-ops, nothing to remove.
55
+ * 2. NEVER hand-edit the profile patch file — the official editor only.
56
+ * 3. NEVER claim success without reading the value back.
57
+ * 4. Restore returns the INHERITED value for OUR key only: unrelated
58
+ * overrides in the same row (the user's own dsh-ops settings) survive.
59
+ */
60
+
61
+ /** The Loader row id of the dsh-ops shell component. */
62
+ export const OPS_SHELL_ROW_ID = 'dsh-ops-shell'
63
+
64
+ /**
65
+ * The effective publishBashTool of the row: the profile patch override wins
66
+ * over the inherited layer value; a missing key means the dsh-ops default
67
+ * (true — it publishes). Treats malformed shapes as the default so a broken
68
+ * hand edit cannot make us believe there is a decision to respect.
69
+ * @param {object} row - one configEditor.configuration() entry.
70
+ * @returns {boolean} the effective value.
71
+ */
72
+ export function effectivePublishBashTool(row) {
73
+ const override = row !== null && row !== undefined && row.override !== null && row.override !== undefined
74
+ ? row.override.publishBashTool
75
+ : undefined
76
+ const inherited = row !== null && row !== undefined && row.inherited !== null && row.inherited !== undefined
77
+ ? row.inherited.publishBashTool
78
+ : undefined
79
+ if (typeof override === 'boolean') return override
80
+ if (typeof inherited === 'boolean') return inherited
81
+ return true
82
+ }
83
+
84
+ /**
85
+ * Decide what to do about ops_bash. Pure: every fact arrives as an argument,
86
+ * so the whole matrix (absent / already overridden / off at the source /
87
+ * to-adopt) is unit-testable anywhere.
88
+ * @param {object|undefined} row - the dsh-ops-shell row from
89
+ * configEditor.configuration(), or undefined when not mounted.
90
+ * @returns {{action: string, reason: string}} the plan.
91
+ */
92
+ export function planOpsAdopt(row) {
93
+ if (row === undefined || row === null) {
94
+ return { action: 'skip-row-absent', reason: 'the dsh-ops shell row is not mounted in this profile' }
95
+ }
96
+ const overrideValue = row.override !== null && row.override !== undefined ? row.override.publishBashTool : undefined
97
+ if (typeof overrideValue === 'boolean' && overrideValue === false) {
98
+ return { action: 'present', reason: 'the row override already sets publishBashTool=false' }
99
+ }
100
+ if (typeof overrideValue !== 'boolean' && effectivePublishBashTool(row) === false) {
101
+ return { action: 'unchanged-inherited-off', reason: 'publishBashTool is false at the configuration source (the dsh-ops settings); nothing to override' }
102
+ }
103
+ return {
104
+ action: 'adopt',
105
+ reason: 'the dsh-ops bash would run next to ours; the owner decision is one bash per face and it is ours',
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Find the dsh-ops shell row in an editor view, or undefined.
111
+ * @param {object} editor - ctx.configEditor (or a stand-in in tests).
112
+ * @returns {object|undefined} the row.
113
+ */
114
+ function opsRowOf(editor) {
115
+ const rows = editor.configuration()
116
+ return Array.isArray(rows) ? rows.find((candidate) => candidate.entry?.options?.id === OPS_SHELL_ROW_ID) : undefined
117
+ }
118
+
119
+ /**
120
+ * Apply the plan through the official config editor, then READ THE VALUE BACK.
121
+ * @param {object} editor - ctx.configEditor (or a stand-in in tests).
122
+ * @returns {Promise<{status: string, detail?: string}>} the outcome; the caller
123
+ * logs it — this function never lies about success and never throws for an
124
+ * expected condition.
125
+ */
126
+ export async function adoptOpsBashRemoval(editor) {
127
+ if (editor === null || editor === undefined || typeof editor.configuration !== 'function') {
128
+ return { status: 'skip-row-absent', detail: 'the config editor is unavailable, so the dsh-ops row cannot be configured' }
129
+ }
130
+ const row = opsRowOf(editor)
131
+ const plan = planOpsAdopt(row)
132
+ if (plan.action !== 'adopt') return { status: plan.action, detail: plan.reason }
133
+ if (typeof editor.edit !== 'function') {
134
+ return { status: 'write-failed', detail: 'the config editor exposes no edit()' }
135
+ }
136
+ try {
137
+ await editor.edit(row.entry, (raw) => ({ ...raw, publishBashTool: false }))
138
+ } catch (error) {
139
+ return { status: 'write-failed', detail: 'the official config editor refused the write: ' + (error && error.message ? error.message : String(error)) }
140
+ }
141
+ // Read back: edit() resolving is NOT proof the row now carries our value.
142
+ let after
143
+ try {
144
+ after = opsRowOf(editor)
145
+ } catch (error) {
146
+ return { status: 'write-failed', detail: 'the write succeeded but reading it back failed: ' + (error && error.message ? error.message : String(error)) }
147
+ }
148
+ if (after === undefined || after.override === null || after.override === undefined || after.override.publishBashTool !== false) {
149
+ return {
150
+ status: 'write-failed',
151
+ detail: 'the write did not stick: the dsh-ops row does not read publishBashTool=false'
152
+ + (after === undefined ? ' (the row vanished during the write)' : ''),
153
+ }
154
+ }
155
+ return { status: 'adopted' }
156
+ }
157
+
158
+ /**
159
+ * Lift our override so dsh-ops follows its own settings again. Runs from the
160
+ * fiber dispose hook (plugin disabled / uninstalled while dsh runs). Only OUR
161
+ * key is touched: unrelated overrides in the same row survive.
162
+ * @param {object} editor - ctx.configEditor (or a stand-in in tests).
163
+ * @returns {Promise<{status: string, detail?: string}>} the outcome.
164
+ */
165
+ export async function restoreOpsBash(editor) {
166
+ if (editor === null || editor === undefined || typeof editor.configuration !== 'function') {
167
+ return { status: 'restore-failed', detail: 'the config editor is unavailable, so the override cannot be lifted' }
168
+ }
169
+ const row = opsRowOf(editor)
170
+ if (row === undefined) {
171
+ return { status: 'skip-row-absent', detail: 'the dsh-ops shell row is no longer mounted — nothing to restore' }
172
+ }
173
+ const overrideValue = row.override !== null && row.override !== undefined ? row.override.publishBashTool : undefined
174
+ if (typeof overrideValue !== 'boolean') {
175
+ return { status: 'unchanged', detail: 'no publishBashTool override to lift' }
176
+ }
177
+ if (overrideValue !== false) {
178
+ // Somebody (the user, through the settings card) turned it back ON after
179
+ // us. Lifting that would REPLACE their choice with the inherited default —
180
+ // leave their value exactly where it is.
181
+ return { status: 'unchanged', detail: 'the override reads publishBashTool=true — a later choice of the user, left alone' }
182
+ }
183
+ if (typeof editor.edit !== 'function') {
184
+ return { status: 'restore-failed', detail: 'the config editor exposes no edit()' }
185
+ }
186
+ try {
187
+ await editor.edit(row.entry, (raw, inherited) => {
188
+ const next = { ...raw }
189
+ if (inherited !== null && inherited !== undefined && Object.hasOwn(inherited, 'publishBashTool')) {
190
+ next.publishBashTool = inherited.publishBashTool
191
+ } else {
192
+ delete next.publishBashTool
193
+ }
194
+ return next
195
+ })
196
+ } catch (error) {
197
+ return { status: 'restore-failed', detail: 'the official config editor refused the restore: ' + (error && error.message ? error.message : String(error)) }
198
+ }
199
+ let after
200
+ try {
201
+ after = opsRowOf(editor)
202
+ } catch (error) {
203
+ return { status: 'restore-failed', detail: 'the restore succeeded but reading it back failed: ' + (error && error.message ? error.message : String(error)) }
204
+ }
205
+ const left = after !== undefined && after.override !== null && after.override !== undefined ? after.override.publishBashTool : undefined
206
+ if (typeof left === 'boolean' && left === false) {
207
+ return { status: 'restore-failed', detail: 'the restore did not stick: the row still reads publishBashTool=false' }
208
+ }
209
+ return { status: 'restored' }
210
+ }
211
+
212
+ /**
213
+ * The one-line log for an adoption outcome (the caller prints it). Kept here
214
+ * so the wording stays identical between the boot path and the tests.
215
+ * @param {object} result - an adoptOpsBashRemoval result.
216
+ * @returns {string} the log line, without the plugin tag.
217
+ */
218
+ export function opsAdoptReport(result) {
219
+ switch (result.status) {
220
+ case 'adopted':
221
+ return 'dsh-ops bash removed from the agent face (publishBashTool=false through the official config editor): the host cannot hide own-layer tools, so dsh-ops must simply not register it — disable this plugin any time you want ops_bash back (the removal is lifted automatically)'
222
+ case 'present':
223
+ return 'dsh-ops bash already removed: the dsh-ops-shell row override already reads publishBashTool=false (nothing written)'
224
+ case 'unchanged-inherited-off':
225
+ return 'dsh-ops bash already off at the source: publishBashTool=false in the dsh-ops configuration (nothing written)'
226
+ case 'skip-row-absent':
227
+ return 'dsh-ops bash removal skipped: ' + (result.detail ?? 'the dsh-ops shell row is not mounted')
228
+ case 'write-failed':
229
+ return 'dsh-ops bash removal FAILED — ' + (result.detail ?? '')
230
+ + ' | next steps: ops_bash stays VISIBLE and our pre-execute denial keeps answering its calls;'
231
+ + ' retry by restarting dsh, or set publishBashTool=false on the dsh-ops-shell row by hand'
232
+ + ' (Settings -> the dsh-ops card -> publishBashTool), or turn to the README for the manual patch row.'
233
+ default:
234
+ return 'dsh-ops bash removal skipped: ' + (result.detail ?? result.status)
235
+ }
236
+ }
237
+
238
+ /**
239
+ * The one-line log for a restore outcome (the caller prints it).
240
+ * @param {object} result - a restoreOpsBash result.
241
+ * @returns {string} the log line, without the plugin tag.
242
+ */
243
+ export function opsRestoreReport(result) {
244
+ switch (result.status) {
245
+ case 'restored':
246
+ return 'dsh-ops bash setting restored: the publishBashTool override is lifted, ops_bash follows the dsh-ops settings again'
247
+ case 'unchanged':
248
+ return 'dsh-ops bash restore not needed: ' + (result.detail ?? 'no override of ours to lift')
249
+ case 'skip-row-absent':
250
+ return 'dsh-ops bash restore skipped: ' + (result.detail ?? 'the dsh-ops shell row is gone')
251
+ case 'restore-failed':
252
+ return 'dsh-ops bash restore FAILED — ' + (result.detail ?? '')
253
+ + ' | the dsh-ops-shell override may survive this plugin: lift it by hand'
254
+ + ' (Settings -> the dsh-ops card -> restore defaults) if ops_bash should come back.'
255
+ default:
256
+ return 'dsh-ops bash restore skipped: ' + (result.detail ?? result.status)
257
+ }
258
+ }
@@ -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
  }