deepseek-coordination 0.1.0 → 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.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.0",
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
+ }