@hwfee/dsh-git-bash 0.0.0-stage → 0.1.0
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 +64 -2
- package/client/client.js +504 -0
- package/cordis.patch.yml +8 -0
- package/icon.svg +16 -0
- package/lib/index.js +558 -0
- package/lib/mode-route.js +204 -0
- package/lib/mode.js +244 -0
- package/lib/resolve.js +161 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +62 -4
package/lib/index.js
ADDED
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shell this plugin runs commands in, and the tool that asks it to.
|
|
3
|
+
*
|
|
4
|
+
* THE SEAM, and why this plugin does not touch it. `@deepseek-ai/dsh-shell`
|
|
5
|
+
* declares `ctx.shell` as a capability seam with ONE implementation per context
|
|
6
|
+
* ("loading a second throws, which is cordis' standard duplicate-service
|
|
7
|
+
* behavior"). On Windows the base bundle composes `pwsh-sandbox`; on POSIX it
|
|
8
|
+
* composes `bash-sandbox`. Both are `ctx.shell`.
|
|
9
|
+
*
|
|
10
|
+
* So the default-shell switch itself is the executor's job. What this plugin
|
|
11
|
+
* owns is the other half of the contract:
|
|
12
|
+
*
|
|
13
|
+
* 1. DECIDE which shell to use, from the user's `auto`/`pwsh`/`gitbash` mode.
|
|
14
|
+
* 2. PUBLISH that decision to the settings row and to the model, with the path
|
|
15
|
+
* it resolved to, so "which shell am I talking to" is never a guess.
|
|
16
|
+
* 3. EXPOSE `run_shell` — one command in the resolved shell, reporting which
|
|
17
|
+
* one ran it.
|
|
18
|
+
*
|
|
19
|
+
* @module @hwfee/dsh-git-bash
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { execFile } from 'node:child_process'
|
|
23
|
+
import { DEFAULT_MODE, MODES, chooseShell, readMode } from './mode.js'
|
|
24
|
+
import { createResolver, execAsync } from './resolve.js'
|
|
25
|
+
import { createModeRoute } from './mode-route.js'
|
|
26
|
+
export const name = 'dsh-git-bash'
|
|
27
|
+
|
|
28
|
+
/** The `tools` registry is the only hard requirement. */
|
|
29
|
+
export const inject = ['tools']
|
|
30
|
+
|
|
31
|
+
/** The tool the model calls. */
|
|
32
|
+
export const TOOL_NAME = 'run_shell'
|
|
33
|
+
|
|
34
|
+
/** The page global carrying the resolved shell, read by the settings row. */
|
|
35
|
+
export const SHELL_GLOBAL = '__dshGitBash'
|
|
36
|
+
|
|
37
|
+
/** The Cordis event the browser half publishes its mode on. */
|
|
38
|
+
export const MODE_EVENT = 'git-bash/mode'
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Register the tool, the mode listener, and the page injection.
|
|
42
|
+
*
|
|
43
|
+
* @param {object} ctx the host context
|
|
44
|
+
*/
|
|
45
|
+
export function apply(ctx) {
|
|
46
|
+
const resolver = createResolver()
|
|
47
|
+
|
|
48
|
+
// The user's mode. The page owns the preference (it is the side with the
|
|
49
|
+
// settings UI), so it publishes; the host caches. Default `auto` covers the
|
|
50
|
+
// window before the first publish — and it is also the right answer for a
|
|
51
|
+
// first launch, since the page has no stored choice yet.
|
|
52
|
+
//
|
|
53
|
+
// TWO CHANNELS, ON PURPOSE:
|
|
54
|
+
// - the Cordis event reaches in-process callers (tests, an embedding host);
|
|
55
|
+
// - the HTTP route reaches the BROWSER, which lives in another process.
|
|
56
|
+
// Without it the host stays on `auto` forever and the row lies.
|
|
57
|
+
let mode = DEFAULT_MODE
|
|
58
|
+
const setMode = (next) => {
|
|
59
|
+
if (typeof next === 'string' && MODES.includes(next)) mode = next
|
|
60
|
+
}
|
|
61
|
+
ctx.on(MODE_EVENT, (next) => {
|
|
62
|
+
if (next !== null && typeof next === 'object') setMode(next.mode)
|
|
63
|
+
})
|
|
64
|
+
ctx.inject(['webServer'], (hostCtx) => {
|
|
65
|
+
const webServer = hostCtx.webServer
|
|
66
|
+
if (webServer === undefined || typeof webServer.register !== 'function') return
|
|
67
|
+
// `getOrigin()` throws before the listener binds; the fence reads it lazily.
|
|
68
|
+
const origin = () => webServer.getOrigin()
|
|
69
|
+
hostCtx.effect(
|
|
70
|
+
() => webServer.register(createModeRoute(setMode, { origin, readMode: () => mode })),
|
|
71
|
+
'dsh-git-bash: mode route',
|
|
72
|
+
)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Resolve the current mode.
|
|
77
|
+
*
|
|
78
|
+
* Called on each use so a mode change applies to the NEXT call, but with a
|
|
79
|
+
* fresh memo per call: `auto` asks for two kinds, and both questions must see
|
|
80
|
+
* the same machine. A memo shared across calls would freeze the shell forever,
|
|
81
|
+
* which is the opposite of what a live setting needs.
|
|
82
|
+
*/
|
|
83
|
+
const current = () => resolveShell(mode, { resolve: memoized(resolver), platform: process.platform })
|
|
84
|
+
|
|
85
|
+
ctx.effect(
|
|
86
|
+
() => ctx.tools.register(createRunShellTool(current, {
|
|
87
|
+
// OPTIONAL ON PURPOSE. The host composes exactly one `ctx.shell` executor
|
|
88
|
+
// (pwsh-sandbox on Windows), but a profile may leave it out. `ctx.get`
|
|
89
|
+
// reads without an inject requirement, so the plugin still mounts and
|
|
90
|
+
// `run_shell` falls back to direct execution.
|
|
91
|
+
getShell: () => (typeof ctx.get === 'function' ? ctx.get('shell') : undefined),
|
|
92
|
+
getWorkingDirectory: () => (typeof ctx.get === 'function' ? ctx.get('workingDirectory') : undefined),
|
|
93
|
+
})),
|
|
94
|
+
'dsh-git-bash: run_shell tool',
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
// PUBLISH EVERY MODE'S RESOLUTION TO THE PAGE.
|
|
98
|
+
//
|
|
99
|
+
// The settings row needs to name what `auto` chose, and it is a BROWSER
|
|
100
|
+
// component: it cannot probe the filesystem. `webserver/index-inject` is the
|
|
101
|
+
// host's seam for a value the page needs before its first paint, computed
|
|
102
|
+
// where the process lives — the same seam cds-theme uses for its fonts base.
|
|
103
|
+
//
|
|
104
|
+
// SYNCHRONOUS AT INJECT TIME, deliberately: the inject fires while the HTML is
|
|
105
|
+
// assembled, so an async resolution that has not settled yet would inject
|
|
106
|
+
// `null` and the row would say "not found" on every launch. The probe costs
|
|
107
|
+
// ~77 ms once per page load, and it is memoized across the three modes.
|
|
108
|
+
//
|
|
109
|
+
// ALL THREE RESOLUTIONS, not just the active one.
|
|
110
|
+
//
|
|
111
|
+
// The page needs to re-render the status line the moment the user picks a
|
|
112
|
+
// different mode, and it cannot probe the filesystem. Injecting only the
|
|
113
|
+
// ACTIVE choice made the row contradict itself: the trigger said "Windows
|
|
114
|
+
// PowerShell" while the line under it still named the Git Bash path the page
|
|
115
|
+
// had been booted with. It also means the row can say what `auto` WOULD pick
|
|
116
|
+
// on this machine, which is the question a user choosing between the modes
|
|
117
|
+
// actually has.
|
|
118
|
+
ctx.on('webserver/index-inject', (table) => {
|
|
119
|
+
const probe = memoized(resolver)
|
|
120
|
+
const byMode = {}
|
|
121
|
+
for (const candidate of MODES) {
|
|
122
|
+
byMode[candidate] = describe(resolveShell(candidate, { resolve: probe, platform: process.platform }), candidate)
|
|
123
|
+
}
|
|
124
|
+
table.push({
|
|
125
|
+
kind: 'global',
|
|
126
|
+
name: SHELL_GLOBAL,
|
|
127
|
+
value: { active: byMode[mode], byMode },
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Resolve one mode against this machine.
|
|
134
|
+
*
|
|
135
|
+
* THE HOST-SIDE ENTRY POINT. `mode.js` holds the decision table but is shared
|
|
136
|
+
* with the browser bundle, so it cannot import `node:child_process`; the
|
|
137
|
+
* executable probe lives in `resolve.js`. This is the one call that joins them,
|
|
138
|
+
* used by the tool and the injection alike so they can never disagree.
|
|
139
|
+
*
|
|
140
|
+
* The result is a VALUE, never a throw: `{ ok, mode, kind, path, source,
|
|
141
|
+
* reason }`. `source` explains WHICH branch ran — under `auto` that is the only
|
|
142
|
+
* evidence a fallback happened.
|
|
143
|
+
*
|
|
144
|
+
* @param {'auto' | 'pwsh' | 'gitbash'} [mode] the selected mode
|
|
145
|
+
* @param {object} [deps]
|
|
146
|
+
* @param {(kind: string) => object | null} [deps.resolve] a resolver, injected for tests
|
|
147
|
+
* @param {string} [deps.platform] `process.platform`, injected for tests
|
|
148
|
+
* @returns {{ ok: boolean, mode: string, kind: string | null, path: string | null, source: string, reason: string | null }}
|
|
149
|
+
*/
|
|
150
|
+
export function resolveShell(mode = DEFAULT_MODE, deps = {}) {
|
|
151
|
+
const platform = deps.platform ?? process.platform
|
|
152
|
+
const resolver = deps.resolve ?? memoized(createResolver({ platform }))
|
|
153
|
+
const decision = chooseShell(mode, { resolve: resolver, platform })
|
|
154
|
+
if (decision.ok !== true) {
|
|
155
|
+
return { ok: false, mode, kind: null, path: null, source: '', reason: decision.reason }
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
ok: true,
|
|
159
|
+
mode,
|
|
160
|
+
kind: decision.shell.kind,
|
|
161
|
+
path: decision.shell.path,
|
|
162
|
+
source: decision.shell.source,
|
|
163
|
+
reason: null,
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Turn a resolution into the shape the page reads.
|
|
169
|
+
*
|
|
170
|
+
* A failure is not an empty value: the row must be able to say WHY nothing runs,
|
|
171
|
+
* and `reason` is the only place that survives the JSON injection. `source` and
|
|
172
|
+
* `kind` are what make `auto` readable — "git --exec-path (auto)" versus
|
|
173
|
+
* "known path (auto fallback)" is the whole difference between "the setting
|
|
174
|
+
* worked" and "the setting quietly did something else".
|
|
175
|
+
*
|
|
176
|
+
* @param {{ ok: boolean, path: string | null, kind: string | null, source: string, reason: string | null }} result
|
|
177
|
+
* the resolution
|
|
178
|
+
* @param {string} mode the mode it was made under
|
|
179
|
+
* @returns {object} the page payload
|
|
180
|
+
*/
|
|
181
|
+
export function describe(result, mode) {
|
|
182
|
+
if (result.ok !== true) {
|
|
183
|
+
return { mode, path: null, kind: null, source: null, reason: result.reason }
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
mode,
|
|
187
|
+
path: result.path,
|
|
188
|
+
kind: result.kind,
|
|
189
|
+
source: result.source,
|
|
190
|
+
reason: null,
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The model-facing tool.
|
|
196
|
+
*
|
|
197
|
+
* `current` is a FUNCTION, not a value: the user can change the mode while a
|
|
198
|
+
* session is open, and the next call must use the new shell.
|
|
199
|
+
*
|
|
200
|
+
* @param {() => ({ ok: boolean, path: string | null, kind: string | null, reason: string | null })} current
|
|
201
|
+
* resolves the shell under the active mode
|
|
202
|
+
* @param {object} [services] optional host services, read lazily
|
|
203
|
+
* @param {() => object | undefined} [services.getShell] `ctx.shell`, the host executor
|
|
204
|
+
* @param {() => object | undefined} [services.getWorkingDirectory] `ctx.workingDirectory`
|
|
205
|
+
* @returns the tool definition `ctx.tools.register` expects
|
|
206
|
+
*/
|
|
207
|
+
export function createRunShellTool(current, services = {}) {
|
|
208
|
+
return {
|
|
209
|
+
name: TOOL_NAME,
|
|
210
|
+
description:
|
|
211
|
+
'Run one shell command and return its output. '
|
|
212
|
+
+ 'The interpreter is whatever the user configured: Git Bash (POSIX), '
|
|
213
|
+
+ 'PowerShell, or automatic. The result names the executable that ran it, so '
|
|
214
|
+
+ 'check that line before assuming which quoting and variable syntax apply. '
|
|
215
|
+
+ 'Each call runs in a fresh shell in the working directory; pass an absolute '
|
|
216
|
+
+ 'path instead of relying on a previous `cd`.',
|
|
217
|
+
// RAW JSON SCHEMA. `ctx.tools.register()` stores this as the wire schema and
|
|
218
|
+
// hands it to the provider verbatim; the `defineTool` author dialect
|
|
219
|
+
// (`{ name: { type, required: true } }`) is rejected at request time with
|
|
220
|
+
// `is not of type "object"`. See docs/genui-audit.md for that incident.
|
|
221
|
+
parameters: {
|
|
222
|
+
type: 'object',
|
|
223
|
+
additionalProperties: false,
|
|
224
|
+
required: ['command'],
|
|
225
|
+
properties: {
|
|
226
|
+
command: {
|
|
227
|
+
type: 'string',
|
|
228
|
+
description: 'The command to run. Passed to the resolved shell as a single command string.',
|
|
229
|
+
},
|
|
230
|
+
workdir: {
|
|
231
|
+
type: 'string',
|
|
232
|
+
description: 'Absolute directory to run in. Defaults to the session working directory.',
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
output: {
|
|
237
|
+
schema: {
|
|
238
|
+
type: 'object',
|
|
239
|
+
additionalProperties: false,
|
|
240
|
+
required: ['shell', 'exitCode', 'stdout'],
|
|
241
|
+
properties: {
|
|
242
|
+
shell: { type: 'string' },
|
|
243
|
+
kind: { type: 'string' },
|
|
244
|
+
exitCode: { type: 'integer' },
|
|
245
|
+
stdout: { type: 'string' },
|
|
246
|
+
stderr: { type: 'string' },
|
|
247
|
+
// WHERE THE COMMAND RAN. 'host' crossed ctx.shell (sandbox, approval
|
|
248
|
+
// and cancellation semantics apply); 'direct' used the plugin's own
|
|
249
|
+
// spawn because the host cannot start this shell. The renderer always
|
|
250
|
+
// spells this out — a silently unconfined command would be the worst
|
|
251
|
+
// possible failure mode.
|
|
252
|
+
via: { type: 'string' },
|
|
253
|
+
note: { type: 'string' },
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
render(_args, value) {
|
|
257
|
+
// THE PATH IS ALWAYS STATED. "host" means the command crossed
|
|
258
|
+
// `ctx.shell` (sandbox / cancellation apply); "direct" means the plugin
|
|
259
|
+
// spawned it itself because the host cannot start this shell. Leaving
|
|
260
|
+
// the host case unmarked made the two indistinguishable in the result.
|
|
261
|
+
const route = value.via === 'host' ? ' · host' : value.via === 'direct' ? ' · direct' : ''
|
|
262
|
+
const parts = [`[${value.kind} · ${value.shell}] exit ${value.exitCode}${route}`]
|
|
263
|
+
if (typeof value.note === 'string' && value.note !== '') parts.push(value.note)
|
|
264
|
+
if (value.stdout !== '') parts.push(value.stdout)
|
|
265
|
+
if (value.stderr !== '') parts.push('stderr: ' + value.stderr)
|
|
266
|
+
return [{ type: 'text', text: parts.join('\n') }]
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
async execute(args, exec) {
|
|
270
|
+
const resolved = current()
|
|
271
|
+
if (resolved.ok !== true) {
|
|
272
|
+
throw new Error(messageFor(resolved.reason))
|
|
273
|
+
}
|
|
274
|
+
const workdir = await resolveWorkdir(args, exec, services.getWorkingDirectory)
|
|
275
|
+
const shell = {
|
|
276
|
+
kind: resolved.kind,
|
|
277
|
+
path: resolved.path,
|
|
278
|
+
...(workdir === undefined ? {} : { workdir }),
|
|
279
|
+
}
|
|
280
|
+
const platform = services.platform ?? process.platform
|
|
281
|
+
const hostShell = typeof services.getShell === 'function' ? services.getShell() : undefined
|
|
282
|
+
const hasHost = hostShell !== undefined
|
|
283
|
+
&& typeof hostShell.execute === 'function'
|
|
284
|
+
&& typeof hostShell.resolve === 'function'
|
|
285
|
+
|
|
286
|
+
// WHICH MODES MAY CROSS THE HOST BOUNDARY.
|
|
287
|
+
//
|
|
288
|
+
// PowerShell does: the host executor IS pwsh on Windows, and nesting a
|
|
289
|
+
// pwsh child through it works. Git Bash on Windows does NOT: the sandboxed
|
|
290
|
+
// pwsh cannot create the MSYS process (NtCreateDirectoryObject,
|
|
291
|
+
// 0xC0000022 — reproduced live), so delegating would turn every command
|
|
292
|
+
// into a spawn failure. POSIX has one shell, so it always delegates.
|
|
293
|
+
//
|
|
294
|
+
// The direct path is the pre-delegation behaviour. It is REPORTED (via
|
|
295
|
+
// 'direct' + a note), never silent: a user reading the tool result must be
|
|
296
|
+
// able to tell that this call did not cross the sandbox.
|
|
297
|
+
const delegate = shouldDelegate(shell.kind, platform, hasHost)
|
|
298
|
+
if (delegate) return runThroughHost(hostShell, shell, args, exec)
|
|
299
|
+
|
|
300
|
+
const direct = await runInShell({ kind: shell.kind, path: shell.path }, { ...args, workdir: shell.workdir })
|
|
301
|
+
const note = !hasHost
|
|
302
|
+
? 'Ran directly: this profile has no ctx.shell executor.'
|
|
303
|
+
: 'Ran directly: the Windows host sandbox cannot start Git Bash (nested MSYS process creation is denied), so this command did not cross the sandbox.'
|
|
304
|
+
return { ...direct, via: 'direct', note }
|
|
305
|
+
},
|
|
306
|
+
presentCall: (args) => ({
|
|
307
|
+
card: 'generic',
|
|
308
|
+
title: typeof args?.command === 'string' ? args.command.slice(0, 80) : 'Run shell command',
|
|
309
|
+
kind: 'execute',
|
|
310
|
+
rawInput: args?.command,
|
|
311
|
+
}),
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* The sentence a user reads when the chosen shell is missing.
|
|
317
|
+
*
|
|
318
|
+
* Each reason names the fix, because the failure is always one of three
|
|
319
|
+
* configuration facts and never the command's fault.
|
|
320
|
+
*
|
|
321
|
+
* @param {string} reason the failure code
|
|
322
|
+
* @returns {string} the message
|
|
323
|
+
*/
|
|
324
|
+
export function messageFor(reason) {
|
|
325
|
+
switch (reason) {
|
|
326
|
+
case 'gitbash-not-found':
|
|
327
|
+
return 'Git Bash is selected but was not found. Install Git for Windows, or set '
|
|
328
|
+
+ 'DSH_GIT_BASH to its bash.exe. (The plugin will not silently switch to PowerShell: '
|
|
329
|
+
+ 'POSIX commands would fail in confusing ways under it.)'
|
|
330
|
+
case 'pwsh-not-found':
|
|
331
|
+
return 'PowerShell is selected but neither pwsh.exe nor Windows PowerShell was found.'
|
|
332
|
+
case 'no-shell-for-platform':
|
|
333
|
+
return 'The selected shell mode is not available on this platform.'
|
|
334
|
+
case 'no-shell-found':
|
|
335
|
+
return 'No usable shell was found. Install Git for Windows, or set DSH_GIT_BASH.'
|
|
336
|
+
default:
|
|
337
|
+
return 'The configured shell could not be started: ' + reason
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Run one command in a resolved shell.
|
|
343
|
+
*
|
|
344
|
+
* HOW THE COMMAND IS PASSED depends on the family, and getting it wrong is
|
|
345
|
+
* silent: `-lc` is bash's "login shell, run this string", while PowerShell's
|
|
346
|
+
* equivalent is `-NoProfile -Command`. Handing `-lc` to PowerShell makes it read
|
|
347
|
+
* `-l` as a switch and complain about an unknown argument.
|
|
348
|
+
*
|
|
349
|
+
* A NONZERO EXIT IS NOT AN ERROR: that is the command's own result, and the
|
|
350
|
+
* model needs to see it (`grep` that matched nothing exits 1).
|
|
351
|
+
*
|
|
352
|
+
* @param {{ kind: string, path: string }} shell the executable
|
|
353
|
+
* @param {{ command: string, workdir?: string }} args the call arguments
|
|
354
|
+
* @param {(path: string, args: string[], cwd?: string) => Promise<object>} [run]
|
|
355
|
+
* the executor, injected for tests
|
|
356
|
+
* @returns {Promise<object>} the tool result
|
|
357
|
+
*/
|
|
358
|
+
export async function runInShell(shell, args, run = defaultRun) {
|
|
359
|
+
const command = typeof args?.command === 'string' ? args.command : ''
|
|
360
|
+
if (command.trim() === '') throw new Error('run_shell needs a non-empty `command`')
|
|
361
|
+
|
|
362
|
+
const argv = shell.kind === 'gitbash' || shell.kind === 'posix-bash'
|
|
363
|
+
? ['-lc', command]
|
|
364
|
+
: ['-NoProfile', '-NonInteractive', '-Command', command]
|
|
365
|
+
|
|
366
|
+
const outcome = await run(shell.path, argv, args?.workdir)
|
|
367
|
+
return {
|
|
368
|
+
shell: shell.path,
|
|
369
|
+
kind: shell.kind,
|
|
370
|
+
exitCode: outcome.exitCode,
|
|
371
|
+
stdout: outcome.stdout,
|
|
372
|
+
stderr: outcome.stderr,
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Quote one string for PowerShell's `-Command` argument.
|
|
378
|
+
*
|
|
379
|
+
* A single-quoted PowerShell string is literal; the ONLY escape is doubling an
|
|
380
|
+
* inner quote. Newlines are legal inside it, so an arbitrary command string
|
|
381
|
+
* survives without the parser re-reading `$`, `;` or `&`.
|
|
382
|
+
*
|
|
383
|
+
* @param {string} value the literal to carry
|
|
384
|
+
* @returns {string} a single-quoted PowerShell string
|
|
385
|
+
*/
|
|
386
|
+
export function quotePowerShell(value) {
|
|
387
|
+
return "'" + String(value == null ? '' : value).replace(/'/g, "''") + "'"
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Quote one string for a POSIX shell's `-lc` argument.
|
|
392
|
+
*
|
|
393
|
+
* @param {string} value the literal to carry
|
|
394
|
+
* @returns {string} a single-quoted shell word
|
|
395
|
+
*/
|
|
396
|
+
export function quoteSh(value) {
|
|
397
|
+
return "'" + String(value == null ? '' : value).replace(/'/g, "'\\''") + "'"
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Whether one resolved shell can run through the host executor.
|
|
402
|
+
*
|
|
403
|
+
* Extracted so the decision is testable without spawning anything. See the
|
|
404
|
+
* call site for why Git Bash on Windows is excluded: the sandbox cannot create
|
|
405
|
+
* the MSYS process, so delegation would fail every command.
|
|
406
|
+
*
|
|
407
|
+
* @param {string} kind the resolved shell kind
|
|
408
|
+
* @param {string} platform `process.platform`
|
|
409
|
+
* @param {boolean} hasHost whether a usable `ctx.shell` is composed
|
|
410
|
+
* @returns {boolean} whether to call `runThroughHost`
|
|
411
|
+
*/
|
|
412
|
+
export function shouldDelegate(kind, platform, hasHost) {
|
|
413
|
+
if (hasHost !== true) return false
|
|
414
|
+
if (platform === 'win32') return kind === 'pwsh' || kind === 'windows-powershell'
|
|
415
|
+
return kind === 'posix-bash'
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* The command a HOST EXECUTOR runs to start the SELECTED interpreter.
|
|
420
|
+
*
|
|
421
|
+
* Why not just hand the user's command to `ctx.shell`: on Windows the executor
|
|
422
|
+
* is always pwsh, and the mode may have chosen Git Bash. The delegation has to
|
|
423
|
+
* name the executable explicitly, or "Git Bash mode" would quietly run
|
|
424
|
+
* PowerShell. The quoting above is what makes an arbitrary command (quotes,
|
|
425
|
+
* `$`, `&&`, newlines) survive the extra shell layer.
|
|
426
|
+
*
|
|
427
|
+
* @param {{ kind: string, path: string }} shell the selected interpreter
|
|
428
|
+
* @param {string} command the user's command
|
|
429
|
+
* @param {string} [platform] `process.platform`
|
|
430
|
+
* @returns {string} the host-executor command string
|
|
431
|
+
*/
|
|
432
|
+
export function delegatedCommand(shell, command, platform = process.platform) {
|
|
433
|
+
const script = String(command == null ? '' : command)
|
|
434
|
+
if (platform === 'win32') {
|
|
435
|
+
const exe = quotePowerShell(shell.path)
|
|
436
|
+
if (shell.kind === 'gitbash' || shell.kind === 'posix-bash') {
|
|
437
|
+
return '& ' + exe + ' -lc ' + quotePowerShell(script)
|
|
438
|
+
}
|
|
439
|
+
return '& ' + exe + ' -NoProfile -NonInteractive -Command ' + quotePowerShell(script)
|
|
440
|
+
}
|
|
441
|
+
return quoteSh(shell.path) + ' -lc ' + quoteSh(script)
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Run one command through the host's shell executor.
|
|
446
|
+
*
|
|
447
|
+
* The executor owns sandboxing, approval facts, cancellation and process
|
|
448
|
+
* lifetime; this function only translates the request and the result into
|
|
449
|
+
* `run_shell`'s shape. `result()` rejects on infrastructure failures, which
|
|
450
|
+
* propagate to the tool layer like any other tool error.
|
|
451
|
+
*
|
|
452
|
+
* @param {object} hostShell the `ctx.shell` service
|
|
453
|
+
* @param {{ kind: string, path: string, workdir?: string }} shell the selected interpreter
|
|
454
|
+
* @param {{ command?: string, workdir?: string }} args the call arguments
|
|
455
|
+
* @param {{ signal?: AbortSignal }} [exec] the tool execution context
|
|
456
|
+
* @returns {Promise<object>} the tool result
|
|
457
|
+
*/
|
|
458
|
+
export async function runThroughHost(hostShell, shell, args, exec = {}) {
|
|
459
|
+
const command = typeof args?.command === 'string' ? args.command : ''
|
|
460
|
+
if (command.trim() === '') throw new Error('run_shell needs a non-empty `command`')
|
|
461
|
+
const workdir = typeof args?.workdir === 'string' && args.workdir !== ''
|
|
462
|
+
? args.workdir
|
|
463
|
+
: shell.workdir
|
|
464
|
+
const request = {
|
|
465
|
+
command: delegatedCommand(shell, command, process.platform),
|
|
466
|
+
...(workdir === undefined ? {} : { workdir }),
|
|
467
|
+
...(exec.signal === undefined ? {} : { signal: exec.signal }),
|
|
468
|
+
}
|
|
469
|
+
const handle = await hostShell.execute(hostShell.resolve(request))
|
|
470
|
+
const result = await handle.result()
|
|
471
|
+
return {
|
|
472
|
+
shell: shell.path,
|
|
473
|
+
kind: shell.kind,
|
|
474
|
+
via: 'host',
|
|
475
|
+
// A signal kill has no exit code. Keep the integer contract the schema
|
|
476
|
+
// declares; the renderer states the signal separately when it matters.
|
|
477
|
+
exitCode: Number.isInteger(result?.exitCode) ? result.exitCode : -1,
|
|
478
|
+
stdout: typeof result?.stdout?.text === 'string' ? result.stdout.text : '',
|
|
479
|
+
stderr: typeof result?.stderr?.text === 'string' ? result.stderr.text : '',
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Resolve the working directory for one call.
|
|
485
|
+
*
|
|
486
|
+
* The description promises "the session working directory" when `workdir` is
|
|
487
|
+
* absent. The host's `workingDirectory.ensure()` is where that fact lives; it is
|
|
488
|
+
* optional (a minimal profile has no such service), in which case the executor
|
|
489
|
+
* keeps its own default.
|
|
490
|
+
*
|
|
491
|
+
* @param {{ workdir?: string }} args the call arguments
|
|
492
|
+
* @param {{ agent?: object, signal?: AbortSignal }} [exec] the tool execution context
|
|
493
|
+
* @param {() => object | undefined} [getService] reads `ctx.workingDirectory`
|
|
494
|
+
* @returns {Promise<string | undefined>} the directory, when one is known
|
|
495
|
+
*/
|
|
496
|
+
export async function resolveWorkdir(args, exec, getService) {
|
|
497
|
+
if (typeof args?.workdir === 'string' && args.workdir !== '') return args.workdir
|
|
498
|
+
const service = typeof getService === 'function' ? getService() : undefined
|
|
499
|
+
if (service === undefined || exec?.agent === undefined || typeof service.ensure !== 'function') return undefined
|
|
500
|
+
try {
|
|
501
|
+
return await service.ensure(exec.agent, exec.signal)
|
|
502
|
+
} catch {
|
|
503
|
+
return undefined
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Run an argv and resolve the outcome instead of rejecting on a nonzero exit.
|
|
509
|
+
*
|
|
510
|
+
* @param {string} command the executable
|
|
511
|
+
* @param {string[]} args its arguments
|
|
512
|
+
* @param {string} [cwd] working directory
|
|
513
|
+
* @returns {Promise<{ exitCode: number, stdout: string, stderr: string }>}
|
|
514
|
+
*/
|
|
515
|
+
function defaultRun(command, args, cwd) {
|
|
516
|
+
return new Promise((resolve, reject) => {
|
|
517
|
+
execFile(command, args, {
|
|
518
|
+
cwd: cwd === undefined ? undefined : cwd,
|
|
519
|
+
windowsHide: true,
|
|
520
|
+
timeout: 120_000,
|
|
521
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
522
|
+
}, (error, stdout, stderr) => {
|
|
523
|
+
// A spawn failure has no exit code and no output; that is the only case
|
|
524
|
+
// worth rejecting.
|
|
525
|
+
if (error !== null && typeof error.code !== 'number') {
|
|
526
|
+
reject(error)
|
|
527
|
+
return
|
|
528
|
+
}
|
|
529
|
+
resolve({
|
|
530
|
+
exitCode: error === null ? 0 : error.code,
|
|
531
|
+
stdout: String(stdout ?? ''),
|
|
532
|
+
stderr: String(stderr ?? ''),
|
|
533
|
+
})
|
|
534
|
+
})
|
|
535
|
+
})
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export { execAsync, readMode }
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Memoize a resolver for one resolution pass.
|
|
542
|
+
*
|
|
543
|
+
* The injection asks for all three modes, and `auto` itself asks for two kinds.
|
|
544
|
+
* Without this, one page load would spawn `git --exec-path` three times and
|
|
545
|
+
* `where pwsh` twice — the probe is the expensive part (~77 ms), not the choice.
|
|
546
|
+
* A FRESH memo per pass, not a module-level one: a cache that outlives the call
|
|
547
|
+
* would answer tomorrow's question with today's machine.
|
|
548
|
+
*
|
|
549
|
+
* @param {(kind: string) => object | null} resolve
|
|
550
|
+
* @returns {(kind: string) => object | null}
|
|
551
|
+
*/
|
|
552
|
+
function memoized(resolve) {
|
|
553
|
+
const seen = new Map()
|
|
554
|
+
return (kind) => {
|
|
555
|
+
if (!seen.has(kind)) seen.set(kind, resolve(kind))
|
|
556
|
+
return seen.get(kind)
|
|
557
|
+
}
|
|
558
|
+
}
|