deepseek-coordination 0.1.1 → 0.1.2

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/README.md CHANGED
@@ -572,7 +572,7 @@ by a **later** case.
572
572
 
573
573
  That defect is **fixed**: the parser case in `tests/golden.ps1` now creates its own fixture
574
574
  (`$fixtureDir = Join-Path $sandbox 'parser\verify'`, then `New-Item` and `Set-Content`) and asserts
575
- against it. The current measurement is **49 passed, 0 failed**.
575
+ against it. The current measurement is **50 passed, 0 failed**.
576
576
 
577
577
  ### Two real POSIX defects found by `tests/smoke-desktop-sh.sh`
578
578
 
@@ -638,10 +638,19 @@ stand-in tools exposed both:
638
638
  no acceptance step depends on it. (The plugin's *installation* path does: the package manager
639
639
  resolves the repository with `git ls-remote`, so a machine where the harness cannot find `git` on
640
640
  `PATH` cannot install the bundle.)
641
+ * **PowerShell 7.6 is exercised, but not across the whole surface.** The 7.6 artefact definitely runs:
642
+ driving the plugin's real `resolveInvocation` path with `DSH_OS_POWERSHELL_EDITION=7.6` selects
643
+ `bin/dsh-os7.ps1` and the runtime that answers is **PowerShell 7.6.6**, while the default stays on
644
+ 5.1.19041.3031 — that is what `tools/verify-editions.mjs` asserts and what the 14 cases measured here
645
+ confirm. What has *not* been done is a run of every verb against `dsh-os7.ps1`; its fidelity as a port
646
+ rests on the identical 82-verb dispatch sets, a per-module function-name comparison and the window
647
+ group's golden cases. That is a real gap in the evidence, not a claim of full parity.
641
648
 
642
649
  ### What is not proven
643
650
 
644
- * That the DSH loader composes this bundle and makes `dsh_os` appear in an active session.
651
+ * That the loader composes the bundle **by the contract `tools/verify-bundle.mjs` checks**, rather than
652
+ by a path that happens to work on this machine. The weaker claim — that `dsh_os` and the five
653
+ `os_permission_*` tools appear and are callable in an active session — has since been observed.
645
654
  * Complete verb parity between the two platforms — in neither direction.
646
655
  * The enforcement effect of layer A on a machine where the harness and the delegate share one identity
647
656
  — by construction that case degrades to layer B.
@@ -665,7 +674,7 @@ npm run test:all # runs everything below, in order
665
674
  |---|---|---|---|
666
675
  | Table conformance | `powershell -File tests/conformance.ps1` | 5 | **5 passed, 0 failed** |
667
676
  | Encoding contracts | `powershell -File tests/encoding.ps1` | 32 build modules | **all contracts hold** |
668
- | Windows golden | `powershell -File tests/golden.ps1` | 49 | **49 passed, 0 failed** |
677
+ | Windows golden | `powershell -File tests/golden.ps1` | 50 | **50 passed, 0 failed** |
669
678
  | POSIX golden | `bash tests/golden-sh.sh` | 116 | **116 passed, 0 failed** |
670
679
  | POSIX desktop injection | `bash tests/smoke-desktop-sh.sh` | 52 | **52 passed, 0 failed** |
671
680
  | Plugin | `node tests/plugin.test.mjs` | 13 | **13 passed, 0 failed** |
package/README.zh-CN.md CHANGED
@@ -410,7 +410,7 @@ bundle 解析校验把包以 junction 链接进一个一次性的 profile 沙箱
410
410
 
411
411
  此处曾记录:在干净检出上实测 `passed: 47 failed: 1`,失败用例是 `parser/one-two-three-token-verbs`,因为它断言 `file length` 作用于一个由**更晚**的用例才创建的夹具目录。
412
412
 
413
- 该缺陷**已修复**:`tests/golden.ps1` 的解析器用例现在自己创建夹具(`$fixtureDir = Join-Path $sandbox 'parser\verify'`,随后 `New-Item` 与 `Set-Content`),再对其断言。当前实测为 **49 通过,0 失败**。
413
+ 该缺陷**已修复**:`tests/golden.ps1` 的解析器用例现在自己创建夹具(`$fixtureDir = Join-Path $sandbox 'parser\verify'`,随后 `New-Item` 与 `Set-Content`),再对其断言。当前实测为 **50 通过,0 失败**。
414
414
 
415
415
  ### `tests/smoke-desktop-sh.sh` 查出的两个 POSIX 真实缺陷
416
416
 
@@ -457,7 +457,7 @@ npm run test:all # 按顺序跑下面全部
457
457
  |---|---|---|---|
458
458
  | 两表一致性 | `powershell -File tests/conformance.ps1` | 5 | **5 通过,0 失败** |
459
459
  | 编码契约 | `powershell -File tests/encoding.ps1` | 32 个构建模块 | **全部契约成立** |
460
- | Windows 黄金 | `powershell -File tests/golden.ps1` | 49 | **49 通过,0 失败** |
460
+ | Windows 黄金 | `powershell -File tests/golden.ps1` | 50 | **50 通过,0 失败** |
461
461
  | POSIX 黄金 | `bash tests/golden-sh.sh` | 116 | **116 通过,0 失败** |
462
462
  | POSIX 桌面注入 | `bash tests/smoke-desktop-sh.sh` | 52 | **52 通过,0 失败** |
463
463
  | 插件 | `node tests/plugin.test.mjs` | 13 | **13 通过,0 失败** |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deepseek-coordination",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Cross-platform atomic OS-interaction surface for the DeepSeek Harness: one argument-stable verb set implemented natively on each OS, with byte-exact charset, BOM and line-ending handling.",
@@ -65,6 +65,8 @@
65
65
  "src/host/docs/command-original.txt",
66
66
  "cordis.patch.yml",
67
67
  "src/plugin/index.js",
68
+ "src/plugin/invoke.js",
69
+ "src/plugin/takeover.js",
68
70
  "src/permission/index.js",
69
71
  "README.md"
70
72
  ],
@@ -3159,7 +3159,16 @@ function Invoke-PermissionList {
3159
3159
  return [ordered]@{
3160
3160
  path = $state.path
3161
3161
  exists = $state.exists
3162
- empty = $(if ($null -ne $state.empty) { $state.empty } else { $false })
3162
+ # Read through PSObject.Properties, NOT as $state.empty.
3163
+ #
3164
+ # Under Set-StrictMode -Version Latest, READING a property that does not exist THROWS, so
3165
+ # if ($null -ne $state.empty) { ... } # the test IS the throwing read
3166
+ # is not a guard. A caller whose permission.json does not exist got
3167
+ # E_INTERNAL The property 'empty' cannot be found on this object
3168
+ # because one path returns a state without an `empty` key. PSObject.Properties is a dictionary
3169
+ # lookup, so a missing name is simply absent and does not throw. Same fix as the 7.6 module, and
3170
+ # the same idiom already used below for $state.document.PSObject.Properties['rules'].
3171
+ empty = $(if ($null -ne $state.PSObject.Properties['empty']) { [bool]$state.empty } else { $false })
3163
3172
  trusted = $state.trusted
3164
3173
  count = $items.Count
3165
3174
  rules = $items.ToArray()
@@ -3512,7 +3512,22 @@ function Invoke-PermissionList {
3512
3512
  return [ordered]@{
3513
3513
  path = $state.path
3514
3514
  exists = $state.exists
3515
- empty = ($state.empty ?? $false)
3515
+ # Read through PSObject.Properties, NOT as $state.empty.
3516
+ #
3517
+ # Under Set-StrictMode -Version Latest, READING a property that does not exist THROWS. So the obvious
3518
+ # guard is not a guard at all:
3519
+ # empty = ($state.empty ?? $false) # throws before ?? can help
3520
+ # and neither is testing it first, because the test itself is the throwing read. That is exactly how
3521
+ # this shipped broken: `permission list` returned
3522
+ # E_INTERNAL The property 'empty' cannot be found on this object
3523
+ # for every caller whose permission.json did not exist - which is every fresh install, because the
3524
+ # package ships no permission.json. It worked in the repository, where the file is present, so no
3525
+ # test here could see it.
3526
+ #
3527
+ # PSObject.Properties is a dictionary lookup rather than a property read, so a missing name is simply
3528
+ # absent and returns $false without throwing. This is the same idiom already used above for
3529
+ # `$doc.PSObject.Properties['updatedBy']`.
3530
+ empty = $(if ($null -ne $state.PSObject.Properties['empty']) { [bool]$state.empty } else { $false })
3516
3531
  trusted = $state.trusted
3517
3532
  count = $items.Count
3518
3533
  rules = $items.ToArray()
@@ -18,224 +18,15 @@
18
18
  * core reason this surface exists.
19
19
  */
20
20
 
21
- import { spawn, spawnSync } from 'node:child_process'
22
- import { existsSync } from 'node:fs'
23
- import { dirname, join } from 'node:path'
24
- import { fileURLToPath } from 'node:url'
21
+ import { join } from 'node:path'
22
+
23
+ import { resolveInvocation, runScript, searchedDirectory } from './invoke.js'
25
24
 
26
25
  export const name = 'dsh-os'
27
26
 
28
27
  /** The plugin is inert without the tool registry. */
29
28
  export const inject = ['tools']
30
29
 
31
- /**
32
- * Whether a command can be started at all.
33
- *
34
- * Used only by the 'auto' edition choice, to decide between the two PowerShell artefacts. It asks the
35
- * command itself rather than searching PATH by hand, so a shim, an alias-resolved path and a
36
- * .exe-extension lookup on Windows all behave the way an actual spawn would. A failure to start is the
37
- * answer being sought, so any error means "not available" and never propagates.
38
- */
39
- function executableAvailable(command) {
40
- try {
41
- const probe = spawnSync(command, ['-NoLogo', '-NoProfile', '-Command', 'exit 0'], {
42
- stdio: 'ignore',
43
- timeout: 10000,
44
- windowsHide: true,
45
- })
46
- return probe.error === undefined && probe.status === 0
47
- } catch {
48
- return false
49
- }
50
- }
51
-
52
- /**
53
- * Find a PowerShell 7 executable.
54
- *
55
- * 'pwsh' alone is not enough. Installing PowerShell 7 does NOT put it on PATH for every process, and
56
- * in this very repository's own environment it is at the standard install location while `spawn pwsh`
57
- * fails with ENOENT. Requiring a PATH entry would therefore make the 7.6 artefact unreachable on a
58
- * machine where it is plainly installed, which is exactly the bug this selection was added to fix.
59
- *
60
- * So the search is: an explicit DSH_OS_POWERSHELL, then PATH, then the two standard install locations.
61
- * The first entry that starts wins, and null means 7.x is genuinely not available here.
62
- */
63
- function findPowerShell7() {
64
- const explicit = process.env.DSH_OS_POWERSHELL
65
- if (explicit) return executableAvailable(explicit) ? explicit : null
66
-
67
- const candidates = ['pwsh']
68
- if (process.platform === 'win32') {
69
- const programFiles = process.env.ProgramFiles ?? 'C:\\Program Files'
70
- const programFilesX86 = process.env['ProgramFiles(x86)']
71
- const localAppData = process.env.LOCALAPPDATA
72
- candidates.push(join(programFiles, 'PowerShell', '7', 'pwsh.exe'))
73
- if (programFilesX86) candidates.push(join(programFilesX86, 'PowerShell', '7', 'pwsh.exe'))
74
- if (localAppData) candidates.push(join(localAppData, 'Microsoft', 'PowerShell', '7', 'pwsh.exe'))
75
- } else {
76
- candidates.push('/usr/bin/pwsh', '/usr/local/bin/pwsh', '/opt/microsoft/powershell/7/pwsh')
77
- }
78
-
79
- for (const candidate of candidates) {
80
- // A bare name is left to PATH resolution; an absolute path is checked for existence first so the
81
- // probe does not pay for a spawn that cannot succeed.
82
- if (candidate.includes('\\') || candidate.includes('/')) {
83
- if (!existsSync(candidate)) continue
84
- }
85
- if (executableAvailable(candidate)) return candidate
86
- }
87
- return null
88
- }
89
-
90
- // src/plugin/index.js -> package root, which is THREE levels up from the file:
91
- // file <pkg>/src/plugin/index.js
92
- // dirname <pkg>/src/plugin
93
- // dirname <pkg>/src
94
- // dirname <pkg> <- what the scripts are joined against
95
- // Getting this off by one is invisible until runtime, where it reports "no entry point" while the
96
- // file is present; tests/plugin.test.mjs pins it.
97
- const PKG_ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url))))
98
-
99
- /**
100
- * Resolve how to invoke the implementation.
101
- *
102
- * Returns { command, prefix } where the final argv is [...prefix, ...verbArguments].
103
- *
104
- * A .bat cannot be executed directly: cmd.exe interprets it, so spawning it with shell:false fails
105
- * with EINVAL on Windows. The .ps1 is therefore preferred and launched through powershell.exe, which
106
- * is an executable and keeps the call shell-free. The .bat shim exists for a human at a cmd prompt,
107
- * not for this plugin. A .sh is executed by bash for the same reason: the script need not be marked
108
- * executable in the checkout for the plugin to work.
109
- *
110
- * TWO PowerShell artefacts exist and BOTH are selectable:
111
- *
112
- * bin/dsh-os.ps1 built from src/host/lib/powershell5.1, run by powershell.exe (Windows PowerShell 5.1)
113
- * bin/dsh-os7.ps1 built from src/host/lib/powershell7.6, run by pwsh (PowerShell 7)
114
- *
115
- * The 7.6 artefact was unreachable from here for a while: it existed, was built and was tested, but this
116
- * function only ever looked for dsh-os.ps1. The default is unchanged at 5.1 so a deployment that is
117
- * already working cannot change behaviour underneath itself; `powershellEdition: '7.6'` or `'auto'`
118
- * selects the other one, and DSH_OS_POWERSHELL still overrides the executable. `'auto'` uses 7.6 only
119
- * when both the artefact and a pwsh executable are present, and falls back to 5.1 otherwise.
120
- */
121
- function resolveInvocation(config) {
122
- const explicit = config.scriptPath ? [config.scriptPath] : []
123
- const dir = config.scriptPath
124
- ? dirname(config.scriptPath)
125
- : join(PKG_ROOT, config.scriptDir ?? 'src/host/bin')
126
-
127
- // The edition may come from config or the environment; config wins because it is the more specific
128
- // statement, and the environment is there for a deployment that cannot edit the profile.
129
- const edition = String(config.powershellEdition ?? process.env.DSH_OS_POWERSHELL_EDITION ?? '5.1').toLowerCase()
130
-
131
- const ps51 = { file: 'dsh-os.ps1', command: 'powershell.exe', edition: '5.1' }
132
- const ps7 = { file: 'dsh-os7.ps1', command: 'pwsh', edition: '7.6' }
133
-
134
- // The PowerShell 7 executable is located once, because the search probes processes and must not run
135
- // per candidate.
136
- const ps7Command = process.platform === 'win32' ? findPowerShell7() : null
137
-
138
- // Build the candidate list for this platform. An explicit scriptPath disables the edition choice
139
- // entirely: the caller has named a file, so that file is what runs.
140
- let powershellCandidates = []
141
- if (process.platform === 'win32') {
142
- if (edition === '7.6' || edition === '7') {
143
- powershellCandidates = ps7Command ? [{ ...ps7, command: ps7Command }, ps51] : [ps51]
144
- } else if (edition === 'auto') {
145
- const ps7Ready = ps7Command !== null && existsSync(join(dir, ps7.file))
146
- powershellCandidates = ps7Ready ? [{ ...ps7, command: ps7Command }, ps51] : [ps51, ps7]
147
- } else {
148
- powershellCandidates = [ps51, ps7]
149
- }
150
- }
151
-
152
- const candidates = explicit.concat(process.platform === 'win32'
153
- ? powershellCandidates.map((c) => join(dir, c.file))
154
- : [join(dir, 'dsh-os.sh')])
155
-
156
- for (const candidate of candidates) {
157
- if (!existsSync(candidate)) continue
158
- if (candidate.toLowerCase().endsWith('.ps1')) {
159
- // The edition is derived from the FILE that was found, not from the request, because the request
160
- // may have named the other one and a fallback happened.
161
- const chosen = powershellCandidates.find((c) => join(dir, c.file) === candidate)
162
- const isSeven = chosen ? chosen.edition === '7.6' : false
163
- // -ExecutionPolicy is a Windows PowerShell option. pwsh accepts it on Windows but not everywhere,
164
- // so it is only passed to the 5.1 executable. The resolved command is used, not the literal name,
165
- // because a located pwsh is an absolute path.
166
- const base = ['-NoLogo', '-NoProfile', '-NonInteractive']
167
- return {
168
- script: candidate,
169
- command: process.env.DSH_OS_POWERSHELL ?? (chosen ? chosen.command : ps51.command),
170
- // -File passes the script and its arguments as separate argv entries. -Command would make
171
- // PowerShell re-parse a single string, which is exactly the quoting layer this surface avoids.
172
- prefix: isSeven
173
- ? [...base, '-File', candidate]
174
- : [...base, '-ExecutionPolicy', 'Bypass', '-File', candidate],
175
- edition: chosen ? chosen.edition : '5.1',
176
- }
177
- }
178
- if (candidate.toLowerCase().endsWith('.sh')) {
179
- return {
180
- script: candidate,
181
- command: '/bin/bash',
182
- prefix: [candidate],
183
- }
184
- }
185
- if (candidate.toLowerCase().endsWith('.bat')) {
186
- // Only reachable when a caller explicitly configures a .bat; cmd.exe must interpret it.
187
- return {
188
- script: candidate,
189
- command: process.env.ComSpec ?? 'cmd.exe',
190
- prefix: ['/d', '/c', candidate],
191
- }
192
- }
193
- }
194
- return null
195
- }
196
-
197
- /**
198
- * Run the script with an argv array and capture stdout/stderr as bytes.
199
- *
200
- * Streams are read to completion rather than awaited one at a time: reading one pipe fully before the
201
- * other deadlocks as soon as the unread pipe fills.
202
- */
203
- function runScript(invocation, verbArgs, timeoutMs, signal) {
204
- return new Promise((resolve, reject) => {
205
- const child = spawn(invocation.command, [...invocation.prefix, ...verbArgs], {
206
- windowsHide: true,
207
- // No shell. The argv array is passed through as-is.
208
- shell: false,
209
- })
210
- const out = []
211
- const err = []
212
- let timedOut = false
213
- const timer = setTimeout(() => {
214
- timedOut = true
215
- try { child.kill() } catch { /* already gone */ }
216
- }, timeoutMs)
217
- const onAbort = () => { try { child.kill() } catch { /* already gone */ } }
218
- if (signal) signal.addEventListener('abort', onAbort, { once: true })
219
- child.stdout.on('data', (c) => out.push(c))
220
- child.stderr.on('data', (c) => err.push(c))
221
- child.on('error', (e) => {
222
- clearTimeout(timer)
223
- if (signal) signal.removeEventListener('abort', onAbort)
224
- reject(e)
225
- })
226
- child.on('close', (code) => {
227
- clearTimeout(timer)
228
- if (signal) signal.removeEventListener('abort', onAbort)
229
- resolve({
230
- code,
231
- timedOut,
232
- stdout: Buffer.concat(out).toString('utf8'),
233
- stderr: Buffer.concat(err).toString('utf8'),
234
- })
235
- })
236
- })
237
- }
238
-
239
30
  export function apply(ctx, config = {}) {
240
31
  const timeoutMs = config.timeoutMs ?? 120000
241
32
 
@@ -289,7 +80,7 @@ export function apply(ctx, config = {}) {
289
80
  // The message names the directory that was actually searched, because the first version of
290
81
  // this code reported a plausible-looking path that was one level off and cost a debugging
291
82
  // round to find.
292
- const searched = config.scriptPath ?? join(PKG_ROOT, config.scriptDir ?? 'src/host/bin')
83
+ const searched = searchedDirectory(config)
293
84
  throw new Error(
294
85
  `dsh-os has no entry point for platform "${process.platform}"; searched ${searched}` +
295
86
  ' (run tools/build.ps1 to generate it, or set the scriptPath config)',
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Shared invocation layer for the dsh-os entry points.
3
+ *
4
+ * Three consumers need to run a verb and get its envelope back:
5
+ * * `src/plugin/index.js` the model-facing `dsh_os` tool;
6
+ * * `src/plugin/takeover.js` the `tools/execute` interception that forwards the built-in
7
+ * `read`/`write`/`edit`/`pwsh` calls onto dsh-os verbs;
8
+ * * `src/plugin/settings.js` the settings service the client panel drives, which probes
9
+ * `selfcheck` and reads `permission list`.
10
+ *
11
+ * The resolution rules below (which artefact, which executable, which argv prefix) were originally
12
+ * inline in the tool plugin. They are extracted rather than copied because a second copy would drift:
13
+ * the 7.6 artefact was unreachable for a while precisely because one place knew about two files and
14
+ * another place did not.
15
+ *
16
+ * Nothing here writes anything. Every export is a pure read of the environment plus a spawn.
17
+ */
18
+
19
+ import { spawn, spawnSync } from 'node:child_process'
20
+ import { existsSync } from 'node:fs'
21
+ import { dirname, join } from 'node:path'
22
+ import { fileURLToPath } from 'node:url'
23
+
24
+ /**
25
+ * Whether a command can be started at all.
26
+ *
27
+ * Used only by the 'auto' edition choice, to decide between the two PowerShell artefacts. It asks the
28
+ * command itself rather than searching PATH by hand, so a shim, an alias-resolved path and a
29
+ * .exe-extension lookup on Windows all behave the way an actual spawn would. A failure to start is the
30
+ * answer being sought, so any error means "not available" and never propagates.
31
+ */
32
+ export function executableAvailable(command) {
33
+ try {
34
+ const probe = spawnSync(command, ['-NoLogo', '-NoProfile', '-Command', 'exit 0'], {
35
+ stdio: 'ignore',
36
+ timeout: 10000,
37
+ windowsHide: true,
38
+ })
39
+ return probe.error === undefined && probe.status === 0
40
+ } catch {
41
+ return false
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Find a PowerShell 7 executable.
47
+ *
48
+ * 'pwsh' alone is not enough. Installing PowerShell 7 does NOT put it on PATH for every process, and
49
+ * in this repository's own environment it is at the standard install location while `spawn pwsh`
50
+ * fails with ENOENT. Requiring a PATH entry would therefore make the 7.6 artefact unreachable on a
51
+ * machine where it is plainly installed, which is exactly the bug this selection was added to fix.
52
+ *
53
+ * So the search is: an explicit DSH_OS_POWERSHELL, then PATH, then the two standard install locations.
54
+ * The first entry that starts wins, and null means 7.x is genuinely not available here.
55
+ */
56
+ export function findPowerShell7() {
57
+ const explicit = process.env.DSH_OS_POWERSHELL
58
+ if (explicit) return executableAvailable(explicit) ? explicit : null
59
+
60
+ const candidates = ['pwsh']
61
+ if (process.platform === 'win32') {
62
+ const programFiles = process.env.ProgramFiles ?? 'C:\\Program Files'
63
+ const programFilesX86 = process.env['ProgramFiles(x86)']
64
+ const localAppData = process.env.LOCALAPPDATA
65
+ candidates.push(join(programFiles, 'PowerShell', '7', 'pwsh.exe'))
66
+ if (programFilesX86) candidates.push(join(programFilesX86, 'PowerShell', '7', 'pwsh.exe'))
67
+ if (localAppData) candidates.push(join(localAppData, 'Microsoft', 'PowerShell', '7', 'pwsh.exe'))
68
+ } else {
69
+ candidates.push('/usr/bin/pwsh', '/usr/local/bin/pwsh', '/opt/microsoft/powershell/7/pwsh')
70
+ }
71
+
72
+ for (const candidate of candidates) {
73
+ // A bare name is left to PATH resolution; an absolute path is checked for existence first so the
74
+ // probe does not pay for a spawn that cannot succeed.
75
+ if (candidate.includes('\\') || candidate.includes('/')) {
76
+ if (!existsSync(candidate)) continue
77
+ }
78
+ if (executableAvailable(candidate)) return candidate
79
+ }
80
+ return null
81
+ }
82
+
83
+ // src/plugin/invoke.js -> package root, which is THREE levels up from the file:
84
+ // file <pkg>/src/plugin/invoke.js
85
+ // dirname <pkg>/src/plugin
86
+ // dirname <pkg>/src
87
+ // dirname <pkg> <- what the scripts are joined against
88
+ // Getting this off by one is invisible until runtime, where it reports "no entry point" while the
89
+ // file is present; tests/plugin.test.mjs pins it.
90
+ export const PKG_ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url))))
91
+
92
+ /** Where the implementation lives inside the package, unless a caller overrides it. */
93
+ export const DEFAULT_SCRIPT_DIR = 'src/host/bin'
94
+
95
+ /**
96
+ * Resolve how to invoke the implementation.
97
+ *
98
+ * Returns { command, prefix, script, edition } where the final argv is [...prefix, ...verbArguments].
99
+ *
100
+ * A .bat cannot be executed directly: cmd.exe interprets it, so spawning it with shell:false fails
101
+ * with EINVAL on Windows. The .ps1 is therefore preferred and launched through powershell.exe, which
102
+ * is an executable and keeps the call shell-free. The .bat shim exists for a human at a cmd prompt,
103
+ * not for this plugin. A .sh is executed by bash for the same reason: the script need not be marked
104
+ * executable in the checkout for the plugin to work.
105
+ *
106
+ * TWO PowerShell artefacts exist and BOTH are selectable:
107
+ *
108
+ * bin/dsh-os.ps1 built from src/host/lib/powershell5.1, run by powershell.exe (Windows PowerShell 5.1)
109
+ * bin/dsh-os7.ps1 built from src/host/lib/powershell7.6, run by pwsh (PowerShell 7)
110
+ *
111
+ * The default is unchanged at 5.1 so a deployment that is already working cannot change behaviour
112
+ * underneath itself; `powershellEdition: '7.6'` or `'auto'` selects the other one, and
113
+ * DSH_OS_POWERSHELL still overrides the executable. `'auto'` uses 7.6 only when both the artefact and
114
+ * a pwsh executable are present, and falls back to 5.1 otherwise.
115
+ */
116
+ export function resolveInvocation(config = {}) {
117
+ const explicit = config.scriptPath ? [config.scriptPath] : []
118
+ const dir = config.scriptPath
119
+ ? dirname(config.scriptPath)
120
+ : join(PKG_ROOT, config.scriptDir ?? DEFAULT_SCRIPT_DIR)
121
+
122
+ // The edition may come from config or the environment; config wins because it is the more specific
123
+ // statement, and the environment is there for a deployment that cannot edit the profile.
124
+ const edition = String(config.powershellEdition ?? process.env.DSH_OS_POWERSHELL_EDITION ?? '5.1').toLowerCase()
125
+
126
+ const ps51 = { file: 'dsh-os.ps1', command: 'powershell.exe', edition: '5.1' }
127
+ const ps7 = { file: 'dsh-os7.ps1', command: 'pwsh', edition: '7.6' }
128
+
129
+ // The PowerShell 7 executable is located once, because the search probes processes and must not run
130
+ // per candidate.
131
+ const ps7Command = process.platform === 'win32' ? findPowerShell7() : null
132
+
133
+ // Build the candidate list for this platform. An explicit scriptPath disables the edition choice
134
+ // entirely: the caller has named a file, so that file is what runs.
135
+ let powershellCandidates = []
136
+ if (process.platform === 'win32') {
137
+ if (edition === '7.6' || edition === '7') {
138
+ powershellCandidates = ps7Command ? [{ ...ps7, command: ps7Command }, ps51] : [ps51]
139
+ } else if (edition === 'auto') {
140
+ const ps7Ready = ps7Command !== null && existsSync(join(dir, ps7.file))
141
+ powershellCandidates = ps7Ready ? [{ ...ps7, command: ps7Command }, ps51] : [ps51, ps7]
142
+ } else {
143
+ powershellCandidates = [ps51, ps7]
144
+ }
145
+ }
146
+
147
+ const candidates = explicit.concat(process.platform === 'win32'
148
+ ? powershellCandidates.map((c) => join(dir, c.file))
149
+ : [join(dir, 'dsh-os.sh')])
150
+
151
+ for (const candidate of candidates) {
152
+ if (!existsSync(candidate)) continue
153
+ if (candidate.toLowerCase().endsWith('.ps1')) {
154
+ // The edition is derived from the FILE that was found, not from the request, because the request
155
+ // may have named the other one and a fallback happened.
156
+ const chosen = powershellCandidates.find((c) => join(dir, c.file) === candidate)
157
+ const isSeven = chosen ? chosen.edition === '7.6' : false
158
+ // -ExecutionPolicy is a Windows PowerShell option. pwsh accepts it on Windows but not everywhere,
159
+ // so it is only passed to the 5.1 executable. The resolved command is used, not the literal name,
160
+ // because a located pwsh is an absolute path.
161
+ const base = ['-NoLogo', '-NoProfile', '-NonInteractive']
162
+ return {
163
+ script: candidate,
164
+ command: process.env.DSH_OS_POWERSHELL ?? (chosen ? chosen.command : ps51.command),
165
+ // -File passes the script and its arguments as separate argv entries. -Command would make
166
+ // PowerShell re-parse a single string, which is exactly the quoting layer this surface avoids.
167
+ prefix: isSeven
168
+ ? [...base, '-File', candidate]
169
+ : [...base, '-ExecutionPolicy', 'Bypass', '-File', candidate],
170
+ edition: chosen ? chosen.edition : '5.1',
171
+ }
172
+ }
173
+ if (candidate.toLowerCase().endsWith('.sh')) {
174
+ return {
175
+ script: candidate,
176
+ command: '/bin/bash',
177
+ prefix: [candidate],
178
+ edition: 'posix',
179
+ }
180
+ }
181
+ if (candidate.toLowerCase().endsWith('.bat')) {
182
+ // Only reachable when a caller explicitly configures a .bat; cmd.exe must interpret it.
183
+ return {
184
+ script: candidate,
185
+ command: process.env.ComSpec ?? 'cmd.exe',
186
+ prefix: ['/d', '/c', candidate],
187
+ edition: 'bat',
188
+ }
189
+ }
190
+ }
191
+ return null
192
+ }
193
+
194
+ /** The directory an invocation would search, for error messages that name a real path. */
195
+ export function searchedDirectory(config = {}) {
196
+ return config.scriptPath ?? join(PKG_ROOT, config.scriptDir ?? DEFAULT_SCRIPT_DIR)
197
+ }
198
+
199
+ /**
200
+ * Run the script with an argv array and capture stdout/stderr as bytes.
201
+ *
202
+ * Streams are read to completion rather than awaited one at a time: reading one pipe fully before the
203
+ * other deadlocks as soon as the unread pipe fills.
204
+ */
205
+ export function runScript(invocation, verbArgs, timeoutMs = 120000, signal) {
206
+ return new Promise((resolve, reject) => {
207
+ const child = spawn(invocation.command, [...invocation.prefix, ...verbArgs], {
208
+ windowsHide: true,
209
+ // No shell. The argv array is passed through as-is.
210
+ shell: false,
211
+ })
212
+ const out = []
213
+ const err = []
214
+ let timedOut = false
215
+ const timer = setTimeout(() => {
216
+ timedOut = true
217
+ try { child.kill() } catch { /* already gone */ }
218
+ }, timeoutMs)
219
+ const onAbort = () => { try { child.kill() } catch { /* already gone */ } }
220
+ if (signal) signal.addEventListener('abort', onAbort, { once: true })
221
+ child.stdout.on('data', (c) => out.push(c))
222
+ child.stderr.on('data', (c) => err.push(c))
223
+ child.on('error', (e) => {
224
+ clearTimeout(timer)
225
+ if (signal) signal.removeEventListener('abort', onAbort)
226
+ reject(e)
227
+ })
228
+ child.on('close', (code) => {
229
+ clearTimeout(timer)
230
+ if (signal) signal.removeEventListener('abort', onAbort)
231
+ resolve({
232
+ code,
233
+ timedOut,
234
+ stdout: Buffer.concat(out).toString('utf8'),
235
+ stderr: Buffer.concat(err).toString('utf8'),
236
+ })
237
+ })
238
+ })
239
+ }
240
+
241
+ /**
242
+ * Run one verb and return its parsed envelope.
243
+ *
244
+ * The envelope is the contract, so it is parsed here once: `ok` decides the branch and `error.code`
245
+ * is authoritative, never `error.message`. A caller that needs the raw text uses `runVerb` instead.
246
+ *
247
+ * `options.allow` appends `--allow`, which is what every mutating verb requires. It is only ever set
248
+ * by a caller that has already resolved authorization: this function does not decide anything.
249
+ */
250
+ export async function invokeVerb(invocation, verb, args = [], flags = [], options = {}) {
251
+ const verbArgs = [...String(verb).trim().split(/\s+/)]
252
+ for (const value of args) verbArgs.push(String(value))
253
+ for (const flag of flags) verbArgs.push(String(flag))
254
+ if (options.allow === true) verbArgs.push('--allow')
255
+
256
+ const result = await runScript(invocation, verbArgs, options.timeoutMs ?? 120000, options.signal)
257
+
258
+ if (result.timedOut) {
259
+ const error = new Error(`dsh-os verb "${verb}" timed out after ${options.timeoutMs ?? 120000}ms`)
260
+ error.code = 'E_TIMEOUT'
261
+ throw error
262
+ }
263
+ const text = result.stdout.trim()
264
+ if (text === '') {
265
+ const error = new Error(
266
+ `dsh-os verb "${verb}" produced no envelope (exit ${result.code})` +
267
+ (result.stderr.trim() ? `: ${result.stderr.trim()}` : ''),
268
+ )
269
+ error.code = 'E_NO_ENVELOPE'
270
+ error.stderr = result.stderr
271
+ throw error
272
+ }
273
+ let envelope
274
+ try {
275
+ envelope = JSON.parse(text)
276
+ } catch (cause) {
277
+ const error = new Error(`dsh-os verb "${verb}" emitted a non-JSON envelope: ${text.slice(0, 200)}`)
278
+ error.code = 'E_ENVELOPE_MALFORMED'
279
+ error.cause = cause
280
+ throw error
281
+ }
282
+ return { envelope, exitCode: result.code, stderr: result.stderr }
283
+ }
284
+
285
+ /**
286
+ * Run one verb and return only `result`, throwing a typed error for a failed envelope.
287
+ *
288
+ * The thrown error carries `code` from the envelope, so a caller branches on the same vocabulary it
289
+ * would have branched on in a shell, while the message stays human-readable for the tool result.
290
+ */
291
+ export async function invokeForResult(invocation, verb, args = [], flags = [], options = {}) {
292
+ const { envelope, stderr } = await invokeVerb(invocation, verb, args, flags, options)
293
+ if (envelope.ok === true) return envelope.result
294
+ const code = envelope.error?.code ?? 'E_INTERNAL'
295
+ const error = new Error(envelope.error?.message ?? `dsh-os verb "${verb}" failed`)
296
+ error.code = code
297
+ error.hint = envelope.error?.hint
298
+ error.verb = envelope.verb ?? verb
299
+ error.stderr = stderr
300
+ throw error
301
+ }
@@ -0,0 +1,392 @@
1
+ /**
2
+ * dsh-os takeover: route the built-in file and shell tools onto dsh-os verbs.
3
+ *
4
+ * WHY THIS IS A SEPARATE PLUGIN ROW
5
+ *
6
+ * `dsh_os` is a tool the model has to be *told* to use. In practice it never is: the built-in
7
+ * `read`/`write`/`edit`/`pwsh` tools are right there, they overlap almost completely, and nothing in
8
+ * the prompt prefers one over the other. The result is that the byte-level guarantees this project
9
+ * exists for are never exercised — the plugin is installed and imperceptible.
10
+ *
11
+ * This row removes the choice. When a user enables takeover in the settings panel, the built-in calls
12
+ * are intercepted and answered by the same machinery, so the guarantees apply whether or not the model
13
+ * cooperates.
14
+ *
15
+ * WHY `tools/execute` AND NOT A SHADOWING REGISTRATION
16
+ *
17
+ * `@deepseek-ai/dsh-tools` lets a scoped registration shadow a global tool, which looks like the
18
+ * obvious mechanism. It does not work here. The Web/desktop composition disables the host-side
19
+ * `tool-fs`/`tool-pwsh` rows and remounts them inside every agent *preset* (`dsh-web-app`'s patch),
20
+ * so the built-ins live in a scope layer that is NEARER than any global registration this plugin could
21
+ * make — the preset would shadow us, not the other way round.
22
+ *
23
+ * `tools/execute` (dsh-tools `dispatchScheduledExecution`) is the around-dispatch waterfall:
24
+ *
25
+ * ctx.waterfall(carrier, "tools/execute", mutableExec, () => this.dispatchToolBody(mutableExec))
26
+ *
27
+ * A handler that returns `{ value }` without calling `next()` replaces the tool body's value. The
28
+ * value is then validated against the BUILT-IN tool's own `output.schema` and rendered by the BUILT-IN
29
+ * `output.render` (`normalizeDispatchResult` -> `createSuccessResult`), so the model-facing text, the
30
+ * diff cards and the presentation metadata stay exactly what they were: the only thing that changes is
31
+ * where the bytes came from.
32
+ *
33
+ * WHAT IS DELIBERATELY NOT TAKEN OVER
34
+ *
35
+ * * `glob` and `grep` stay built-in. dsh-os has no content-search verb at all, and `file search`
36
+ * matches names rather than contents, so forwarding them would silently lose results.
37
+ * * `run_in_background` command calls stay built-in: the built-in result for a background call is a
38
+ * job id from the harness job system, and dsh-os's `--background` starts an unrelated process with
39
+ * a pid. Claiming otherwise would hand the model a handle it cannot collect.
40
+ * * Relative paths are not intercepted. The built-in `read`/`write`/`edit` resolve against the
41
+ * session's workspace root through `ctx.fs`; an interception that used the process working
42
+ * directory instead would silently read a different file. Interception requires an absolute path.
43
+ * * `sandbox_permissions` escalation is not honoured. The built-in body asks `ctx.approval` for a
44
+ * wider sandbox before touching the file; a redirected call runs the verb directly, so the file
45
+ * sandbox is not what bounds it — dsh-os's own protected-path check is. This is a real reduction
46
+ * in defence and is documented in README.md rather than hidden.
47
+ *
48
+ * FALLBACK IS THE CONTRACT
49
+ *
50
+ * Every interception is wrapped: anything unexpected — a verb this build does not have, an encoding
51
+ * dsh-os refuses, a malformed path — calls `next()` and lets the built-in tool do the work. Takeover
52
+ * can therefore only ever change HOW a call is answered, never whether it is answered. A model that
53
+ * cannot see the difference is the point.
54
+ */
55
+
56
+ import { randomBytes } from 'node:crypto'
57
+ import { existsSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
58
+ import { basename, dirname, isAbsolute, join } from 'node:path'
59
+
60
+ import { invokeForResult, resolveInvocation } from './invoke.js'
61
+
62
+ export const name = 'dsh-os-takeover'
63
+
64
+ /** The interception needs the tool pipeline, and the switch lives in the settings service. */
65
+ export const inject = ['tools', 'dsh-os-settings']
66
+
67
+ /**
68
+ * Tools this row knows how to answer.
69
+ *
70
+ * The order is also the order the settings panel presents them in. Leaving a tool out is a supported
71
+ * configuration, not a degraded one: with `read` off, reads are the built-in implementation and writes
72
+ * still go through dsh-os.
73
+ */
74
+ export const TAKEOVER_TOOLS = ['read', 'write', 'edit', 'pwsh']
75
+
76
+ /** Defaults matching the "follow the built-in, preserve the target" policy chosen for this feature. */
77
+ export const DEFAULT_CONFIG = {
78
+ read: true,
79
+ write: true,
80
+ edit: true,
81
+ pwsh: true,
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // target format detection
86
+ // ---------------------------------------------------------------------------
87
+
88
+ /**
89
+ * Read the byte-level facts the built-in tools do not preserve.
90
+ *
91
+ * The built-in `write` always emits UTF-8 with no BOM and LF line endings. Rewriting a file that had a
92
+ * BOM or CRLF therefore produces a whole-file diff and can break a script that depended on the
93
+ * convention. Which of the two the target already had is only knowable from its bytes, so they are
94
+ * read here rather than guessed.
95
+ *
96
+ * A file that does not exist yields the built-in defaults, which is what makes a newly created file
97
+ * behave exactly as it did before takeover was enabled.
98
+ */
99
+ export function readTargetFormat(path) {
100
+ let bytes
101
+ try {
102
+ bytes = readFileSync(path)
103
+ } catch {
104
+ return { exists: false, bom: false, eol: 'lf' }
105
+ }
106
+ const bom = bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf
107
+ let crlf = 0
108
+ let lf = 0
109
+ for (let index = 0; index < bytes.length; index += 1) {
110
+ if (bytes[index] !== 0x0a) continue
111
+ if (index > 0 && bytes[index - 1] === 0x0d) crlf += 1
112
+ else lf += 1
113
+ }
114
+ // A file with no line ending at all is treated as LF, which is the built-in default and therefore
115
+ // changes nothing about how it will be written.
116
+ const eol = crlf > lf ? 'crlf' : 'lf'
117
+ return { exists: true, bom, eol }
118
+ }
119
+
120
+ /** Decode a text file's bytes the way the built-in `read` does: BOM stripped, CRLF normalised to LF. */
121
+ export function decodeTextFile(bytes) {
122
+ const hasBom = bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf
123
+ const body = hasBom ? bytes.subarray(3) : bytes
124
+ return body.toString('utf8').replace(/\r\n/g, '\n')
125
+ }
126
+
127
+ /** Encode text back into a target's convention, so the write preserves what was already there. */
128
+ export function encodeTextForTarget(text, format) {
129
+ const body = format.eol === 'crlf' ? text.replace(/\r\n/g, '\n').replace(/\n/g, '\r\n') : text.replace(/\r\n/g, '\n')
130
+ const payload = Buffer.from(body, 'utf8')
131
+ if (!format.bom) return payload
132
+ return Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), payload])
133
+ }
134
+
135
+ /**
136
+ * Replace a file's bytes atomically.
137
+ *
138
+ * Same-directory temporary file then `rename`, which is the same shape dsh-os's own `Write-Atomic`
139
+ * uses. A cross-volume rename would not be atomic, and this is never one: the temporary file is a
140
+ * sibling of its target by construction.
141
+ *
142
+ * The temporary name is random rather than sequential so two concurrent interceptions of the same
143
+ * path cannot collide, and it is removed on any failure so a failed write leaves no debris behind.
144
+ */
145
+ export function writeFileAtomic(path, bytes) {
146
+ const temporary = join(dirname(path), `.${basename(path)}.dsh-os-${randomBytes(6).toString('hex')}.tmp`)
147
+ try {
148
+ writeFileSync(temporary, bytes)
149
+ renameSync(temporary, path)
150
+ } catch (error) {
151
+ try { rmSync(temporary, { force: true }) } catch { /* the failure that matters is the original */ }
152
+ throw error
153
+ }
154
+ }
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // per-tool handlers
158
+ // ---------------------------------------------------------------------------
159
+
160
+ /** Split an `output.text` slice into the `{ number, text }` records the built-in `read` reports. */
161
+ function windowFromSlice(sliceText, offset, totalLines) {
162
+ const all = sliceText.split('\n')
163
+ // dsh-os splits on LF and a file ending in a newline therefore yields a final empty element. The
164
+ // built-in `read` does not report that trailing element as a line, so it is dropped here rather than
165
+ // inflating totalLines by one.
166
+ if (all.length > 0 && all[all.length - 1] === '') all.pop()
167
+ const lines = []
168
+ for (let index = 0; index < all.length; index += 1) {
169
+ const number = offset + index
170
+ if (number > totalLines) break
171
+ lines.push({ number, text: all[index] })
172
+ }
173
+ return lines
174
+ }
175
+
176
+ /** Run `file view` and return its envelope result. */
177
+ async function viewFile(invocation, path, flags = []) {
178
+ return invokeForResult(invocation, 'file view', [path], flags)
179
+ }
180
+
181
+ /**
182
+ * Answer a built-in `read` call.
183
+ *
184
+ * The value must satisfy the built-in schema exactly (`path`, `offset`, `lines[{number,text}]`,
185
+ * `totalLines`, additionalProperties false), because that value is what the built-in renderer formats
186
+ * and what the built-in presentation metadata is derived from.
187
+ */
188
+ async function handleRead(args, invocation) {
189
+ const path = String(args.file_path)
190
+ const offset = Number.isInteger(args.offset) && args.offset > 0 ? args.offset : 1
191
+ const limit = Number.isInteger(args.limit) && args.limit > 0 ? args.limit : 2000
192
+
193
+ const view = await viewFile(invocation, path)
194
+ // `lineCount` counts the trailing empty element the same way `split` does, so the reported total is
195
+ // taken from the slice arithmetic instead: the verb's own text is authoritative.
196
+ const text = String(view.text ?? '')
197
+ const endsWithNewline = text.endsWith('\n')
198
+ const allLines = text.split('\n')
199
+ if (endsWithNewline) allLines.pop()
200
+ const totalLines = allLines.length
201
+
202
+ const selected = allLines.slice(offset - 1, offset - 1 + limit)
203
+ const lines = selected.map((lineText, index) => ({ number: offset + index, text: lineText }))
204
+
205
+ return { path: view.path ?? path, offset, lines, totalLines }
206
+ }
207
+
208
+ /**
209
+ * Answer a built-in `write` call.
210
+ *
211
+ * `before` and `after` are the decoded text, matching what the built-in `read` reports for the same
212
+ * file, while the bytes actually landing on disk keep the target's original BOM and line endings. That
213
+ * split is deliberate: the values are compared by the model and diffed by the UI (both of which work in
214
+ * normalised text), and only the byte-level write needs the file's convention.
215
+ */
216
+ async function handleWrite(args, invocation) {
217
+ const path = String(args.file_path)
218
+ const content = String(args.content ?? '')
219
+
220
+ const before = existsSync(path) ? decodeTextFile(readFileSync(path)) : null
221
+ const format = readTargetFormat(path)
222
+
223
+ writeFileAtomic(path, encodeTextForTarget(content, format))
224
+
225
+ // The verb is still consulted for the write itself only when the target did not exist: `file create`
226
+ // refuses to overwrite by contract, so an existing target is replaced here. A new target goes through
227
+ // the verb, which is where the parent-directory creation and the protected-path check live.
228
+ if (before === null) {
229
+ const created = await invokeForResult(invocation, 'file create', [path, content, format.bom ? 'utf-8-bom' : 'utf-8'], [], { allow: true })
230
+ return {
231
+ path: created.path ?? path,
232
+ operation: 'create',
233
+ before: null,
234
+ after: content,
235
+ }
236
+ }
237
+
238
+ return {
239
+ path,
240
+ operation: 'update',
241
+ before,
242
+ after: content,
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Answer a built-in `edit` call.
248
+ *
249
+ * The literal replacement is performed on the decoded text — the same text the model was shown by
250
+ * `read` — and the result is written back in the target's own convention. Match counting mirrors the
251
+ * built-in contract: one match is required unless `replace_all` is set, and a violation throws so the
252
+ * caller falls back to the built-in tool rather than inventing a different answer.
253
+ */
254
+ async function handleEdit(args, invocation) {
255
+ const path = String(args.file_path)
256
+ const oldString = String(args.old_string ?? '')
257
+ const newString = String(args.new_string ?? '')
258
+ const replaceAll = args.replace_all === true
259
+
260
+ if (oldString === '') throw new Error('old_string must be a non-empty string')
261
+ if (oldString === newString) throw new Error('old_string and new_string must differ')
262
+
263
+ const format = readTargetFormat(path)
264
+ if (!format.exists) throw new Error(`cannot edit '${path}': it does not exist`)
265
+ const before = decodeTextFile(readFileSync(path))
266
+
267
+ const occurrences = before.split(oldString).length - 1
268
+ if (occurrences === 0) throw new Error(`old_string was not found in ${path}`)
269
+ if (occurrences > 1 && !replaceAll) {
270
+ throw new Error(`old_string appears ${occurrences} times in ${path}; pass replace_all to replace every occurrence`)
271
+ }
272
+
273
+ const after = replaceAll
274
+ ? before.split(oldString).join(newString)
275
+ : before.replace(oldString, newString)
276
+
277
+ writeFileAtomic(path, encodeTextForTarget(after, format))
278
+
279
+ return { path, before, after }
280
+ }
281
+
282
+ /**
283
+ * Answer a built-in `pwsh` call.
284
+ *
285
+ * The value must satisfy the built-in foreground variant of the `oneOf` output schema, including
286
+ * `kind: "foreground"`, both stream objects with their `truncated` flags, and the null-able
287
+ * `exitCode`/`signal` pair.
288
+ *
289
+ * `signal` is always null: dsh-os reports a numeric exit status and does not surface the terminating
290
+ * signal separately, and inventing a value there would be a claim the envelope does not support.
291
+ */
292
+ async function handlePwsh(args, invocation, timeoutMs) {
293
+ const command = String(args.command ?? '')
294
+ const workdir = typeof args.workdir === 'string' && args.workdir !== '' ? args.workdir : undefined
295
+ const requestedTimeout = Number.isFinite(args.timeoutMs) && args.timeoutMs > 0 ? args.timeoutMs : timeoutMs
296
+
297
+ // `pwsh -Command <command>` is what the built-in tool runs, so the same argv shape is handed to
298
+ // execute.command: the arguments are passed as separate argv elements and never spliced into a
299
+ // shell string.
300
+ const flags = ['--timeout', String(Math.round(requestedTimeout))]
301
+ if (workdir !== undefined) flags.push('--workdir', workdir)
302
+
303
+ const result = await invokeForResult(invocation, 'execute.command', ['pwsh', '-NoLogo', '-NoProfile', '-Command', command], flags, { allow: true, timeoutMs: requestedTimeout + 5000 })
304
+
305
+ const stdoutText = String(result.stdout ?? '')
306
+ const stderrText = String(result.stderr ?? '')
307
+
308
+ return {
309
+ kind: 'foreground',
310
+ exitCode: Number.isInteger(result.exitCode) ? result.exitCode : null,
311
+ signal: null,
312
+ timedOut: result.timedOut === true,
313
+ aborted: false,
314
+ timeoutMs: Math.round(requestedTimeout),
315
+ stdout: { text: stdoutText, truncated: result.stdoutTruncated === true },
316
+ stderr: { text: stderrText, truncated: result.stderrTruncated === true },
317
+ }
318
+ }
319
+
320
+ // ---------------------------------------------------------------------------
321
+ // plugin
322
+ // ---------------------------------------------------------------------------
323
+
324
+ /**
325
+ * Whether a path can be forwarded at all.
326
+ *
327
+ * An absolute path is required because the built-in tools resolve relative paths against the session
328
+ * workspace root through `ctx.fs`, and this plugin has no equivalent of that resolution. Guessing the
329
+ * process working directory would read a different file than the one the model named, which is worse
330
+ * than not taking the call over.
331
+ */
332
+ function forwardablePath(value) {
333
+ return typeof value === 'string' && value.trim() !== '' && isAbsolute(value)
334
+ }
335
+
336
+ /** Whether the call is one this row can answer without changing what the result means. */
337
+ function eligible(tool, args) {
338
+ switch (tool) {
339
+ case 'read':
340
+ case 'write':
341
+ case 'edit':
342
+ return forwardablePath(args.file_path)
343
+ case 'pwsh':
344
+ // A background call's result is a harness job id, and this surface cannot produce one. The
345
+ // session-scoped sandbox escalation arguments are likewise the harness's own seam.
346
+ if (args.run_in_background === true) return false
347
+ if (args.sandbox_permissions !== undefined) return false
348
+ return typeof args.command === 'string' && args.command.trim() !== ''
349
+ default:
350
+ return false
351
+ }
352
+ }
353
+
354
+ export function apply(ctx, config = {}) {
355
+ const toolTimeoutMs = config.timeoutMs ?? 120000
356
+
357
+ ctx.on('tools/execute', async (exec, next) => {
358
+ const settings = ctx['dsh-os-settings']
359
+ if (settings === undefined || !settings.enabled()) return next()
360
+
361
+ const tool = exec?.name
362
+ if (!TAKEOVER_TOOLS.includes(tool)) return next()
363
+ if (!settings.toolEnabled(tool)) return next()
364
+
365
+ const args = exec.arguments ?? {}
366
+ if (!eligible(tool, args)) return next()
367
+
368
+ const invocation = resolveInvocation(config)
369
+ if (invocation === null) return next()
370
+
371
+ try {
372
+ let value
373
+ if (tool === 'read') value = await handleRead(args, invocation)
374
+ else if (tool === 'write') value = await handleWrite(args, invocation)
375
+ else if (tool === 'edit') value = await handleEdit(args, invocation)
376
+ else value = await handlePwsh(args, invocation, toolTimeoutMs)
377
+
378
+ settings.record(tool, exec, value)
379
+ // Returning without calling next() is what replaces the built-in body. The value still travels
380
+ // through the built-in output schema and renderer.
381
+ return { value }
382
+ } catch (error) {
383
+ // Fallback is the contract: a redirected call may change how a result is produced, never whether
384
+ // the caller gets one. The reason is recorded so the settings panel can show why.
385
+ settings.recordFallback(tool, exec, error)
386
+ return next()
387
+ }
388
+ })
389
+ }
390
+
391
+ /** Exported for tests: the mapping from built-in arguments onto dsh-os argv. */
392
+ export const __internals = { eligible, handleRead, handleWrite, handleEdit, handlePwsh, windowFromSlice, statSync }