dsh-win-pwsh7 0.2.0 → 0.2.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
@@ -1,5 +1,41 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.3 — removable dsh-ops coexistence bridge
4
+
5
+ - Moved the bridge, profile-patch ownership, and pure name planning into `lib/dsh-ops-bridge.js`.
6
+ `index.js` now mounts it in one place; `package.json` ships the module.
7
+ - Enabled coexistence declaratively from `cordis.patch.yml`; the plugin no longer writes an
8
+ enable override into the profile. Turning the switch off writes only a marked disable override.
9
+ - Removal contract: delete the module, the mount line, the config field, the client switch, and its
10
+ gate; the bundle patch disappears with the package. A switch-off override is the only possible
11
+ profile residue and is removed by the plugin's cleanup when it runs.
12
+ - The bundle patch must come after `dsh-ops` in `dsh.profile.bundles`; otherwise the loader warns
13
+ and skips it. The profile keeps `dsh-ops` before `dsh-win-pwsh7`.
14
+
15
+ ## 0.2.2 — fix the dsh-ops bridge freeze
16
+
17
+ - Fixed the 0.2.1 hide/unhide oscillation that could pin the Host main thread when many sessions
18
+ were restored. The bridge now plans from the host's **pre-restriction** `knownNames` /
19
+ `restrictableNames` instead of the post-restriction `visible` set, and filters the deny list to
20
+ names `tools.restrict()` actually accepts. The fallback path latches names it already hid.
21
+ - Added regression checks for the stable plan, the older-host fallback, and the scope-local
22
+ `ops_bash` case that used to throw an unknown-global-tool warning.
23
+ - Restored `dshOpsCompat` to default-on after multi-session validation of the fixed bridge.
24
+ - The configuration page now uses the native `Switch` primitive and native setting-row layout for
25
+ the coexistence toggle; labels and hints no longer expose raw config keys.
26
+
27
+ ## 0.2.1 — dsh-ops coexistence bridge
28
+
29
+ - Added a default-on `dshOpsCompat` configuration row. When dsh-ops is installed, pwsh7 writes
30
+ a marked `dsh-ops-compat` override into the active profile patch and enables it, so the native
31
+ `pwsh` tool is visible again alongside `ops_bash`.
32
+ - The agent bridge re-hides the official `read` / `glob` / `grep` tools when their OPS
33
+ counterparts are visible, plus the duplicate official `bash` / `pwsh_persistent` shell. It
34
+ keeps `pwsh` and `ops_bash` and does nothing when dsh-ops is absent.
35
+ - The compatibility patch needs a DSH restart; turning the switch off removes only the block this
36
+ plugin wrote. Uninstall cleanup is best-effort and leaves at most a harmless compatibility row.
37
+ - Added the plugin-page toggle with its own localized row and hint.
38
+
3
39
  ## 0.2.0 — 优化 Store 版 pwsh7 安装方式
4
40
 
5
41
  优化store版pwsh7安装方式,重构项目前端设计和工程设计,使其符合原生DSH工程习惯和界面风格。
package/README.en.md CHANGED
@@ -25,14 +25,26 @@ default executor then chooses the runtime itself, and the plugin still never wri
25
25
  ## What it does not do
26
26
 
27
27
  - It does not install anything at startup.
28
- - It does not write the executor's `pwshPath` or edit the profile patch.
28
+ - It does not write the executor's `pwshPath`; it only writes a marked dsh-ops compatibility override when coexistence is on.
29
29
  - It does not uninstall a Store-installed PowerShell 7.
30
30
  - It does not auto-detect VPNs or preflight metered connections.
31
31
 
32
+ ## Coexistence with dsh-ops
33
+
34
+ On by default. The `dsh-ops-compat` override in `cordis.patch.yml` owns the enabled state: while
35
+ the dsh-win-pwsh7 bundle is installed and comes after dsh-ops, a DSH restart restores the native
36
+ `pwsh` tool and keeps `ops_bash`. Turning the switch off writes only a marked disable override;
37
+ removing the bundle removes the enable override, and cleanup best-effort removes the disable one.
38
+
39
+ The 0.2.1 bridge planned from the post-restriction visible set, which made it hide and restore the
40
+ same tools in a loop and starved the Host main thread. 0.2.2 plans from the pre-restriction
41
+ `knownNames` / `restrictableNames` and filters names `tools.restrict()` does not accept. The
42
+ multi-session validation passed, so the switch is default-on again.
43
+
32
44
  ## Install this plugin
33
45
 
34
46
  ```powershell
35
- dsh plugin --profile desktop add github:XinSpark/dsh-win-pwsh7#v0.2.0
47
+ dsh plugin --profile desktop add github:XinSpark/dsh-win-pwsh7#v0.2.3
36
48
  # or
37
49
  dsh plugin --profile desktop add dsh-win-pwsh7
38
50
  ```
@@ -70,6 +82,7 @@ The count lives in the current process and resets when DSH restarts.
70
82
  | Key | Default | Meaning |
71
83
  |---|---|---|
72
84
  | `overridePath` | unset | Priority ① explicit `pwsh.exe`. It must spawn and report PowerShell 7+; a failed validation falls back to auto-detection. **It only changes probing and recommendations, not executor wiring.** |
85
+ | `dshOpsCompat` | `true` | Coexist with dsh-ops (on by default): writes and enables its Compatibility mode, hides duplicate official tools, and keeps `pwsh` + `ops_bash`. Requires a restart. |
73
86
 
74
87
  `overridePath` is `volatile()`: the plugin-page configuration section saves it live; it can also be
75
88
  set in the profile patch:
@@ -113,7 +126,8 @@ executor / disable the plugin.
113
126
  ## Plugin page
114
127
 
115
128
  The status section is a native card showing the state, runtime and executor path, with Re-detect
116
- at the top right; the configuration section exposes only `overridePath`.
129
+ at the top right; the configuration section exposes `overridePath` and the default-on "Keep native
130
+ pwsh alongside dsh-ops" native switch.
117
131
 
118
132
  ## Disable or uninstall
119
133
 
package/README.md CHANGED
@@ -20,15 +20,23 @@
20
20
  ## 它不做什么
21
21
 
22
22
  - 不在启动时自动安装。
23
- - 不写执行器的 `pwshPath`,也不修改 profile 补丁。
23
+ - 不写执行器的 `pwshPath`;只会在开启共存时写一条带标记的 dsh-ops 兼容覆盖。
24
24
  - 不卸载由 Store 安装的 PowerShell 7。
25
25
  - 不做 VPN 自动检测,也不做计量连接预检。
26
26
 
27
+ ## 与 dsh-ops 共存
28
+
29
+ 默认开启。启用由 `cordis.patch.yml` 的声明式 `dsh-ops-compat` 覆盖负责:只要 dsh-win-pwsh7 的 bundle 安装且位于 dsh-ops 之后,重启 DSH 后官方 `pwsh` 恢复,`ops_bash` 保留。关闭开关时只写入一条带标记的 disable override;卸载 bundle 后启用覆盖消失,cleanup 会尽力删除 disable override。
30
+
31
+ 0.2.1 的桥接层在计算隐藏集合时读取了限制后的可见工具,会在隐藏/恢复之间振荡并卡死宿主;0.2.2 改为读取限制前的 `knownNames` / `restrictableNames`,并过滤 `tools.restrict()` 不接受的名字。多会话验证通过后已恢复默认开启。
32
+
33
+ 桥接层还会在对应 OPS 工具可见时隐藏官方 `read` / `glob` / `grep`,以及重复的官方 `bash` / `pwsh_persistent`,让工具面保持 `pwsh` + `ops_bash`。桥接实现独立在 `lib/dsh-ops-bridge.js`,删除该模块和入口挂载即可移除。
34
+
27
35
  ## 安装本插件
28
36
 
29
37
  ```powershell
30
38
  # 从 GitHub 安装(推荐锁定 tag)
31
- dsh plugin --profile desktop add github:XinSpark/dsh-win-pwsh7#v0.2.0
39
+ dsh plugin --profile desktop add github:XinSpark/dsh-win-pwsh7#v0.2.3
32
40
  # 或 npm
33
41
  dsh plugin --profile desktop add dsh-win-pwsh7
34
42
  ```
@@ -61,6 +69,7 @@ dsh plugin --profile desktop add dsh-win-pwsh7
61
69
  | 键 | 默认 | 说明 |
62
70
  |---|---|---|
63
71
  | `overridePath` | 未设 | 优先级①的显式 `pwsh.exe`。必须能启动并报告 PowerShell 7+;未通过校验就回落到自动探测。**只影响探测与推荐,不接线**。 |
72
+ | `dshOpsCompat` | `true` | 与 dsh-ops 共存(默认开启):自动写入并启用兼容模式,隐藏重复的官方工具,保留 `pwsh` + `ops_bash`。需要重启。 |
64
73
 
65
74
  `overridePath` 是 `volatile()`:插件页配置区保存即生效;也可以写进 profile 补丁:
66
75
 
@@ -96,7 +105,7 @@ The executor resolves to the version-stable Store family alias: … Nothing to c
96
105
 
97
106
  ## 插件页
98
107
 
99
- 插件页的状态区是原生卡片,显示状态、运行时和执行器路径,右上角有「重新检测」;配置区只有 `overridePath`。
108
+ 插件页的状态区是原生卡片,显示状态、运行时和执行器路径,右上角有「重新检测」;配置区有 `overridePath` 和默认开启的「与 dsh-ops 共存」原生开关。
100
109
 
101
110
  ## 禁用或卸载
102
111
 
package/client.js CHANGED
@@ -12,6 +12,7 @@ window.__ModuleLoader__.load({
12
12
  id: 'dsh-win-pwsh7',
13
13
  factory(require) {
14
14
  const React = require('react')
15
+ const { Switch } = require('@deepseek-ai/dsh-client-ui-primitives')
15
16
  const h = React.createElement
16
17
  const { useEffect, useState, useSyncExternalStore } = React
17
18
 
@@ -96,8 +97,10 @@ window.__ModuleLoader__.load({
96
97
  redetect: 'Re-detect',
97
98
  redetecting: 'Detecting…',
98
99
  card_title: 'Configuration',
99
- field_overridePath: 'Explicit path (overridePath)',
100
- overridePath_hint: 'Priority ① explicit pwsh.exe path. Empty = auto-detect. It must spawn and report PowerShell 7 or newer; the Store family alias is accepted, while a version-numbered Store path is never pinned. This only changes what the plugin probes and recommends — it does not wire the pwsh tool. Use the patch in the status section for that.',
100
+ field_overridePath: 'PowerShell 7 path',
101
+ overridePath_hint: 'Optional explicit pwsh.exe path (`overridePath`). Empty = auto-detect. It must spawn and report PowerShell 7 or newer; the Store family alias is accepted, while a version-numbered Store path is never pinned. This only changes what the plugin probes and recommends — it does not wire the pwsh tool. Use the patch in the status section for that.',
102
+ field_dshOpsCompat: 'Coexist with dsh-ops',
103
+ dshOpsCompat_hint: 'On by default. Keeps the native pwsh tool beside ops_bash by enabling dsh-ops Compatibility and hiding its duplicate official tools. Requires a DSH restart.',
101
104
  save: 'Save',
102
105
  reset: 'Reset',
103
106
  saved: 'Saved {at}',
@@ -138,8 +141,10 @@ window.__ModuleLoader__.load({
138
141
  redetect: '重新检测',
139
142
  redetecting: '检测中…',
140
143
  card_title: '配置',
141
- field_overridePath: '指定路径(overridePath)',
142
- overridePath_hint: '优先级①的显式 pwsh.exe 路径。留空 = 自动探测。必须能启动并报告 PowerShell 7 及以上;Store 家族别名可用,版本号目录永不被 pin。这里只影响插件的探测与推荐,不会给 pwsh 工具接线;接线请用状态区里的补丁。',
144
+ field_overridePath: 'PowerShell 7 路径',
145
+ overridePath_hint: '可选的显式 pwsh.exe 路径(`overridePath`)。留空 = 自动探测。必须能启动并报告 PowerShell 7 及以上;Store 家族别名可用,版本号目录永不被 pin。这里只影响插件的探测与推荐,不会给 pwsh 工具接线;接线请用状态区里的补丁。',
146
+ field_dshOpsCompat: '与 dsh-ops 共存',
147
+ dshOpsCompat_hint: '默认开启。通过启用 dsh-ops 的 Compatibility 模式并隐藏重复的官方工具,让原生 pwsh 与 ops_bash 并存。需要重启 DSH。',
143
148
  save: '保存',
144
149
  reset: '重置',
145
150
  saved: '已保存 {at}',
@@ -181,6 +186,8 @@ window.__ModuleLoader__.load({
181
186
  .dw7-input:focus-visible{outline:none;border-color:var(--dsw-alias-state-business-primary);}
182
187
  .dw7-input:disabled{color:var(--dsw-alias-label-tertiary);cursor:default;}
183
188
  .dw7-hint{margin:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5;}
189
+ .dw7-switch-row{display:flex;align-items:flex-start;gap:16px;min-width:0;}
190
+ .dw7-switch-text{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:4px;}
184
191
  .dw7-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding-top:4px;}
185
192
  .dw7-save{appearance:none;border:1px solid transparent;border-radius:var(--dsw-radius-md);padding:5px 14px;font:inherit;font-size:13px;line-height:1.5;cursor:pointer;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);}
186
193
  .dw7-save:disabled{opacity:.4;cursor:default;}
@@ -415,9 +422,10 @@ window.__ModuleLoader__.load({
415
422
  const unavailable = snap.status === 'unavailable'
416
423
  const served = {
417
424
  overridePath: typeof snap.value?.overridePath === 'string' ? snap.value.overridePath : '',
425
+ dshOpsCompat: snap.value?.dshOpsCompat !== false,
418
426
  }
419
427
  const values = draft ?? served
420
- const dirty = draft !== null && values.overridePath !== served.overridePath
428
+ const dirty = draft !== null && (values.overridePath !== served.overridePath || values.dshOpsCompat !== served.dshOpsCompat)
421
429
  const editable = !loading && !unavailable && snap.writable !== false && !busy
422
430
 
423
431
  async function save() {
@@ -431,6 +439,9 @@ window.__ModuleLoader__.load({
431
439
  } else if (nextPath !== served.overridePath) {
432
440
  ok = await form.set('overridePath', nextPath) && ok
433
441
  }
442
+ if (values.dshOpsCompat !== served.dshOpsCompat) {
443
+ ok = await form.set('dshOpsCompat', values.dshOpsCompat) && ok
444
+ }
434
445
  if (ok) {
435
446
  setDraft(null)
436
447
  setSavedAt(new Date().toLocaleTimeString())
@@ -462,12 +473,27 @@ window.__ModuleLoader__.load({
462
473
  disabled: !editable,
463
474
  placeholder: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe',
464
475
  onChange: (event) => {
465
- setDraft({ overridePath: event.target.value })
476
+ setDraft({ ...values, overridePath: event.target.value })
466
477
  setNote(null)
467
478
  setSavedAt(null)
468
479
  },
469
480
  }),
470
481
  h('p', { className: 'dw7-hint' }, t('overridePath_hint'))),
482
+ h('div', { className: 'dw7-field', key: 'dshOpsCompat' },
483
+ h('div', { className: 'dw7-switch-row' },
484
+ h('div', { className: 'dw7-switch-text' },
485
+ h('div', { className: 'dw7-label' }, t('field_dshOpsCompat')),
486
+ h('p', { className: 'dw7-hint' }, t('dshOpsCompat_hint'))),
487
+ h(Switch, {
488
+ checked: values.dshOpsCompat,
489
+ disabled: !editable,
490
+ label: t('field_dshOpsCompat'),
491
+ onChange: (checked) => {
492
+ setDraft({ ...values, dshOpsCompat: checked })
493
+ setNote(null)
494
+ setSavedAt(null)
495
+ },
496
+ }))),
471
497
  h('div', { className: 'dw7-actions', key: 'actions' },
472
498
  h('button', {
473
499
  className: 'dw7-save',
package/cordis.patch.yml CHANGED
@@ -4,3 +4,9 @@
4
4
  - insert:
5
5
  - id: win-pwsh7
6
6
  name: dsh-win-pwsh7
7
+ # Coexistence override: keep dsh-ops Compatibility on while this bundle is installed. The
8
+ # loader warns and skips the override when dsh-ops is absent; the profile switch writes the
9
+ # matching disable row only when the user turns coexistence off.
10
+ - id: dsh-ops-compat
11
+ name: dsh-ops/compat
12
+ disabled: false
package/index.js CHANGED
@@ -46,6 +46,11 @@ import { Service } from '@deepseek-ai/cordis'
46
46
  import { defineTool } from '@deepseek-ai/dsh-tools'
47
47
  import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
48
48
  import z from '@deepseek-ai/schemastery'
49
+ import {
50
+ ensureDshOpsCompat,
51
+ mountDshOpsBridge,
52
+ removeDshOpsCompatPatch,
53
+ } from './lib/dsh-ops-bridge.js'
49
54
 
50
55
  export const name = 'dsh-win-pwsh7'
51
56
  export const inject = ['subprocess', 'tools', 'configEditor', 'systemPrompt']
@@ -58,9 +63,13 @@ export const inject = ['subprocess', 'tools', 'configEditor', 'systemPrompt']
58
63
  * - id: win-pwsh7
59
64
  * config:
60
65
  * overridePath: 'D:\tools\pwsh\pwsh.exe' # priority ① — validated, must be an ordinary PowerShell 7+
66
+ * dshOpsCompat: true # default; keeps pwsh beside dsh-ops ops_bash
61
67
  */
62
68
  export const Config = z.object({
63
69
  overridePath: z.string().volatile(),
70
+ // Default-on after the multi-session validation of the pre-restriction bridge. Turning it
71
+ // off removes the managed compatibility override; turning it on restores it.
72
+ dshOpsCompat: z.boolean().default(true).volatile(),
64
73
  })
65
74
 
66
75
  /**
@@ -343,6 +352,27 @@ export function apply(ctx, config) {
343
352
  const current = () => readRowConfig(ctx, config)
344
353
  hostContext = ctx
345
354
  const state = runtimeState(ctx)
355
+ const syncCompat = () => {
356
+ void ensureDshOpsCompat(ctx, current()).then((result) => {
357
+ if (result.changed) {
358
+ const action = result.state === 'bundle'
359
+ ? 'delegated to the bundle patch'
360
+ : result.state === 'legacy-order'
361
+ ? 'kept as a profile fallback because the bundle order is reversed'
362
+ : result.state === 'disabled' ? 'disabled in the profile' : 'removed'
363
+ ctx.logger?.info?.('dsh-win-pwsh7: dsh-ops compatibility %s; restart DSH to apply', action)
364
+ }
365
+ }, (error) => {
366
+ ctx.logger?.warn?.('dsh-win-pwsh7: dsh-ops compatibility patch failed: %s', describe(error))
367
+ })
368
+ }
369
+
370
+ ctx.effect(() => {
371
+ syncCompat()
372
+ return () => { removeDshOpsCompatPatch(ctx) }
373
+ }, 'dsh-win-pwsh7: manage the dsh-ops compatibility patch')
374
+
375
+ ctx.effect(() => mountDshOpsBridge(ctx, current), 'dsh-win-pwsh7: hide duplicate official tools while dsh-ops is compatible')
346
376
 
347
377
  ctx.effect(() => {
348
378
  void activationSweep(ctx, current()).then((report) => {
@@ -473,8 +503,8 @@ export function apply(ctx, config) {
473
503
  ctx.effect(() => {
474
504
  if (typeof ctx.on !== 'function') return
475
505
  const disposers = [
476
- ctx.on('app-boot/config-reload', () => { void refreshGate(ctx, current()) }),
477
- ctx.on('plugin-manager/changed', () => { void refreshGate(ctx, current()) }),
506
+ ctx.on('app-boot/config-reload', () => { void refreshGate(ctx, current()); syncCompat() }),
507
+ ctx.on('plugin-manager/changed', () => { void refreshGate(ctx, current()); syncCompat() }),
478
508
  ]
479
509
  return () => {
480
510
  for (const dispose of disposers) dispose?.()
@@ -502,11 +532,13 @@ let hostContext = null
502
532
  function readRowConfig(ctx, fallback) {
503
533
  const merged = {
504
534
  overridePath: typeof fallback?.overridePath === 'string' ? fallback.overridePath : undefined,
535
+ dshOpsCompat: fallback?.dshOpsCompat !== false,
505
536
  }
506
537
  try {
507
538
  const entry = ctx.configEditor.entries().find((candidate) => candidate?.options?.name === 'dsh-win-pwsh7')
508
539
  const values = entry?.options?.config
509
540
  if (isSet(values?.overridePath)) merged.overridePath = String(values.overridePath)
541
+ if (values?.dshOpsCompat !== undefined) merged.dshOpsCompat = values.dshOpsCompat !== false
510
542
  } catch { /* fall back to the captured config */ }
511
543
  return merged
512
544
  }
@@ -0,0 +1,329 @@
1
+ /**
2
+ * Optional dsh-ops coexistence bridge.
3
+ *
4
+ * The bundle patch declares the compatibility override; this module only owns the switch-off
5
+ * override and the agent-scoped hiding of duplicate official tools. Keeping the enable path
6
+ * declarative is what makes the feature removable with the bundle.
7
+ */
8
+ import { readFileSync, renameSync, writeFileSync } from 'node:fs'
9
+ import { join } from 'node:path'
10
+
11
+ const isSet = (value) => typeof value === 'string' && value.length > 0
12
+ const describe = (error) => error instanceof Error ? error.message : String(error)
13
+
14
+ /** Markers for the only profile blocks this plugin may own. */
15
+ const DSH_OPS_PATCH_MARKER = '# dsh-win-pwsh7: dsh-ops compatibility (managed)'
16
+ const DSH_OPS_PATCH_BLOCK = `${DSH_OPS_PATCH_MARKER}\n- id: dsh-ops-compat\n disabled: false\n`
17
+ const DSH_OPS_DISABLE_MARKER = '# dsh-win-pwsh7: dsh-ops compatibility disabled (managed)'
18
+ const DSH_OPS_DISABLE_BLOCK = `${DSH_OPS_DISABLE_MARKER}\n- id: dsh-ops-compat\n disabled: true\n`
19
+
20
+ /**
21
+ * The active profile directory. DSH exposes it to profile-launched processes; the
22
+ * plugin-manager instance carries the same directory as a fallback for hosts that do not.
23
+ * @param ctx - the row's Host context.
24
+ * @returns the profile directory, or `undefined` when it cannot be established safely.
25
+ */
26
+ function profileDirectory(ctx) {
27
+ if (isSet(process.env.DSH_PROFILE_DIR)) return process.env.DSH_PROFILE_DIR
28
+ const manager = ctx.get('pluginManager')
29
+ const directory = manager?.profile?.dir
30
+ return isSet(directory) ? directory : undefined
31
+ }
32
+
33
+ /**
34
+ * Whether dsh-ops is installed in this profile, read through the plugin manager's public
35
+ * bundle view. A missing manager or an unreadable view is "not detected", never an error.
36
+ * @param ctx - the row's Host context.
37
+ * @returns true when the dsh-ops bundle is present.
38
+ */
39
+ async function dshOpsInstalled(ctx) {
40
+ const manager = ctx.get('pluginManager')
41
+ if (manager === undefined || typeof manager.listBundles !== 'function') return false
42
+ try {
43
+ const bundles = await manager.listBundles()
44
+ const ops = Array.isArray(bundles)
45
+ ? bundles.find((bundle) => bundle?.name === 'dsh-ops' && bundle.installed !== false)
46
+ : undefined
47
+ return ops !== undefined && Array.isArray(ops.rows) && ops.rows.some((row) => row?.moduleName === 'dsh-ops/compat')
48
+ } catch {
49
+ return false
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Whether dsh-ops is layered before dsh-win-pwsh7. The bundle override can only target a row
55
+ * inserted by an earlier layer; a reversed order would be warned and skipped by the loader.
56
+ * @param ctx - the row's Host context.
57
+ * @returns true when the order is usable or cannot be established.
58
+ */
59
+ function bundleOrderOk(ctx) {
60
+ const directory = profileDirectory(ctx)
61
+ if (directory === undefined) return true
62
+ try {
63
+ const manifest = JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8'))
64
+ const bundles = manifest?.dsh?.profile?.bundles
65
+ if (!Array.isArray(bundles)) return true
66
+ const ops = bundles.indexOf('dsh-ops')
67
+ const pwsh7 = bundles.indexOf('dsh-win-pwsh7')
68
+ return ops < 0 || pwsh7 < 0 || ops < pwsh7
69
+ } catch {
70
+ return true
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Read and rewrite the profile patch atomically; a failed write leaves the previous file.
76
+ * @param path - the profile patch file.
77
+ * @param transform - receives the current text and returns the next text.
78
+ * @returns true when the file changed.
79
+ */
80
+ function rewriteProfilePatch(path, transform) {
81
+ let current = ''
82
+ try {
83
+ current = readFileSync(path, 'utf8')
84
+ } catch {
85
+ current = ''
86
+ }
87
+ const next = transform(current)
88
+ if (next === current) return false
89
+ const temporary = `${path}.${process.pid}.tmp`
90
+ writeFileSync(temporary, next, 'utf8')
91
+ renameSync(temporary, path)
92
+ return true
93
+ }
94
+
95
+ /**
96
+ * Remove the marked block only; a user's own `dsh-ops-compat` override is not touched.
97
+ * @param text - the profile patch text.
98
+ * @returns the text without this plugin's block.
99
+ */
100
+ function withoutCompatBlock(text) {
101
+ return text
102
+ .replace(/# dsh-win-pwsh7: dsh-ops compatibility \(managed\)\r?\n- id: dsh-ops-compat\r?\n disabled: false\r?\n?/u, '')
103
+ .replace(/# dsh-win-pwsh7: dsh-ops compatibility disabled \(managed\)\r?\n- id: dsh-ops-compat\r?\n disabled: true\r?\n?/u, '')
104
+ }
105
+
106
+ /** Whether the user already owns a dsh-ops-compat override outside our markers. */
107
+ function hasForeignCompatEntry(text) {
108
+ return /^\s*-\s*(?:\{\s*)?id:\s*['"]?dsh-ops-compat\b/mu.test(withoutCompatBlock(text))
109
+ }
110
+
111
+ /**
112
+ * Ensure the bundle owns the enabled state, and the profile owns only an explicit switch-off.
113
+ * @param ctx - the row's Host context.
114
+ * @param config - the live row configuration.
115
+ * @returns `{ changed, state }` for logging; never throws.
116
+ */
117
+ export async function ensureDshOpsCompat(ctx, config) {
118
+ const directory = profileDirectory(ctx)
119
+ if (directory === undefined) return { changed: false, state: 'no-profile' }
120
+ const path = join(directory, 'cordis.patch.yml')
121
+ const installed = await dshOpsInstalled(ctx)
122
+ const enabled = config?.dshOpsCompat !== false
123
+ if (!installed) {
124
+ return { changed: rewriteProfilePatch(path, (text) => withoutCompatBlock(text)), state: 'not-installed' }
125
+ }
126
+ if (enabled) {
127
+ if (bundleOrderOk(ctx)) {
128
+ // The bundle patch enables compatibility. Remove only legacy managed blocks so an old
129
+ // profile does not keep a duplicate override; a user's own entry is untouched.
130
+ const changed = rewriteProfilePatch(path, (text) => {
131
+ if (!text.includes(DSH_OPS_PATCH_MARKER) && !text.includes(DSH_OPS_DISABLE_MARKER)) return text
132
+ return withoutCompatBlock(text)
133
+ })
134
+ return { changed, state: 'bundle' }
135
+ }
136
+ // Older/foreign profiles can list pwsh7 before dsh-ops; the bundle override would be
137
+ // skipped. Keep the legacy profile enable block as a working fallback.
138
+ const changed = rewriteProfilePatch(path, (text) => {
139
+ if (hasForeignCompatEntry(text)) return text
140
+ if (text.includes(DSH_OPS_PATCH_MARKER)) return text
141
+ const base = withoutCompatBlock(text)
142
+ const normalized = base.trim() === '[]' ? '' : base
143
+ const separator = normalized === '' || normalized.endsWith('\n') ? '' : '\n'
144
+ return `${normalized}${separator}${DSH_OPS_PATCH_BLOCK}`
145
+ })
146
+ return { changed, state: 'legacy-order' }
147
+ }
148
+ const changed = rewriteProfilePatch(path, (text) => {
149
+ // Respect a user-owned override instead of fighting it with a second id.
150
+ if (hasForeignCompatEntry(text)) return withoutCompatBlock(text)
151
+ if (text.includes(DSH_OPS_DISABLE_MARKER)) return text
152
+ const base = withoutCompatBlock(text)
153
+ const normalized = base.trim() === '[]' ? '' : base
154
+ const separator = normalized === '' || normalized.endsWith('\n') ? '' : '\n'
155
+ return `${normalized}${separator}${DSH_OPS_DISABLE_BLOCK}`
156
+ })
157
+ return { changed, state: 'disabled' }
158
+ }
159
+
160
+ /** Synchronous best-effort removal for plugin disposal and config changes. */
161
+ export function removeDshOpsCompatPatch(ctx) {
162
+ const directory = profileDirectory(ctx)
163
+ if (directory === undefined) return false
164
+ try {
165
+ return rewriteProfilePatch(join(directory, 'cordis.patch.yml'), (text) => withoutCompatBlock(text))
166
+ } catch (error) {
167
+ ctx.logger?.warn?.('dsh-win-pwsh7: could not remove the dsh-ops compatibility patch: %s', describe(error))
168
+ return false
169
+ }
170
+ }
171
+
172
+ /**
173
+ * The tool-name facts of one agent, read from the host's pre-restriction view when it is
174
+ * available.
175
+ *
176
+ * `visible` is POST-restriction: hiding read/glob/grep removes them from it, so the next
177
+ * reconcile pass no longer saw a counterpart pair, lifted the mask, saw the pair again, and
178
+ * restored it. Each flip emitted `tools/change`, which called this reconcile again — an
179
+ * endless hide/unhide loop that starved the Host main thread once many agents were created.
180
+ * `knownNames` / `restrictableNames` keep the pre-restriction facts and are also the only
181
+ * names `tools.restrict()` accepts, so the deny list is stable and valid.
182
+ * @param tools - the tool registry.
183
+ * @param agent - the agent scope.
184
+ * @param previousDeny - names this bridge already hid, used to keep older hosts stable.
185
+ * @returns `{ names, restrictable }`, both empty when the surface cannot be read.
186
+ */
187
+ export function toolNameFacts(tools, agent, previousDeny = []) {
188
+ try {
189
+ const view = tools.view?.(agent)
190
+ const known = view?.knownNames
191
+ if (known !== undefined && typeof known.has === 'function') {
192
+ const restrictable = view?.restrictableNames
193
+ const facts = {
194
+ names: new Set(known),
195
+ restrictable: new Set(restrictable !== undefined && typeof restrictable.has === 'function' ? restrictable : known),
196
+ }
197
+ for (const name of previousDeny) {
198
+ facts.names.add(name)
199
+ facts.restrictable.add(name)
200
+ }
201
+ return facts
202
+ }
203
+ // Older hosts expose only the visible map or the schema projection. It is
204
+ // post-restriction, but unioning the names this bridge already hid keeps a
205
+ // hide/unhide loop from forming in the fallback path too.
206
+ const visible = view?.visible
207
+ let names
208
+ if (visible !== undefined && typeof visible.keys === 'function') {
209
+ names = new Set(visible.keys())
210
+ } else {
211
+ const schemas = tools.schemas?.(agent)
212
+ names = new Set(Array.isArray(schemas) ? schemas.map((schema) => schema.name) : [])
213
+ }
214
+ for (const name of previousDeny) names.add(name)
215
+ return { names, restrictable: new Set(names) }
216
+ } catch {
217
+ return { names: new Set(), restrictable: new Set() }
218
+ }
219
+ }
220
+
221
+ /**
222
+ * The official tools this bridge re-hides when their OPS counterparts are visible. The
223
+ * counterpart test is deliberate: without it the bridge would hide official tools on a
224
+ * machine where dsh-ops is absent or Compatibility is off. Candidates are filtered to the
225
+ * names the host will actually accept in `tools.restrict()`, so a scope-local name such as
226
+ * a session-owned `ops_bash` can never make the whole mask throw.
227
+ * @param facts - the agent's stable tool-name facts.
228
+ * @returns the official names to deny.
229
+ */
230
+ export function bridgeDenyNames(facts) {
231
+ const { names } = facts
232
+ const deny = []
233
+ if (names.has('ops_inspect_local_file') && names.has('read')) deny.push('read')
234
+ if (names.has('ops_glob') && names.has('glob')) deny.push('glob')
235
+ if (names.has('ops_grep') && names.has('grep')) deny.push('grep')
236
+ if (names.has('ops_bash')) {
237
+ if (names.has('bash')) deny.push('bash')
238
+ // Compatibility would otherwise expose both PowerShell tools beside ops_bash; keep
239
+ // the ordinary `pwsh` and remove the persistent duplicate.
240
+ if (names.has('pwsh') && names.has('pwsh_persistent')) deny.push('pwsh_persistent')
241
+ }
242
+ return deny.filter((name) => facts.restrictable.has(name))
243
+ }
244
+
245
+ /**
246
+ * Keep the official file tools and duplicate shell out of the model's view when dsh-ops
247
+ * Compatibility is on. Agent-scoped `tools.restrict` is the only seam that hides rather
248
+ * than denies; every mask is lifted with its agent or with this effect.
249
+ * @param ctx - the row's Host context.
250
+ * @param current - reads the live row configuration.
251
+ * @returns a disposer for the bridge.
252
+ */
253
+ export function mountDshOpsBridge(ctx, current) {
254
+ if (typeof ctx.on !== 'function') return
255
+ const tools = ctx.tools ?? ctx.get('tools')
256
+ const agents = ctx.get('agents')
257
+ if (tools === undefined || agents === undefined || typeof tools.restrict !== 'function') return
258
+ const entries = new Map()
259
+ let stopped = false
260
+ let mutating = false
261
+ const lift = (entry) => {
262
+ entry.deny = []
263
+ for (const dispose of entry.lifts.splice(0)) {
264
+ try {
265
+ dispose?.()
266
+ } catch (error) {
267
+ ctx.logger?.warn?.('dsh-win-pwsh7: bridge restriction cleanup failed: %s', describe(error))
268
+ }
269
+ }
270
+ }
271
+ const reconcile = () => {
272
+ if (stopped || mutating) return
273
+ mutating = true
274
+ try {
275
+ const enabled = current()?.dshOpsCompat !== false
276
+ if (!enabled) {
277
+ for (const entry of entries.values()) lift(entry)
278
+ entries.clear()
279
+ return
280
+ }
281
+ for (const agent of agents.list?.() ?? []) {
282
+ if (!entries.has(agent)) entries.set(agent, { lifts: [], deny: [] })
283
+ }
284
+ for (const [agent, entry] of entries) {
285
+ const deny = bridgeDenyNames(toolNameFacts(tools, agent, entry.deny))
286
+ if (deny.length === entry.deny.length && deny.every((name) => entry.deny.includes(name))) continue
287
+ lift(entry)
288
+ if (deny.length === 0) continue
289
+ try {
290
+ if (agent?.ctx?.tools === undefined) continue
291
+ entry.lifts.push(agent.ctx.tools.restrict({ deny }))
292
+ entry.deny = deny
293
+ } catch (error) {
294
+ ctx.logger?.warn?.('dsh-win-pwsh7: bridge could not hide %s: %s', deny.join(', '), describe(error))
295
+ }
296
+ }
297
+ } finally {
298
+ mutating = false
299
+ }
300
+ }
301
+ const disposers = []
302
+ if (typeof ctx.on === 'function') {
303
+ disposers.push(ctx.on('agent/created', ({ agent } = {}) => {
304
+ if (agent !== undefined) entries.set(agent, { lifts: [], deny: [] })
305
+ reconcile()
306
+ }))
307
+ disposers.push(ctx.on('agent/disposed', ({ agent } = {}) => {
308
+ const entry = entries.get(agent)
309
+ if (entry !== undefined) {
310
+ lift(entry)
311
+ entries.delete(agent)
312
+ }
313
+ }))
314
+ disposers.push(ctx.on('tools/change', () => { reconcile() }))
315
+ disposers.push(ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
316
+ reconcile()
317
+ return next()
318
+ }))
319
+ disposers.push(ctx.on('app-boot/config-reload', () => { reconcile() }))
320
+ disposers.push(ctx.on('plugin-manager/changed', () => { reconcile() }))
321
+ }
322
+ reconcile()
323
+ return () => {
324
+ stopped = true
325
+ for (const dispose of disposers) dispose?.()
326
+ for (const entry of entries.values()) lift(entry)
327
+ entries.clear()
328
+ }
329
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-win-pwsh7",
3
- "version": "0.2.0",
3
+ "version": "0.2.3",
4
4
  "type": "module",
5
5
  "description": "Detect and validate PowerShell 7 for the Harness pwsh tool on Windows, and install it on request through the Microsoft Store — scripted or manual — while the pwsh tool stays blocked until a PowerShell 7 runtime actually resolves.",
6
6
  "engines": {
@@ -32,6 +32,7 @@
32
32
  "index.js",
33
33
  "client.js",
34
34
  "lib/typert.host.js",
35
+ "lib/dsh-ops-bridge.js",
35
36
  "cordis.patch.yml",
36
37
  "locale/*.json",
37
38
  "icon.svg",
@@ -64,7 +65,8 @@
64
65
  "immediately": true,
65
66
  "inject": [
66
67
  "@deepseek-ai/dsh-client-ui-plugin-manager",
67
- "@deepseek-ai/dsh-api-remotes"
68
+ "@deepseek-ai/dsh-api-remotes",
69
+ "@deepseek-ai/dsh-client-ui-primitives"
68
70
  ]
69
71
  }
70
72
  }
@@ -38,6 +38,18 @@ const React = {
38
38
  },
39
39
  }
40
40
 
41
+ /** The native Switch primitive, rendered as the button role the real component uses. */
42
+ function Switch({ checked, onChange, label, disabled }) {
43
+ return React.createElement('button', {
44
+ type: 'button',
45
+ role: 'switch',
46
+ 'aria-checked': checked,
47
+ 'aria-label': label,
48
+ disabled,
49
+ onClick: () => onChange(!checked),
50
+ })
51
+ }
52
+
41
53
  /** Every string rendered anywhere in the tree, in document order. */
42
54
  function textsOf(node, found = []) {
43
55
  if (typeof node === 'string' || typeof node === 'number') {
@@ -119,6 +131,7 @@ await import('../client.js')
119
131
  assert.ok(entry !== undefined, 'client.js did not call window.__ModuleLoader__.load()')
120
132
  const client = entry.factory((name) => {
121
133
  if (name === 'react') return React
134
+ if (name === '@deepseek-ai/dsh-client-ui-primitives') return { Switch }
122
135
  throw new Error(`the client half required an unexpected module: ${name}`)
123
136
  })
124
137
  client.apply(ctx)
@@ -141,24 +154,22 @@ const cardTree = card({ view: 'page' })
141
154
  const cardTexts = textsOf(cardTree)
142
155
 
143
156
  check('the card renders its labels from the dictionaries, never as bare config keys', () => {
144
- for (const key of ['overridePath']) {
157
+ for (const key of ['overridePath', 'dshOpsCompat']) {
145
158
  assert.ok(!cardTexts.includes(key),
146
159
  `the card rendered the bare config key "${key}" — a label was written as a literal instead of t()`)
147
160
  }
148
- for (const expected of [dictionaries.zh.field_overridePath]) {
161
+ for (const expected of [dictionaries.zh.field_overridePath, dictionaries.zh.field_dshOpsCompat]) {
149
162
  assert.ok(cardTexts.includes(expected), `the card does not render the label "${expected}"`)
150
163
  }
151
164
  })
152
- check('each field label also keeps its raw config key, so the form maps to cordis.patch.yml', () => {
153
- const labels = elementsOf(cardTree, 'label').map((element) => textsOf(element).join(''))
154
- assert.equal(labels.length, 1, `expected one label, found ${labels.length}`)
155
- for (const [index, key] of ['overridePath'].entries()) {
156
- assert.match(labels[index], new RegExp(key, 'u'), `label ${index} drops the raw key ${key}`)
157
- assert.match(labels[index], /[\u4e00-\u9fff]/u, `label ${index} is not localized: ${labels[index]}`)
158
- }
165
+ check('the coexist row uses the native Switch primitive, not a custom checkbox', () => {
166
+ const switches = elementsOf(cardTree, Switch)
167
+ assert.equal(switches.length, 1, `expected one native switch, found ${switches.length}`)
168
+ assert.equal(switches[0].props.checked, true)
169
+ assert.equal(switches[0].props.label, dictionaries.zh.field_dshOpsCompat)
159
170
  })
160
171
  check('the card renders every hint in Chinese', () => {
161
- for (const hint of [dictionaries.zh.overridePath_hint]) {
172
+ for (const hint of [dictionaries.zh.overridePath_hint, dictionaries.zh.dshOpsCompat_hint]) {
162
173
  assert.ok(cardTexts.includes(hint), `the card does not render the hint "${hint.slice(0, 24)}…"`)
163
174
  }
164
175
  })
@@ -12,7 +12,7 @@
12
12
  * from a table; the install checks run the job body against that stub, never Microsoft Store.
13
13
  */
14
14
  import { register } from 'node:module'
15
- import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
15
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
16
16
  import { dirname, join } from 'node:path'
17
17
  import { tmpdir } from 'node:os'
18
18
  import assert from 'node:assert/strict'
@@ -52,6 +52,7 @@ process.env.LOCALAPPDATA = gateHome
52
52
  process.env.PATH = join(gateHome, 'bin')
53
53
 
54
54
  const { apply } = await import('../index.js')
55
+ const { bridgeDenyNames, toolNameFacts } = await import('../lib/dsh-ops-bridge.js')
55
56
 
56
57
  /** An ordinary executable that exists on every Windows machine; its version comes from the stub. */
57
58
  const ORDINARY = 'C:\\Windows\\System32\\cmd.exe'
@@ -78,7 +79,7 @@ function makeStoreFixtures() {
78
79
  * A stub Host context: records registrations, prompt sections and job specs, answers probes,
79
80
  * spawns nothing. `jobs: false` omits the job registry.
80
81
  */
81
- function makeContext({ rows = [], versions = {}, jobs = false, shellPath, shellObject } = {}) {
82
+ function makeContext({ rows = [], versions = {}, jobs = false, shellPath, shellObject, pluginManager, agents, visible } = {}) {
82
83
  const registered = new Map()
83
84
  const sections = []
84
85
  const orderNames = []
@@ -105,6 +106,8 @@ function makeContext({ rows = [], versions = {}, jobs = false, shellPath, shellO
105
106
  plugin() {},
106
107
  get: (key) => {
107
108
  if (key === 'configForms') return { get: () => ({}) }
109
+ if (key === 'pluginManager') return pluginManager
110
+ if (key === 'agents') return agents
108
111
  if (key === 'jobs' && jobs) return jobRegistry
109
112
  // The pwsh tool executes through `ctx.shell`; the executor's own `pwshPath` is the
110
113
  // authoritative "what will run" fact. A context without it keeps the row-based reading.
@@ -129,6 +132,13 @@ function makeContext({ rows = [], versions = {}, jobs = false, shellPath, shellO
129
132
  if (index >= 0) guards.splice(index, 1)
130
133
  }
131
134
  },
135
+ restrict() { return () => {} },
136
+ view() {
137
+ return { visible: new Map((visible ?? []).map((name) => [name, {}])) }
138
+ },
139
+ schemas() {
140
+ return (visible ?? []).map((name) => ({ name }))
141
+ },
132
142
  },
133
143
  on(event, listener) {
134
144
  const set = listeners.get(event) ?? new Set()
@@ -641,6 +651,150 @@ if (existsSync(MSI)) {
641
651
  else process.env.ProgramFiles = savedProgramFiles
642
652
  }
643
653
 
654
+ // --- dsh-ops compatibility patch ---------------------------------------------------------
655
+ const compatDir = mkdtempSync(join(tmpdir(), 'dsh-win-pwsh7-compat-'))
656
+ const compatPatchPath = join(compatDir, 'cordis.patch.yml')
657
+ writeFileSync(compatPatchPath, '# dsh-win-pwsh7: dsh-ops compatibility (managed)\n- id: dsh-ops-compat\n disabled: false\n- id: keep-me\n name: keep-me\n', 'utf8')
658
+ const savedProfileDir = process.env.DSH_PROFILE_DIR
659
+ process.env.DSH_PROFILE_DIR = compatDir
660
+ const compatMachine = makeContext({
661
+ pluginManager: {
662
+ async listBundles() {
663
+ return [{ name: 'dsh-ops', installed: true, rows: [{ moduleName: 'dsh-ops/compat' }] }]
664
+ },
665
+ },
666
+ })
667
+ apply(compatMachine.ctx, {})
668
+ await new Promise((resolve) => setTimeout(resolve, 50))
669
+ const compatWritten = readFileSync(compatPatchPath, 'utf8')
670
+ check('the enabled path delegates to the bundle and removes a legacy managed override', () => {
671
+ assert.ok(!compatWritten.includes('dsh-win-pwsh7: dsh-ops compatibility'), 'a managed block survived')
672
+ assert.ok(!compatWritten.includes('- id: dsh-ops-compat'), 'the bundle-owned override was written into the profile')
673
+ assert.match(compatWritten, /- id: keep-me/u, 'the user patch entry was lost')
674
+ })
675
+ apply(compatMachine.ctx, { dshOpsCompat: false })
676
+ await new Promise((resolve) => setTimeout(resolve, 50))
677
+ const compatDisabled = readFileSync(compatPatchPath, 'utf8')
678
+ check('turning coexistence off writes only the managed disable override', () => {
679
+ assert.match(compatDisabled, /dsh-win-pwsh7: dsh-ops compatibility disabled \(managed\)/u)
680
+ assert.match(compatDisabled, /- id: dsh-ops-compat\n disabled: true/u)
681
+ assert.ok(!compatDisabled.includes('dsh-win-pwsh7: dsh-ops compatibility (managed)'), 'the enable marker survived')
682
+ assert.ok(compatDisabled.includes('- id: keep-me'), 'the user patch entry was removed')
683
+ })
684
+ apply(compatMachine.ctx, { dshOpsCompat: true })
685
+ await new Promise((resolve) => setTimeout(resolve, 50))
686
+ const compatReenabled = readFileSync(compatPatchPath, 'utf8')
687
+ check('turning coexistence back on removes the disable override for the bundle', () => {
688
+ assert.ok(!compatReenabled.includes('dsh-win-pwsh7: dsh-ops compatibility'), 'a managed block survived')
689
+ assert.ok(!compatReenabled.includes('- id: dsh-ops-compat'), 'the bundle-owned override was written into the profile')
690
+ assert.ok(compatReenabled.includes('- id: keep-me'), 'the user patch entry was removed')
691
+ })
692
+ if (savedProfileDir === undefined) delete process.env.DSH_PROFILE_DIR
693
+ else process.env.DSH_PROFILE_DIR = savedProfileDir
694
+ rmSync(compatDir, { recursive: true, force: true })
695
+
696
+ check('the bundle patch owns the enabled compatibility override', () => {
697
+ const patch = readFileSync(new URL('../cordis.patch.yml', import.meta.url), 'utf8')
698
+ assert.match(patch, /^- id: dsh-ops-compat\n name: dsh-ops\/compat\n disabled: false$/mu)
699
+ })
700
+
701
+ const orderDir = mkdtempSync(join(tmpdir(), 'dsh-win-pwsh7-order-'))
702
+ writeFileSync(join(orderDir, 'package.json'), JSON.stringify({
703
+ dsh: { profile: { bundles: ['dsh-win-pwsh7', 'dsh-ops'] } },
704
+ }), 'utf8')
705
+ writeFileSync(join(orderDir, 'cordis.patch.yml'), '- id: keep-me\n name: keep-me\n', 'utf8')
706
+ const savedOrderDir = process.env.DSH_PROFILE_DIR
707
+ process.env.DSH_PROFILE_DIR = orderDir
708
+ const orderMachine = makeContext({
709
+ pluginManager: {
710
+ async listBundles() {
711
+ return [{ name: 'dsh-ops', installed: true, rows: [{ moduleName: 'dsh-ops/compat' }] }]
712
+ },
713
+ },
714
+ })
715
+ apply(orderMachine.ctx, {})
716
+ await new Promise((resolve) => setTimeout(resolve, 50))
717
+ const orderPatch = readFileSync(join(orderDir, 'cordis.patch.yml'), 'utf8')
718
+ check('a reversed bundle order keeps the profile fallback instead of silently disabling compatibility', () => {
719
+ assert.match(orderPatch, /dsh-win-pwsh7: dsh-ops compatibility \(managed\)/u)
720
+ assert.match(orderPatch, /- id: dsh-ops-compat\n disabled: false/u)
721
+ assert.ok(orderPatch.includes('- id: keep-me'))
722
+ })
723
+ if (savedOrderDir === undefined) delete process.env.DSH_PROFILE_DIR
724
+ else process.env.DSH_PROFILE_DIR = savedOrderDir
725
+ rmSync(orderDir, { recursive: true, force: true })
726
+
727
+ // --- agent bridge hides only the duplicate tools ------------------------------------------
728
+ const bridgeDenies = []
729
+ const bridgeAgent = {
730
+ ctx: {
731
+ tools: {
732
+ restrict({ deny }) {
733
+ bridgeDenies.push(deny)
734
+ return () => {}
735
+ },
736
+ },
737
+ },
738
+ }
739
+ const bridgeMachine = makeContext({
740
+ agents: { list: () => [bridgeAgent] },
741
+ visible: [
742
+ 'read', 'ops_inspect_local_file',
743
+ 'glob', 'ops_glob',
744
+ 'grep', 'ops_grep',
745
+ 'bash', 'pwsh', 'pwsh_persistent', 'ops_bash',
746
+ ],
747
+ })
748
+ apply(bridgeMachine.ctx, {})
749
+ check('dsh-ops bridge hides duplicate official tools but keeps pwsh', () => {
750
+ const deny = bridgeDenies.at(-1) ?? []
751
+ for (const name of ['read', 'glob', 'grep', 'bash', 'pwsh_persistent']) {
752
+ assert.ok(deny.includes(name), `bridge did not hide ${name}: ${deny.join(', ')}`)
753
+ }
754
+ assert.ok(!deny.includes('pwsh'), 'bridge hid the native pwsh tool')
755
+ assert.ok(!deny.includes('ops_bash'), 'bridge hid ops_bash')
756
+ })
757
+
758
+ const bridgeNames = new Set([
759
+ 'read', 'ops_inspect_local_file',
760
+ 'glob', 'ops_glob',
761
+ 'grep', 'ops_grep',
762
+ 'bash', 'pwsh', 'pwsh_persistent', 'ops_bash',
763
+ ])
764
+ const bridgeRestrictable = new Set([...bridgeNames].filter((name) => name !== 'ops_bash'))
765
+ const bridgeTools = {
766
+ view() {
767
+ return { knownNames: bridgeNames, restrictableNames: bridgeRestrictable }
768
+ },
769
+ }
770
+ check('the bridge plans from pre-restriction names, so hiding cannot flip it', () => {
771
+ const first = bridgeDenyNames(toolNameFacts(bridgeTools, {}, []))
772
+ assert.deepEqual(first, ['read', 'glob', 'grep', 'bash', 'pwsh_persistent'])
773
+ // `visible` is now post-restriction; knownNames is unchanged, as the host's view guarantees.
774
+ const second = bridgeDenyNames(toolNameFacts(bridgeTools, {}, first))
775
+ assert.deepEqual(second, first)
776
+ })
777
+
778
+ check('the fallback view latches the names already hidden instead of oscillating', () => {
779
+ const visible = new Set(bridgeNames)
780
+ const fallbackTools = {
781
+ view() {
782
+ return { visible: new Map([...visible].map((name) => [name, {}])) }
783
+ },
784
+ }
785
+ const first = bridgeDenyNames(toolNameFacts(fallbackTools, {}, []))
786
+ for (const name of first) visible.delete(name)
787
+ const second = bridgeDenyNames(toolNameFacts(fallbackTools, {}, first))
788
+ assert.deepEqual(second, first)
789
+ })
790
+
791
+ check('a scope-local name cannot make the whole restriction plan throw', () => {
792
+ const facts = toolNameFacts(bridgeTools, {}, [])
793
+ const deny = bridgeDenyNames({ ...facts, restrictable: new Set([...bridgeRestrictable].filter((name) => name !== 'glob')) })
794
+ assert.ok(!deny.includes('glob'), 'a non-restrictable name survived the filter')
795
+ assert.ok(!deny.includes('ops_bash'), 'a scope-local counterpart was put in the deny list')
796
+ })
797
+
644
798
  // --- The client half's policy copy must not drift (static; no browser needed) -------------
645
799
 
646
800
  const client = readFileSync(new URL('../client.js', import.meta.url), 'utf8')