deepseek-coordination 0.1.9 → 0.10.1
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/LICENSE +21 -21
- package/README.md +351 -1083
- package/README.zh-CN.md +351 -697
- package/cordis.patch.yml +5 -74
- package/lib/client.js +33772 -0
- package/lib/index.d.mts +252 -0
- package/lib/index.mjs +27378 -0
- package/package.json +98 -114
- package/client.js +0 -338
- package/src/host/bin/dsh-os.bat +0 -19
- package/src/host/bin/dsh-os.ps1 +0 -5039
- package/src/host/bin/dsh-os.sh +0 -3385
- package/src/host/bin/dsh-os7.ps1 +0 -5518
- package/src/host/command.txt +0 -136
- package/src/host/docs/command-original.txt +0 -26
- package/src/host/docs/os-spec.md +0 -528
- package/src/host/docs/permission-model.md +0 -206
- package/src/host/spec/charsets.tsv +0 -28
- package/src/host/spec/verbs.tsv +0 -120
- package/src/permission/index.js +0 -546
- package/src/plugin/index.js +0 -226
- package/src/plugin/invoke.js +0 -306
- package/src/plugin/settings.js +0 -349
- package/src/plugin/takeover.js +0 -436
- package/src/plugin/workflow.js +0 -1494
- package/tools/measure-cache.mjs +0 -223
package/src/plugin/index.js
DELETED
|
@@ -1,226 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-os host plugin.
|
|
3
|
-
*
|
|
4
|
-
* Registers one model-facing tool, `dsh_os`, that runs a verb of the atomic OS-interaction surface
|
|
5
|
-
* and returns its JSON envelope verbatim.
|
|
6
|
-
*
|
|
7
|
-
* One tool rather than one tool per verb, deliberately:
|
|
8
|
-
* * the verb table is the contract and it lives in spec/verbs.tsv, so the tool schema does not have
|
|
9
|
-
* to be regenerated every time a verb is added or renamed (the `environment attribute` rename cost
|
|
10
|
-
* four edits once already);
|
|
11
|
-
* * an agent discovers the surface in a single call (`verb: "selfcheck"`) instead of from a
|
|
12
|
-
* several-hundred-line tool list;
|
|
13
|
-
* * the envelope is already the stable, parseable result shape, so the tool adds no second
|
|
14
|
-
* contract that could drift from the script's.
|
|
15
|
-
*
|
|
16
|
-
* The implementation is spawned by absolute path with an argv array. It is never invoked through a
|
|
17
|
-
* shell string: that is what makes a path containing spaces stay a single argument, and it is the
|
|
18
|
-
* core reason this surface exists.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
import { join } from 'node:path'
|
|
22
|
-
|
|
23
|
-
import { resolveInvocation, runScript, searchedDirectory } from './invoke.js'
|
|
24
|
-
|
|
25
|
-
export const name = 'dsh-os'
|
|
26
|
-
|
|
27
|
-
/** The plugin is inert without the tool registry; the approval seam is optional and read at use time. */
|
|
28
|
-
export const inject = ['tools']
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Parse an envelope, or undefined when the text is not one.
|
|
32
|
-
*
|
|
33
|
-
* Used only to READ the error code of a refusal. A caller that cannot parse it gets the text back
|
|
34
|
-
* unchanged, so a malformed envelope is never made worse by this.
|
|
35
|
-
*/
|
|
36
|
-
function parseEnvelope(text) {
|
|
37
|
-
try {
|
|
38
|
-
const parsed = JSON.parse(text)
|
|
39
|
-
return parsed !== null && typeof parsed === 'object' ? parsed : undefined
|
|
40
|
-
} catch {
|
|
41
|
-
return undefined
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Ask the DSH approval seam for a one-shot decision on one call.
|
|
47
|
-
*
|
|
48
|
-
* A host plugin does NOT register an answerer to reach the user — the opposite is true. The Web
|
|
49
|
-
* composition already ships `dsh-client-ui-approval`, which answers `approval/request` and renders the
|
|
50
|
-
* banner ("tool X requests elevated execution" with Reject / Allow once). A plugin only has to ASK, and
|
|
51
|
-
* the client renders it. That is why this file carries no UI code for the decision.
|
|
52
|
-
*
|
|
53
|
-
* Three facts about the seam shape the code below, all read from `@deepseek-ai/dsh-user-approval`:
|
|
54
|
-
*
|
|
55
|
-
* * `request()` reads `req.agent.session`, so a call with no agent THROWS rather than being refused
|
|
56
|
-
* politely. The caller must not invoke it in that case, which is why `agent` is passed in and
|
|
57
|
-
* checked here rather than being trusted.
|
|
58
|
-
* * the only grant in the outcome vocabulary is `'allowed-once'`; `'rejected'`, `'cancelled'` and
|
|
59
|
-
* `'unavailable'` all mean "no". They are reported distinctly so the caller can say which happened.
|
|
60
|
-
* * a session whose policy is `never` resolves to `'rejected'` BEFORE the waterfall, so no prompt is
|
|
61
|
-
* ever rendered. That is a deployment setting, not a fault in this code, and the message says so.
|
|
62
|
-
*
|
|
63
|
-
* The seam is consumed opportunistically with `ctx.get('approval')`, the same way `dsh-tools` does it: a
|
|
64
|
-
* deployment without the approval service keeps every verb that does not need a prompt.
|
|
65
|
-
*/
|
|
66
|
-
async function askApproval(ctx, { agent, callId, toolName, reason, displayReason, signal }) {
|
|
67
|
-
const approval = typeof ctx.get === 'function' ? ctx.get('approval') : undefined
|
|
68
|
-
if (approval === undefined) {
|
|
69
|
-
return { granted: false, outcome: 'unavailable', detail: 'no approval service is loaded in this deployment' }
|
|
70
|
-
}
|
|
71
|
-
if (agent === undefined || agent === null) {
|
|
72
|
-
return { granted: false, outcome: 'unavailable', detail: 'the call carries no agent, so there is no session to route an approval through' }
|
|
73
|
-
}
|
|
74
|
-
try {
|
|
75
|
-
const outcome = await approval.request({ agent, toolName, ...callId === undefined ? {} : { callId }, reason, displayReason, signal })
|
|
76
|
-
if (outcome === 'allowed-once') return { granted: true, outcome }
|
|
77
|
-
return { granted: false, outcome, detail: outcome === 'rejected' ? 'the request was refused (a session policy of "never" refuses before any prompt is shown)' : undefined }
|
|
78
|
-
} catch (error) {
|
|
79
|
-
return { granted: false, outcome: 'unavailable', detail: error instanceof Error ? error.message : String(error) }
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export function apply(ctx, rawConfig) {
|
|
84
|
-
// `?? {}` rather than a default parameter: Cordis passes a row's config as `null` when the row declares
|
|
85
|
-
// none, and a default parameter only fires for `undefined`. Every entry point in this package guards the
|
|
86
|
-
// same way, so a row whose config is trimmed degrades to defaults instead of failing to activate.
|
|
87
|
-
const config = rawConfig ?? {}
|
|
88
|
-
const timeoutMs = config.timeoutMs ?? 120000
|
|
89
|
-
|
|
90
|
-
ctx.tools.register({
|
|
91
|
-
name: 'dsh_os',
|
|
92
|
-
description: [
|
|
93
|
-
'Run one verb of the dsh-os atomic OS-interaction surface and return its JSON envelope.',
|
|
94
|
-
'Use verb "selfcheck" first to list every verb with its arguments, flags and platform support.',
|
|
95
|
-
'Arguments are passed as separate argv elements, so a value containing spaces stays one value;',
|
|
96
|
-
'both the positional form (file create "./a.txt" "123" "utf-8-bom") and the named form',
|
|
97
|
-
'(file create path="./a.txt" content="123") are accepted.',
|
|
98
|
-
'Read-only verbs need no authorization; mutating verbs require allow: true.',
|
|
99
|
-
].join(' '),
|
|
100
|
-
parameters: {
|
|
101
|
-
// A COMPLETE JSON Schema, not the property map that `defineTool` accepts.
|
|
102
|
-
//
|
|
103
|
-
// The registry's `register()` takes a finished schema and does not compile one for you: only the
|
|
104
|
-
// internal `defineTool` runs `parameterSchemaSpecToJsonSchema` over a { name: {...} } map. Passing
|
|
105
|
-
// the bare map here ships a schema whose root has no `type`, and the provider rejects the whole
|
|
106
|
-
// request with `schema must be a JSON Schema of 'type: "object"', got 'type: null'`.
|
|
107
|
-
type: 'object',
|
|
108
|
-
properties: {
|
|
109
|
-
verb: {
|
|
110
|
-
type: 'string',
|
|
111
|
-
description: 'Verb to run, e.g. "selfcheck", "file charset", "environment attribute get".',
|
|
112
|
-
},
|
|
113
|
-
arguments: {
|
|
114
|
-
type: 'array',
|
|
115
|
-
items: { type: 'string' },
|
|
116
|
-
description: 'Positional arguments and named arguments (name="value") in any order.',
|
|
117
|
-
},
|
|
118
|
-
flags: {
|
|
119
|
-
type: 'array',
|
|
120
|
-
items: { type: 'string' },
|
|
121
|
-
description: 'Flags, e.g. "--scope", "user", "--recursive". Prefix with -- exactly as documented.',
|
|
122
|
-
},
|
|
123
|
-
allow: {
|
|
124
|
-
type: 'boolean',
|
|
125
|
-
description: 'Authorization for a mutating verb. Required by create, delete, edit, execute, window, keyboard, clipboard, text, structure.',
|
|
126
|
-
},
|
|
127
|
-
},
|
|
128
|
-
required: ['verb'],
|
|
129
|
-
},
|
|
130
|
-
output: {
|
|
131
|
-
schema: { type: 'string' },
|
|
132
|
-
render: (_args, value) => [{ type: 'text', text: value }],
|
|
133
|
-
},
|
|
134
|
-
async execute(args, exec) {
|
|
135
|
-
const invocation = resolveInvocation(config)
|
|
136
|
-
if (invocation === null) {
|
|
137
|
-
// The message names the directory that was actually searched, because the first version of
|
|
138
|
-
// this code reported a plausible-looking path that was one level off and cost a debugging
|
|
139
|
-
// round to find.
|
|
140
|
-
const searched = searchedDirectory(config)
|
|
141
|
-
throw new Error(
|
|
142
|
-
`dsh-os has no entry point for platform "${process.platform}"; searched ${searched}` +
|
|
143
|
-
' (run tools/build.ps1 to generate it, or set the scriptPath config)',
|
|
144
|
-
)
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// The verb may be given as several words; the script resolves multi-word verbs itself, so it is
|
|
148
|
-
// split here rather than guessed at.
|
|
149
|
-
const baseArgs = [...String(args.verb).trim().split(/\s+/)]
|
|
150
|
-
for (const value of args.arguments ?? []) baseArgs.push(String(value))
|
|
151
|
-
for (const flag of args.flags ?? []) baseArgs.push(String(flag))
|
|
152
|
-
|
|
153
|
-
const runOnce = (withAllow) => runScript(
|
|
154
|
-
invocation,
|
|
155
|
-
withAllow ? [...baseArgs, '--allow'] : baseArgs,
|
|
156
|
-
timeoutMs,
|
|
157
|
-
exec?.signal,
|
|
158
|
-
)
|
|
159
|
-
|
|
160
|
-
let result = await runOnce(args.allow === true)
|
|
161
|
-
|
|
162
|
-
if (result.timedOut) {
|
|
163
|
-
throw new Error(`dsh-os verb "${args.verb}" timed out after ${timeoutMs}ms`)
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
let text = result.stdout.trim()
|
|
167
|
-
if (text === '') {
|
|
168
|
-
// An empty stdout would leave the caller with nothing to parse, so the exit code and the
|
|
169
|
-
// captured diagnostics are reported instead of an empty success.
|
|
170
|
-
throw new Error(
|
|
171
|
-
`dsh-os verb "${args.verb}" produced no envelope (exit ${result.code})` +
|
|
172
|
-
(result.stderr.trim() ? `: ${result.stderr.trim()}` : ''),
|
|
173
|
-
)
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// A refusal that a user decision could actually resolve is turned into one, ONCE, in this same
|
|
177
|
-
// call: mutating verbs answer `E_PERMISSION` when they arrive without `--allow`, so the user is
|
|
178
|
-
// asked and the verb is retried with it. Asking is skipped when the request already carried
|
|
179
|
-
// `--allow`, because then the refusal came from somewhere a prompt cannot change.
|
|
180
|
-
if (args.allow !== true) {
|
|
181
|
-
const refusal = parseEnvelope(text)
|
|
182
|
-
if (refusal?.error?.code === 'E_PERMISSION') {
|
|
183
|
-
const decision = await askApproval(ctx, {
|
|
184
|
-
agent: exec?.agent,
|
|
185
|
-
callId: exec?.callId,
|
|
186
|
-
toolName: 'dsh_os',
|
|
187
|
-
reason: `dsh-os verb "${args.verb}" needs authorization`,
|
|
188
|
-
displayReason: {
|
|
189
|
-
en: `dsh-os wants to run "${args.verb}", which changes something on this machine.`,
|
|
190
|
-
},
|
|
191
|
-
signal: exec?.signal,
|
|
192
|
-
})
|
|
193
|
-
if (decision.granted) {
|
|
194
|
-
result = await runOnce(true)
|
|
195
|
-
if (result.timedOut) {
|
|
196
|
-
throw new Error(`dsh-os verb "${args.verb}" timed out after ${timeoutMs}ms after approval`)
|
|
197
|
-
}
|
|
198
|
-
text = result.stdout.trim()
|
|
199
|
-
} else if (decision.outcome !== 'unavailable' || decision.detail !== undefined) {
|
|
200
|
-
// Report the refusal as the typed envelope the caller already branches on, with the reason
|
|
201
|
-
// attached — a bare "no" from a prompt that was never shown would be indistinguishable
|
|
202
|
-
// from a verb that failed on its own.
|
|
203
|
-
return JSON.stringify({
|
|
204
|
-
ok: false,
|
|
205
|
-
verb: refusal.verb ?? String(args.verb).trim().replace(/\s+/g, '.'),
|
|
206
|
-
error: {
|
|
207
|
-
code: 'E_PERMISSION',
|
|
208
|
-
message: `"${args.verb}" was not authorized (approval: ${decision.outcome})`,
|
|
209
|
-
...decision.detail === undefined ? {} : { hint: decision.detail },
|
|
210
|
-
},
|
|
211
|
-
meta: { ...refusal.meta, approval: decision.outcome },
|
|
212
|
-
})
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
if (text === '') {
|
|
218
|
-
throw new Error(`dsh-os verb "${args.verb}" produced no envelope after approval (exit ${result.code})`)
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// The envelope is returned verbatim, including when ok is false: a typed failure is a result the
|
|
222
|
-
// caller must be able to read (error.code is authoritative), not an exception that loses it.
|
|
223
|
-
return text
|
|
224
|
-
},
|
|
225
|
-
})
|
|
226
|
-
}
|
package/src/plugin/invoke.js
DELETED
|
@@ -1,306 +0,0 @@
|
|
|
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(rawConfig) {
|
|
117
|
-
// `?? {}` rather than a default parameter. Cordis passes a row's config through as `null` when the row
|
|
118
|
-
// declares none, and a default parameter only fires for `undefined` — so `config.scriptPath` on that
|
|
119
|
-
// null threw and the plugin row failed to activate. Every caller may pass null.
|
|
120
|
-
const config = rawConfig ?? {}
|
|
121
|
-
const explicit = config.scriptPath ? [config.scriptPath] : []
|
|
122
|
-
const dir = config.scriptPath
|
|
123
|
-
? dirname(config.scriptPath)
|
|
124
|
-
: join(PKG_ROOT, config.scriptDir ?? DEFAULT_SCRIPT_DIR)
|
|
125
|
-
|
|
126
|
-
// The edition may come from config or the environment; config wins because it is the more specific
|
|
127
|
-
// statement, and the environment is there for a deployment that cannot edit the profile.
|
|
128
|
-
const edition = String(config.powershellEdition ?? process.env.DSH_OS_POWERSHELL_EDITION ?? '5.1').toLowerCase()
|
|
129
|
-
|
|
130
|
-
const ps51 = { file: 'dsh-os.ps1', command: 'powershell.exe', edition: '5.1' }
|
|
131
|
-
const ps7 = { file: 'dsh-os7.ps1', command: 'pwsh', edition: '7.6' }
|
|
132
|
-
|
|
133
|
-
// The PowerShell 7 executable is located once, because the search probes processes and must not run
|
|
134
|
-
// per candidate.
|
|
135
|
-
const ps7Command = process.platform === 'win32' ? findPowerShell7() : null
|
|
136
|
-
|
|
137
|
-
// Build the candidate list for this platform. An explicit scriptPath disables the edition choice
|
|
138
|
-
// entirely: the caller has named a file, so that file is what runs.
|
|
139
|
-
let powershellCandidates = []
|
|
140
|
-
if (process.platform === 'win32') {
|
|
141
|
-
if (edition === '7.6' || edition === '7') {
|
|
142
|
-
powershellCandidates = ps7Command ? [{ ...ps7, command: ps7Command }, ps51] : [ps51]
|
|
143
|
-
} else if (edition === 'auto') {
|
|
144
|
-
const ps7Ready = ps7Command !== null && existsSync(join(dir, ps7.file))
|
|
145
|
-
powershellCandidates = ps7Ready ? [{ ...ps7, command: ps7Command }, ps51] : [ps51, ps7]
|
|
146
|
-
} else {
|
|
147
|
-
powershellCandidates = [ps51, ps7]
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
const candidates = explicit.concat(process.platform === 'win32'
|
|
152
|
-
? powershellCandidates.map((c) => join(dir, c.file))
|
|
153
|
-
: [join(dir, 'dsh-os.sh')])
|
|
154
|
-
|
|
155
|
-
for (const candidate of candidates) {
|
|
156
|
-
if (!existsSync(candidate)) continue
|
|
157
|
-
if (candidate.toLowerCase().endsWith('.ps1')) {
|
|
158
|
-
// The edition is derived from the FILE that was found, not from the request, because the request
|
|
159
|
-
// may have named the other one and a fallback happened.
|
|
160
|
-
const chosen = powershellCandidates.find((c) => join(dir, c.file) === candidate)
|
|
161
|
-
const isSeven = chosen ? chosen.edition === '7.6' : false
|
|
162
|
-
// -ExecutionPolicy is a Windows PowerShell option. pwsh accepts it on Windows but not everywhere,
|
|
163
|
-
// so it is only passed to the 5.1 executable. The resolved command is used, not the literal name,
|
|
164
|
-
// because a located pwsh is an absolute path.
|
|
165
|
-
const base = ['-NoLogo', '-NoProfile', '-NonInteractive']
|
|
166
|
-
return {
|
|
167
|
-
script: candidate,
|
|
168
|
-
command: process.env.DSH_OS_POWERSHELL ?? (chosen ? chosen.command : ps51.command),
|
|
169
|
-
// -File passes the script and its arguments as separate argv entries. -Command would make
|
|
170
|
-
// PowerShell re-parse a single string, which is exactly the quoting layer this surface avoids.
|
|
171
|
-
prefix: isSeven
|
|
172
|
-
? [...base, '-File', candidate]
|
|
173
|
-
: [...base, '-ExecutionPolicy', 'Bypass', '-File', candidate],
|
|
174
|
-
edition: chosen ? chosen.edition : '5.1',
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
if (candidate.toLowerCase().endsWith('.sh')) {
|
|
178
|
-
return {
|
|
179
|
-
script: candidate,
|
|
180
|
-
command: '/bin/bash',
|
|
181
|
-
prefix: [candidate],
|
|
182
|
-
edition: 'posix',
|
|
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
|
-
edition: 'bat',
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
return null
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/** The directory an invocation would search, for error messages that name a real path. */
|
|
199
|
-
export function searchedDirectory(rawConfig) {
|
|
200
|
-
const config = rawConfig ?? {}
|
|
201
|
-
return config.scriptPath ?? join(PKG_ROOT, config.scriptDir ?? DEFAULT_SCRIPT_DIR)
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Run the script with an argv array and capture stdout/stderr as bytes.
|
|
206
|
-
*
|
|
207
|
-
* Streams are read to completion rather than awaited one at a time: reading one pipe fully before the
|
|
208
|
-
* other deadlocks as soon as the unread pipe fills.
|
|
209
|
-
*/
|
|
210
|
-
export function runScript(invocation, verbArgs, timeoutMs = 120000, signal) {
|
|
211
|
-
return new Promise((resolve, reject) => {
|
|
212
|
-
const child = spawn(invocation.command, [...invocation.prefix, ...verbArgs], {
|
|
213
|
-
windowsHide: true,
|
|
214
|
-
// No shell. The argv array is passed through as-is.
|
|
215
|
-
shell: false,
|
|
216
|
-
})
|
|
217
|
-
const out = []
|
|
218
|
-
const err = []
|
|
219
|
-
let timedOut = false
|
|
220
|
-
const timer = setTimeout(() => {
|
|
221
|
-
timedOut = true
|
|
222
|
-
try { child.kill() } catch { /* already gone */ }
|
|
223
|
-
}, timeoutMs)
|
|
224
|
-
const onAbort = () => { try { child.kill() } catch { /* already gone */ } }
|
|
225
|
-
if (signal) signal.addEventListener('abort', onAbort, { once: true })
|
|
226
|
-
child.stdout.on('data', (c) => out.push(c))
|
|
227
|
-
child.stderr.on('data', (c) => err.push(c))
|
|
228
|
-
child.on('error', (e) => {
|
|
229
|
-
clearTimeout(timer)
|
|
230
|
-
if (signal) signal.removeEventListener('abort', onAbort)
|
|
231
|
-
reject(e)
|
|
232
|
-
})
|
|
233
|
-
child.on('close', (code) => {
|
|
234
|
-
clearTimeout(timer)
|
|
235
|
-
if (signal) signal.removeEventListener('abort', onAbort)
|
|
236
|
-
resolve({
|
|
237
|
-
code,
|
|
238
|
-
timedOut,
|
|
239
|
-
stdout: Buffer.concat(out).toString('utf8'),
|
|
240
|
-
stderr: Buffer.concat(err).toString('utf8'),
|
|
241
|
-
})
|
|
242
|
-
})
|
|
243
|
-
})
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
/**
|
|
247
|
-
* Run one verb and return its parsed envelope.
|
|
248
|
-
*
|
|
249
|
-
* The envelope is the contract, so it is parsed here once: `ok` decides the branch and `error.code`
|
|
250
|
-
* is authoritative, never `error.message`. A caller that needs the raw text uses `runVerb` instead.
|
|
251
|
-
*
|
|
252
|
-
* `options.allow` appends `--allow`, which is what every mutating verb requires. It is only ever set
|
|
253
|
-
* by a caller that has already resolved authorization: this function does not decide anything.
|
|
254
|
-
*/
|
|
255
|
-
export async function invokeVerb(invocation, verb, args = [], flags = [], options = {}) {
|
|
256
|
-
const verbArgs = [...String(verb).trim().split(/\s+/)]
|
|
257
|
-
for (const value of args) verbArgs.push(String(value))
|
|
258
|
-
for (const flag of flags) verbArgs.push(String(flag))
|
|
259
|
-
if (options.allow === true) verbArgs.push('--allow')
|
|
260
|
-
|
|
261
|
-
const result = await runScript(invocation, verbArgs, options.timeoutMs ?? 120000, options.signal)
|
|
262
|
-
|
|
263
|
-
if (result.timedOut) {
|
|
264
|
-
const error = new Error(`dsh-os verb "${verb}" timed out after ${options.timeoutMs ?? 120000}ms`)
|
|
265
|
-
error.code = 'E_TIMEOUT'
|
|
266
|
-
throw error
|
|
267
|
-
}
|
|
268
|
-
const text = result.stdout.trim()
|
|
269
|
-
if (text === '') {
|
|
270
|
-
const error = new Error(
|
|
271
|
-
`dsh-os verb "${verb}" produced no envelope (exit ${result.code})` +
|
|
272
|
-
(result.stderr.trim() ? `: ${result.stderr.trim()}` : ''),
|
|
273
|
-
)
|
|
274
|
-
error.code = 'E_NO_ENVELOPE'
|
|
275
|
-
error.stderr = result.stderr
|
|
276
|
-
throw error
|
|
277
|
-
}
|
|
278
|
-
let envelope
|
|
279
|
-
try {
|
|
280
|
-
envelope = JSON.parse(text)
|
|
281
|
-
} catch (cause) {
|
|
282
|
-
const error = new Error(`dsh-os verb "${verb}" emitted a non-JSON envelope: ${text.slice(0, 200)}`)
|
|
283
|
-
error.code = 'E_ENVELOPE_MALFORMED'
|
|
284
|
-
error.cause = cause
|
|
285
|
-
throw error
|
|
286
|
-
}
|
|
287
|
-
return { envelope, exitCode: result.code, stderr: result.stderr }
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
/**
|
|
291
|
-
* Run one verb and return only `result`, throwing a typed error for a failed envelope.
|
|
292
|
-
*
|
|
293
|
-
* The thrown error carries `code` from the envelope, so a caller branches on the same vocabulary it
|
|
294
|
-
* would have branched on in a shell, while the message stays human-readable for the tool result.
|
|
295
|
-
*/
|
|
296
|
-
export async function invokeForResult(invocation, verb, args = [], flags = [], options = {}) {
|
|
297
|
-
const { envelope, stderr } = await invokeVerb(invocation, verb, args, flags, options)
|
|
298
|
-
if (envelope.ok === true) return envelope.result
|
|
299
|
-
const code = envelope.error?.code ?? 'E_INTERNAL'
|
|
300
|
-
const error = new Error(envelope.error?.message ?? `dsh-os verb "${verb}" failed`)
|
|
301
|
-
error.code = code
|
|
302
|
-
error.hint = envelope.error?.hint
|
|
303
|
-
error.verb = envelope.verb ?? verb
|
|
304
|
-
error.stderr = stderr
|
|
305
|
-
throw error
|
|
306
|
-
}
|