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 +12 -3
- package/README.zh-CN.md +2 -2
- package/package.json +3 -1
- package/src/host/bin/dsh-os.ps1 +10 -1
- package/src/host/bin/dsh-os7.ps1 +16 -1
- package/src/plugin/index.js +4 -213
- package/src/plugin/invoke.js +301 -0
- package/src/plugin/takeover.js +392 -0
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 **
|
|
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
|
|
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` |
|
|
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`),再对其断言。当前实测为 **
|
|
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` |
|
|
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.
|
|
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
|
],
|
package/src/host/bin/dsh-os.ps1
CHANGED
|
@@ -3159,7 +3159,16 @@ function Invoke-PermissionList {
|
|
|
3159
3159
|
return [ordered]@{
|
|
3160
3160
|
path = $state.path
|
|
3161
3161
|
exists = $state.exists
|
|
3162
|
-
|
|
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()
|
package/src/host/bin/dsh-os7.ps1
CHANGED
|
@@ -3512,7 +3512,22 @@ function Invoke-PermissionList {
|
|
|
3512
3512
|
return [ordered]@{
|
|
3513
3513
|
path = $state.path
|
|
3514
3514
|
exists = $state.exists
|
|
3515
|
-
|
|
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()
|
package/src/plugin/index.js
CHANGED
|
@@ -18,224 +18,15 @@
|
|
|
18
18
|
* core reason this surface exists.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import {
|
|
22
|
-
|
|
23
|
-
import {
|
|
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 =
|
|
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 }
|