dsh-gitbash-shell 0.39.4 → 0.40.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/CHANGELOG.md +163 -1
- package/README.md +69 -33
- package/README_EN.md +38 -44
- package/docs/experimental-native-execution.md +224 -0
- package/docs/experimental-native-verification.md +184 -0
- package/dsh.plugin.json +1 -1
- package/package.json +4 -3
- package/src/bash-tool.js +5 -7
- package/src/client.js +73 -21
- package/src/experimental-tools.js +47 -0
- package/src/index.js +26 -20
- package/src/inline-tools.js +95 -8
- package/src/native-executor.js +283 -0
- package/src/native-output.js +347 -0
- package/src/native-tool.js +198 -0
- package/src/path-tool.js +297 -0
- package/src/session-workdir.js +12 -0
- package/src/shell.js +78 -76
package/src/inline-tools.js
CHANGED
|
@@ -101,14 +101,14 @@ export function inlineNeeds(names, { pwshVisible } = {}) {
|
|
|
101
101
|
* routing. Both hold regardless of the path dialect a delegate sees.
|
|
102
102
|
*/
|
|
103
103
|
export const GITBASH_ROUTE_ADDENDUM = [
|
|
104
|
-
'Prefer this bash tool for
|
|
105
|
-
'Fix failed bash
|
|
104
|
+
'Prefer this bash tool for shell syntax, scripts, pipelines, redirection, globbing and POSIX utilities; use the pwsh tool ONLY when PowerShell cmdlets, registry, services or WMI are truly required.',
|
|
105
|
+
'Fix failed bash syntax in bash; never embed `pwsh -Command` inside a bash command as a workaround (variable expansion and output encoding break there). Choose a separately offered native route for its documented purpose before execution, never as a policy-denial retry.',
|
|
106
106
|
].join('\n')
|
|
107
107
|
|
|
108
108
|
/** The bash-only routing variant, used while the pwsh switch is off (the default). */
|
|
109
109
|
export const GITBASH_ROUTE_ADDENDUM_NO_PWSH = [
|
|
110
|
-
'Prefer this bash tool for
|
|
111
|
-
'Fix failed
|
|
110
|
+
'Prefer this bash tool for shell syntax, scripts, pipelines, redirection, globbing and POSIX utilities — it is this host\'s shell.',
|
|
111
|
+
'Fix failed bash syntax in bash; no PowerShell tool is offered here. Do not invent a hidden tool or embed another interpreter as a workaround.',
|
|
112
112
|
].join('\n')
|
|
113
113
|
|
|
114
114
|
/**
|
|
@@ -118,6 +118,38 @@ export const GITBASH_ROUTE_ADDENDUM_NO_PWSH = [
|
|
|
118
118
|
*/
|
|
119
119
|
export const GITBASH_MSYS_ADDENDUM = 'This bash is Git for Windows (MSYS): paths use drive roots (/c/Users/...), and ~, /tmp, /dev/null and /usr/... resolve exactly as Git Bash resolves them.'
|
|
120
120
|
|
|
121
|
+
const ROUTING_START = '\n[gitbash tool routing]\n'
|
|
122
|
+
const ROUTING_END = '\n[/gitbash tool routing]'
|
|
123
|
+
const ROUTING_SAFETY = 'Choose the execution route before running. A policy/access denial is a stop condition: never switch tools, shells or permissions to bypass it. A decoding/display failure does not mean the command failed to run; preserve its exit facts and never blindly repeat a side-effecting command. Do not guess unknown or mixed encodings.'
|
|
124
|
+
const PTC_ROUTING = 'Inside run_code, call only bindings actually offered by the current SDK with await tools.<name>(...). Select by their descriptions: shell execution for shell syntax/pipelines; an offered typed native-executable binding for exact Windows argv or explicitly known per-stream encodings; an offered complete-path conversion binding before passing an MSYS value to Node fs/path. These are different operations, not substitutes for permissions. Never pass routes, search text or source code to a path converter. Program strings do not perform shell expansion. If a needed binding is absent, report the missing capability; do not invent its name. ' + ROUTING_SAFETY
|
|
125
|
+
|
|
126
|
+
/** Model guidance names only tools in this assembly, not a switch's intent. */
|
|
127
|
+
export function toolRoutingHintFor(tools, { pwshVisible = false } = {}) {
|
|
128
|
+
const names = new Set((Array.isArray(tools) ? tools : []).map(tool => tool?.name))
|
|
129
|
+
if (!names.has('bash')) return ''
|
|
130
|
+
const lines = [pwshVisible && names.has('pwsh') ? GITBASH_ROUTE_ADDENDUM : GITBASH_ROUTE_ADDENDUM_NO_PWSH]
|
|
131
|
+
if (names.has('gitbash_native')) lines.push('Use gitbash_native before execution when a Windows .exe needs exact literal argv such as /all or /c without MSYS conversion, or independently declared stdout/stderr encodings. Supply an absolute executable, mark each argument literal or path, and select only a known encoding (raw for unknown/mixed bytes). It does not implement shell pipelines, expansion or .cmd/.bat wrappers; only resolved danger-full-access can execute it.')
|
|
132
|
+
if (names.has('gitbash_to_native_path')) lines.push('Use gitbash_to_native_path only for a complete caller-declared MSYS filesystem path that a native Node fs/path API needs, especially a runtime-built value. Read native_path from the result. Never convert routes, search strings or shell/program source. Already-native or cwd-relative Node paths need no conversion; ordinary tool path fields already use their own translation. Conversion grants no access.')
|
|
133
|
+
lines.push(ROUTING_SAFETY)
|
|
134
|
+
return lines.join('\n')
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Add a replaceable, idempotent block; a reused assembly follows visibility. */
|
|
138
|
+
export function applyToolRouting(tools, { pwshVisible = false, msys = false } = {}) {
|
|
139
|
+
if (!Array.isArray(tools)) return
|
|
140
|
+
for (const tool of tools) {
|
|
141
|
+
if (!tool || typeof tool.description !== 'string' || !['bash', 'run_code'].includes(tool.name)) continue
|
|
142
|
+
let description = tool.description
|
|
143
|
+
const start = description.indexOf(ROUTING_START)
|
|
144
|
+
const end = start < 0 ? -1 : description.indexOf(ROUTING_END, start)
|
|
145
|
+
if (end >= 0) description = description.slice(0, start) + description.slice(end + ROUTING_END.length)
|
|
146
|
+
const hint = tool.name === 'bash'
|
|
147
|
+
? toolRoutingHintFor(tools, { pwshVisible }) + (msys ? '\n' + GITBASH_MSYS_ADDENDUM : '')
|
|
148
|
+
: PTC_ROUTING
|
|
149
|
+
tool.description = description + ROUTING_START + hint + ROUTING_END
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
121
153
|
/** @deprecated composed form kept for tests: route + MSYS sentences. */
|
|
122
154
|
export const GITBASH_BASH_ADDENDUM = GITBASH_ROUTE_ADDENDUM + '\n' + GITBASH_MSYS_ADDENDUM
|
|
123
155
|
|
|
@@ -158,7 +190,12 @@ export async function mountPrivateExecutor(ctx, GitBashSandboxExecutor, createSc
|
|
|
158
190
|
if (executorScope.fiber) await executorScope.fiber
|
|
159
191
|
const fiber = executorScope.ctx.plugin(GitBashSandboxExecutor)
|
|
160
192
|
await fiber
|
|
161
|
-
|
|
193
|
+
const executor = executorScope.ctx.get('shell')
|
|
194
|
+
const sandboxModule = await resolveHostModule(executorScope.ctx, '@deepseek-ai/dsh-sandbox')
|
|
195
|
+
if (typeof sandboxModule?.SandboxUnavailableError === 'function') {
|
|
196
|
+
executor.unavailableErrorType = sandboxModule.SandboxUnavailableError
|
|
197
|
+
}
|
|
198
|
+
return executor
|
|
162
199
|
})(),
|
|
163
200
|
mountTimeout,
|
|
164
201
|
])
|
|
@@ -198,6 +235,7 @@ export function mountInlineTools(ctx, deps) {
|
|
|
198
235
|
let closed = false
|
|
199
236
|
const isLive = (agent, state) => !closed && tracked.get(agent) === state && !state.controller.signal.aborted
|
|
200
237
|
const newState = () => ({ timer: undefined, scope: undefined, denyPwsh: undefined, pending: undefined, controller: new AbortController() })
|
|
238
|
+
const experimentalEnabled = () => deps.experimentalEnabled?.() !== false
|
|
201
239
|
// Loader imports cannot be cancelled, but no continuation may outlive its
|
|
202
240
|
// owner. Join our cancelled continuations without waiting on a stuck loader.
|
|
203
241
|
const untilDisposed = async (promise, signal) => {
|
|
@@ -247,6 +285,7 @@ export function mountInlineTools(ctx, deps) {
|
|
|
247
285
|
if (typeof createScope !== 'function') throw new Error('no createScope seam')
|
|
248
286
|
controller.signal.throwIfAborted()
|
|
249
287
|
const mounted = await mountPrivateExecutor(ctx, GitBashSandboxExecutor, createScope)
|
|
288
|
+
mounted.executor.experimentalEnabled = experimentalEnabled
|
|
250
289
|
executorScope = mounted.executorScope
|
|
251
290
|
controller.signal.throwIfAborted()
|
|
252
291
|
const hostModules = {}
|
|
@@ -255,7 +294,33 @@ export function mountInlineTools(ctx, deps) {
|
|
|
255
294
|
}
|
|
256
295
|
const { createBashToolModule } = await untilDisposed(import('./bash-tool.js'), controller.signal)
|
|
257
296
|
const module = createBashToolModule({ executor: mounted.executor, log, ...hostModules })
|
|
258
|
-
|
|
297
|
+
const extraModules = []
|
|
298
|
+
if (typeof deps.translatePath === 'function') {
|
|
299
|
+
const { createPathToolModule, PATH_TOOL_NAME } = await untilDisposed(import('./path-tool.js'), controller.signal)
|
|
300
|
+
extraModules.push({ name: PATH_TOOL_NAME, create: scope => createPathToolModule({
|
|
301
|
+
toolsModule: hostModules.toolsModule, translate: deps.translatePath, experimentalEnabled, log, scope,
|
|
302
|
+
}) })
|
|
303
|
+
try {
|
|
304
|
+
const localBashModule = await untilDisposed(resolveHostModule(ctx, '@deepseek-ai/dsh-bash-local'), controller.signal)
|
|
305
|
+
const outputModule = await untilDisposed(resolveHostModule(ctx, '@deepseek-ai/dsh-subprocess-local/output'), controller.signal)
|
|
306
|
+
if (typeof localBashModule?.LocalBashExecutor !== 'function'
|
|
307
|
+
|| typeof outputModule?.OutputCollector !== 'function') {
|
|
308
|
+
throw new Error('public native executor/OutputCollector capability unavailable')
|
|
309
|
+
}
|
|
310
|
+
const { createNativeOutputAdapter } = await untilDisposed(import('./native-output.js'), controller.signal)
|
|
311
|
+
const { createNativeToolModule } = await untilDisposed(import('./native-tool.js'), controller.signal)
|
|
312
|
+
extraModules.push({ name: 'gitbash_native', create: scope => createNativeToolModule({
|
|
313
|
+
toolsModule: hostModules.toolsModule, sandboxModule: hostModules.sandboxModule,
|
|
314
|
+
localBashModule, createScope, translateMsysPath: deps.translatePath,
|
|
315
|
+
createNativeOutputAdapter, outputOptions: { OutputCollector: outputModule?.OutputCollector },
|
|
316
|
+
createArtifactDirectory: deps.createArtifactDirectory, experimentalEnabled, log, scope,
|
|
317
|
+
}) })
|
|
318
|
+
} catch (error) {
|
|
319
|
+
if (controller.signal.aborted) throw error
|
|
320
|
+
log(`native tool capability unavailable; bash/path tools remain available: ${error?.message ?? error}`)
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return { executor: mounted.executor, executorScope, module, extraModules }
|
|
259
324
|
} catch (error) {
|
|
260
325
|
if (executorScope) await executorScope.dispose()
|
|
261
326
|
throw error
|
|
@@ -271,7 +336,7 @@ export function mountInlineTools(ctx, deps) {
|
|
|
271
336
|
let scope
|
|
272
337
|
let retained = false
|
|
273
338
|
try {
|
|
274
|
-
const { executor, module } = await untilDisposed(bashOncePromise(), state.controller.signal)
|
|
339
|
+
const { executor, module, extraModules } = await untilDisposed(bashOncePromise(), state.controller.signal)
|
|
275
340
|
if (!isLive(agent, state)) return
|
|
276
341
|
log(`own bash tool ready (private Git Bash executor at ${executor.bashPath || '(unresolved)'}; official pwsh executor untouched)`)
|
|
277
342
|
scope = createScope(agent.ctx)
|
|
@@ -297,6 +362,28 @@ export function mountInlineTools(ctx, deps) {
|
|
|
297
362
|
if (registeredNames.includes('bash') === false) {
|
|
298
363
|
throw new Error('bash tool did not register for this agent (module fiber INACTIVE — this host lacks an injected service the module requires — or the tools read-back is unavailable); bash is NOT available in this session, please report this with your dsh version (issue #27)')
|
|
299
364
|
}
|
|
365
|
+
for (const extra of extraModules ?? []) {
|
|
366
|
+
let extraFiber
|
|
367
|
+
try {
|
|
368
|
+
let applied = false
|
|
369
|
+
const module = extra.create(agent)
|
|
370
|
+
extraFiber = scope.ctx.plugin({ ...module, async apply(extraCtx, config) {
|
|
371
|
+
await module.apply(extraCtx, config)
|
|
372
|
+
applied = true
|
|
373
|
+
} })
|
|
374
|
+
await untilDisposed(extraFiber, state.controller.signal)
|
|
375
|
+
if (!isLive(agent, state)) return
|
|
376
|
+
if (!applied) {
|
|
377
|
+
throw new Error(`${extra.name} scope inactive (a required service may be missing)`)
|
|
378
|
+
}
|
|
379
|
+
// Registration/stand-down is reported by the shared live registrar.
|
|
380
|
+
log(`${extra.name} scope ready; registration follows experimentalNative and existing owners`)
|
|
381
|
+
} catch (error) {
|
|
382
|
+
if (!isLive(agent, state)) return
|
|
383
|
+
await extraFiber?.dispose()
|
|
384
|
+
log(`${extra.name} activation failed; bash remains available: ${error?.message ?? error}`)
|
|
385
|
+
}
|
|
386
|
+
}
|
|
300
387
|
retained = true
|
|
301
388
|
log('own bash tool activated for this agent (Git Bash execution; official pwsh tool real but hidden by default)')
|
|
302
389
|
} catch (error) {
|
|
@@ -403,7 +490,7 @@ export function mountInlineTools(ctx, deps) {
|
|
|
403
490
|
log(`agent bash tool cleanup failed: ${error?.message ?? error}`)
|
|
404
491
|
}))
|
|
405
492
|
ctx.on('loader/volatile-update', (paths) => {
|
|
406
|
-
if (!Array.isArray(paths) || !paths.some((path) => Array.isArray(path) && path[0]
|
|
493
|
+
if (!Array.isArray(paths) || !paths.some((path) => Array.isArray(path) && ['pwshTool', 'experimentalNative'].includes(path[0]))) return
|
|
407
494
|
for (const agent of tracked.keys()) {
|
|
408
495
|
try { reconcile(agent) } catch (error) { log(`pwsh switch update failed: ${error?.message ?? error}`) }
|
|
409
496
|
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { resolveSessionCwd } from './session-workdir.js'
|
|
3
|
+
|
|
4
|
+
// No host peer imports here: the owner supplies modules via its host loader.
|
|
5
|
+
const KEYS = new Set(['description', 'executable', 'argv', 'cwd', 'timeoutMs', 'run_in_background', 'entrypoint', 'stdoutEncoding', 'stderrEncoding', 'errors'])
|
|
6
|
+
const ENCODINGS = new Set(['utf8', 'gbk', 'utf16le', 'raw'])
|
|
7
|
+
// A usability guard for known names, NOT executable attestation or a sandbox:
|
|
8
|
+
// a renamed/custom .exe may implement its own language. Direct argv is not a
|
|
9
|
+
// promise about arbitrary Windows parsers, even after explicit opt-in.
|
|
10
|
+
const INTERPRETERS = /^(?:cmd|powershell|pwsh|bash|sh|dash|zsh|fish|wsl|wscript|cscript|mshta|node|nodejs|pythonw?(?:\d+(?:\.\d+)*)?|pypy(?:\d+)?|py|perl|ruby|php|lua|javaw?|jshell|dotnet|csi|tclsh|wish|rscript|busybox|deno|bun)(?:\.exe)?$/i
|
|
11
|
+
export const NATIVE_TIMEOUT_MS = 120000
|
|
12
|
+
export const NATIVE_MAX_TIMEOUT_MS = 600000
|
|
13
|
+
|
|
14
|
+
function object(value, label, allowed) {
|
|
15
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError(`${label}: expected an object`)
|
|
16
|
+
for (const key of Object.keys(value)) if (!allowed.has(key)) throw new TypeError(`${label}: unsupported field ${key}`)
|
|
17
|
+
}
|
|
18
|
+
function string(value, label, empty = false) {
|
|
19
|
+
if (typeof value !== 'string' || (!empty && value.trim() === '') || value.includes('\0')) throw new TypeError(`${label}: expected ${empty ? 'a' : 'a non-empty'} NUL-free string`)
|
|
20
|
+
return value
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Only explicitly typed paths cross the existing mount translator. No argv guessing. */
|
|
24
|
+
export function nativePath(input, translateMsysPath, platform = process.platform, label = 'path') {
|
|
25
|
+
object(input, label, new Set(['kind', 'value']))
|
|
26
|
+
if (input.kind !== 'path') throw new TypeError(`${label}: kind must be path`)
|
|
27
|
+
const value = string(input.value, label)
|
|
28
|
+
if (/^[~$]|%[^%]+%/.test(value)) throw new TypeError(`${label}: variable and home expansion are not supported; use an absolute path`)
|
|
29
|
+
if (platform !== 'win32') throw new Error('gitbash_native currently supports only the Windows local execution world')
|
|
30
|
+
// A rooted Windows path without a drive is not absolute in this contract.
|
|
31
|
+
const absolute = v => /^[a-z]:[\\/]/i.test(v) || /^\\\\[^\\]+\\[^\\]+/.test(v) || /^\/\/[^/]+\/[^/]+/.test(v)
|
|
32
|
+
const translated = absolute(value) ? value : typeof translateMsysPath === 'function' ? translateMsysPath(value) : value
|
|
33
|
+
if (typeof translated !== 'string' || translated.includes('\0') || !absolute(translated)) throw new TypeError(`${label}: unresolved or non-absolute typed path`)
|
|
34
|
+
return path.win32.normalize(translated)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Strict request validation is also applied outside ToolRuntime. */
|
|
38
|
+
export function parseNativeRequest(args, { translateMsysPath, platform = process.platform, sessionCwd } = {}) {
|
|
39
|
+
object(args, 'gitbash_native', KEYS)
|
|
40
|
+
const description = string(args.description, 'description')
|
|
41
|
+
const executable = nativePath(args.executable, translateMsysPath, platform, 'executable')
|
|
42
|
+
if (!/\.exe$/i.test(executable)) throw new TypeError('executable: only native .exe files are supported; .cmd/.bat and script files are never wrapped')
|
|
43
|
+
const entrypoint = args.entrypoint ?? 'native'
|
|
44
|
+
if (!['native', 'interpreter'].includes(entrypoint)) throw new TypeError('entrypoint: expected native or interpreter')
|
|
45
|
+
if (INTERPRETERS.test(path.win32.basename(executable)) && entrypoint !== 'interpreter') throw new TypeError('executable: a known interpreter requires explicit entrypoint: interpreter; its language and quoting remain your responsibility')
|
|
46
|
+
if (!Array.isArray(args.argv) || args.argv.length > 4096) throw new TypeError('argv: expected an array of at most 4096 typed arguments')
|
|
47
|
+
const argv = args.argv.map((arg, index) => {
|
|
48
|
+
object(arg, `argv[${index}]`, new Set(['kind', 'value']))
|
|
49
|
+
if (arg.kind === 'literal') return string(arg.value, `argv[${index}].value`, true)
|
|
50
|
+
if (arg.kind === 'path') return nativePath(arg, translateMsysPath, platform, `argv[${index}]`)
|
|
51
|
+
throw new TypeError(`argv[${index}]: kind must be literal or path`)
|
|
52
|
+
})
|
|
53
|
+
// Conservative bound before the provider's Windows command-line serialization.
|
|
54
|
+
if ([executable, ...argv].reduce((n, arg) => n + arg.length * 2 + 3, 0) > 30000) throw new TypeError('argv: conservative Windows command-line size limit exceeded')
|
|
55
|
+
const cwd = nativePath(args.cwd ?? { kind: 'path', value: sessionCwd }, translateMsysPath, platform, 'cwd')
|
|
56
|
+
if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) throw new TypeError('timeoutMs: expected a positive finite number')
|
|
57
|
+
if (args.run_in_background !== undefined && typeof args.run_in_background !== 'boolean') throw new TypeError('run_in_background: expected boolean')
|
|
58
|
+
if (args.run_in_background === true && args.timeoutMs !== undefined) throw new TypeError('timeoutMs is not supported with run_in_background; managed jobs have no tool deadline')
|
|
59
|
+
const stdoutEncoding = args.stdoutEncoding ?? 'utf8'
|
|
60
|
+
const stderrEncoding = args.stderrEncoding ?? 'utf8'
|
|
61
|
+
const errors = args.errors ?? 'replace'
|
|
62
|
+
if (!ENCODINGS.has(stdoutEncoding) || !ENCODINGS.has(stderrEncoding)) throw new TypeError('encoding: expected utf8, gbk, utf16le or raw; auto is not supported')
|
|
63
|
+
if (!['replace', 'strict'].includes(errors)) throw new TypeError('errors: expected replace or strict')
|
|
64
|
+
return Object.freeze({ description, executable, argv: Object.freeze(argv), cwd, entrypoint,
|
|
65
|
+
timeoutMs: Math.min(args.timeoutMs ?? NATIVE_TIMEOUT_MS, NATIVE_MAX_TIMEOUT_MS),
|
|
66
|
+
run_in_background: args.run_in_background === true,
|
|
67
|
+
output: Object.freeze({ stdoutEncoding, stderrEncoding, errors }),
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Resolve the *call's* standing policy, never the composition default. */
|
|
72
|
+
export function requireNativePolicy(ctx, exec, sandboxModule) {
|
|
73
|
+
const service = ctx.get('sandboxPolicy')
|
|
74
|
+
const policy = service?.resolve(exec?.agent === undefined ? {} : { session: exec.agent.session })
|
|
75
|
+
if (!policy || policy.mode !== 'danger-full-access') {
|
|
76
|
+
const detail = 'gitbash_native has no confined backend adapter. This entry refuses read-only, workspace-write and missing policy before spawn or artifact creation; do not bypass a denied operation. The user can select danger-full-access in the session access-mode control, or turn off Experimental native execution / fail-closed (experimentalNative) in Settings > Plugins > dsh-gitbash-shell to disable native/path tools and restore explicitly UNCONFINED legacy Bash.'
|
|
77
|
+
if (typeof sandboxModule?.SandboxUnavailableError === 'function') throw new sandboxModule.SandboxUnavailableError(policy?.mode ?? 'unknown', detail)
|
|
78
|
+
throw Object.assign(new Error(detail), { code: 'SANDBOX_UNAVAILABLE' })
|
|
79
|
+
}
|
|
80
|
+
return policy
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function checkedDshEnv(value) {
|
|
84
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError('shellEnv.collect must return managed environment facts')
|
|
85
|
+
const seen = new Set()
|
|
86
|
+
const result = {}
|
|
87
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
88
|
+
if (!/^DSH_/i.test(key) || key.includes('=') || key.includes('\0') || seen.has(key.toUpperCase())) throw new TypeError('shellEnv: invalid or conflicting managed environment key')
|
|
89
|
+
result[key] = string(entry, 'shellEnv managed fact', true)
|
|
90
|
+
seen.add(key.toUpperCase())
|
|
91
|
+
}
|
|
92
|
+
return Object.freeze(result)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function preparationTimeout() {
|
|
96
|
+
return Object.assign(new Error('gitbash_native preparation timed out before spawn; no native producer was started'), { code: 'NATIVE_PREPARATION_TIMEOUT' })
|
|
97
|
+
}
|
|
98
|
+
function cancellation(parent, timeoutMs) {
|
|
99
|
+
const controller = new AbortController()
|
|
100
|
+
const forward = () => controller.abort(parent.reason)
|
|
101
|
+
parent?.addEventListener('abort', forward, { once: true })
|
|
102
|
+
if (parent?.aborted) forward()
|
|
103
|
+
let timer
|
|
104
|
+
if (timeoutMs <= 0) controller.abort(preparationTimeout())
|
|
105
|
+
else timer = setTimeout(() => controller.abort(preparationTimeout()), timeoutMs)
|
|
106
|
+
return { controller, signal: controller.signal,
|
|
107
|
+
stopDeadline() { clearTimeout(timer) },
|
|
108
|
+
dispose() { clearTimeout(timer); parent?.removeEventListener('abort', forward) },
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async function whileActive(work, signal) {
|
|
112
|
+
signal.throwIfAborted()
|
|
113
|
+
let abort
|
|
114
|
+
const interrupted = new Promise((_, reject) => {
|
|
115
|
+
abort = () => reject(signal.reason)
|
|
116
|
+
signal.addEventListener('abort', abort, { once: true })
|
|
117
|
+
})
|
|
118
|
+
try { return await Promise.race([Promise.resolve().then(() => { signal.throwIfAborted(); return work() }), interrupted]) }
|
|
119
|
+
finally { signal.removeEventListener('abort', abort) }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Per-operation private Core executor. Only this operation's scope and handle are
|
|
124
|
+
* owned here. In particular, disposing it never disposes the root subprocess.
|
|
125
|
+
* Prepared requests are private capabilities, not caller-supplied policy DTOs.
|
|
126
|
+
*/
|
|
127
|
+
export function createNativeExecutor(ctx, deps) {
|
|
128
|
+
const { localBashModule, sandboxModule, createScope, translateMsysPath, createNativeOutputAdapter, outputOptions = {} } = deps
|
|
129
|
+
const LocalBashExecutor = localBashModule?.LocalBashExecutor
|
|
130
|
+
if (typeof LocalBashExecutor !== 'function' || typeof createScope !== 'function' || typeof createNativeOutputAdapter !== 'function') throw new Error('gitbash_native: required host executor/output/scope capability is unavailable')
|
|
131
|
+
const prepared = new WeakMap()
|
|
132
|
+
const requireEnabled = () => {
|
|
133
|
+
if (deps.experimentalEnabled?.() === false) throw new Error('gitbash_native is disabled by experimentalNative. Enable Experimental native execution / fail-closed in Settings > Plugins > dsh-gitbash-shell.')
|
|
134
|
+
}
|
|
135
|
+
const remaining = request => request.timeoutMs - (Date.now() - prepared.get(request))
|
|
136
|
+
return {
|
|
137
|
+
waitBudget: request => Math.max(1, remaining(request)),
|
|
138
|
+
async prepare(args, exec) {
|
|
139
|
+
requireEnabled()
|
|
140
|
+
const policy = requireNativePolicy(ctx, exec, sandboxModule)
|
|
141
|
+
exec.signal?.throwIfAborted()
|
|
142
|
+
if (args?.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) throw new TypeError('timeoutMs: expected a positive finite number')
|
|
143
|
+
const startedAt = Date.now()
|
|
144
|
+
const timeoutMs = Math.min(args?.timeoutMs ?? NATIVE_TIMEOUT_MS, NATIVE_MAX_TIMEOUT_MS)
|
|
145
|
+
const preparation = cancellation(exec.signal, timeoutMs)
|
|
146
|
+
try {
|
|
147
|
+
const sessionCwd = await whileActive(() => resolveSessionCwd(ctx, { ...exec, signal: preparation.signal }, policy), preparation.signal)
|
|
148
|
+
requireEnabled()
|
|
149
|
+
preparation.signal.throwIfAborted()
|
|
150
|
+
const request = parseNativeRequest(args, { translateMsysPath, sessionCwd, platform: deps.platform ?? process.platform })
|
|
151
|
+
const value = Object.freeze({ ...request, dshEnv: checkedDshEnv(ctx.shellEnv.collect(exec)) })
|
|
152
|
+
prepared.set(value, startedAt)
|
|
153
|
+
return value
|
|
154
|
+
} finally { preparation.dispose() }
|
|
155
|
+
},
|
|
156
|
+
async execute(request, exec, { signal = exec.signal, onExpiry = 'kill' } = {}) {
|
|
157
|
+
requireEnabled()
|
|
158
|
+
const policy = requireNativePolicy(ctx, exec, sandboxModule)
|
|
159
|
+
if (!prepared.has(request)) throw new TypeError('gitbash_native: request must be prepared by this executor')
|
|
160
|
+
if (!['kill', 'none'].includes(onExpiry)) throw new TypeError('invalid native expiry mode')
|
|
161
|
+
signal?.throwIfAborted()
|
|
162
|
+
const provider = ctx.get('subprocess')
|
|
163
|
+
if (typeof provider?.spawn !== 'function') throw new Error('gitbash_native: subprocess provider unavailable')
|
|
164
|
+
// Jobs registrations survive the tool fiber. Their operation scopes belong
|
|
165
|
+
// directly to the agent, not to a disposable tool registration fiber.
|
|
166
|
+
const owner = exec.agent?.ctx ?? ctx
|
|
167
|
+
const operation = cancellation(signal, remaining(request))
|
|
168
|
+
let scope
|
|
169
|
+
let handle
|
|
170
|
+
let scopeDisposed = false
|
|
171
|
+
let producerSettled = false
|
|
172
|
+
let proc
|
|
173
|
+
let resultPromise
|
|
174
|
+
const cancel = reason => {
|
|
175
|
+
if (producerSettled || operation.signal.aborted) return false
|
|
176
|
+
operation.controller.abort(Object.assign(new Error(reason), { code: 'NATIVE_OPERATION_CANCELLED' }))
|
|
177
|
+
return true
|
|
178
|
+
}
|
|
179
|
+
// OutputCollector DTOs may contain optional undefined fields. ToolRuntime
|
|
180
|
+
// requires lossless JSON, so project the adapter's facts at this boundary.
|
|
181
|
+
// Even a facts projection problem must not erase a producer's real exit.
|
|
182
|
+
const outputFacts = () => {
|
|
183
|
+
try { return JSON.parse(JSON.stringify(handle?.outputFacts?.() ?? { unavailable: true })) }
|
|
184
|
+
catch (error) { return { projectionError: String(error?.message ?? error), reliableText: false } }
|
|
185
|
+
}
|
|
186
|
+
const release = async () => {
|
|
187
|
+
if (scopeDisposed) return
|
|
188
|
+
scopeDisposed = true
|
|
189
|
+
try { await scope?.dispose() } finally { operation.dispose() }
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
operation.signal.throwIfAborted()
|
|
193
|
+
scope = createScope(owner.isolate('shell').isolate('subprocess'))
|
|
194
|
+
if (scope.fiber) await whileActive(() => scope.fiber, operation.signal)
|
|
195
|
+
operation.signal.throwIfAborted()
|
|
196
|
+
// Install ownership cancellation before any further preparation await.
|
|
197
|
+
scope.ctx.effect(() => async () => {
|
|
198
|
+
if (!producerSettled) cancel('native execution owner disposed')
|
|
199
|
+
if (!handle) return
|
|
200
|
+
handle.terminate()
|
|
201
|
+
if (typeof handle.waitForExit === 'function') await handle.waitForExit(AbortSignal.timeout(5000))
|
|
202
|
+
})
|
|
203
|
+
requireEnabled()
|
|
204
|
+
requireNativePolicy(ctx, exec, sandboxModule)
|
|
205
|
+
const outputConfig = { ...outputOptions, ...request.output, artifactPolicyValidated: true }
|
|
206
|
+
// Capability/encoding validation is pure and precedes directory creation.
|
|
207
|
+
createNativeOutputAdapter(provider, { ...outputConfig, artifactDirectory: undefined })
|
|
208
|
+
if (outputConfig.artifactDirectory === undefined) {
|
|
209
|
+
if (typeof deps.createArtifactDirectory !== 'function') throw new Error('gitbash_native: no trusted artifact directory owner is configured')
|
|
210
|
+
requireEnabled()
|
|
211
|
+
requireNativePolicy(ctx, exec, sandboxModule)
|
|
212
|
+
operation.signal.throwIfAborted()
|
|
213
|
+
outputConfig.artifactDirectory = await whileActive(async () => {
|
|
214
|
+
const directory = await deps.createArtifactDirectory(request, { ...exec, signal: operation.signal }, operation.signal)
|
|
215
|
+
if (operation.signal.aborted) {
|
|
216
|
+
// The directory owner retains late results under its normal retention
|
|
217
|
+
// policy. Never spawn from a late callback or delete a path still
|
|
218
|
+
// potentially owned/referenced by that trusted asynchronous producer.
|
|
219
|
+
deps.log?.(`gitbash_native: cancelled preparation retained its late artifact directory under owner retention: ${String(directory)}`)
|
|
220
|
+
}
|
|
221
|
+
return directory
|
|
222
|
+
}, operation.signal)
|
|
223
|
+
if (typeof outputConfig.artifactDirectory !== 'string') throw new Error('gitbash_native: artifact directory owner did not return a path')
|
|
224
|
+
}
|
|
225
|
+
requireNativePolicy(ctx, exec, sandboxModule)
|
|
226
|
+
operation.signal.throwIfAborted()
|
|
227
|
+
requireEnabled()
|
|
228
|
+
const adapter = createNativeOutputAdapter(provider, outputConfig)
|
|
229
|
+
const facade = { spawn(spec) {
|
|
230
|
+
requireEnabled()
|
|
231
|
+
requireNativePolicy(ctx, exec, sandboxModule)
|
|
232
|
+
spec.signal?.throwIfAborted()
|
|
233
|
+
handle = adapter.spawn(spec)
|
|
234
|
+
return handle
|
|
235
|
+
} }
|
|
236
|
+
scope.ctx.provide('subprocess', facade)
|
|
237
|
+
const mounted = scope.ctx.plugin(LocalBashExecutor)
|
|
238
|
+
await whileActive(() => mounted, operation.signal)
|
|
239
|
+
requireEnabled()
|
|
240
|
+
operation.signal.throwIfAborted()
|
|
241
|
+
const executor = scope.ctx.get('shell')
|
|
242
|
+
if (typeof executor?.executeArgv !== 'function') throw new Error('gitbash_native: host executeArgv contract is unavailable')
|
|
243
|
+
const budget = remaining(request)
|
|
244
|
+
if (budget <= 0) throw preparationTimeout()
|
|
245
|
+
const spec = executor.resolve({ command: request.description, workdir: request.cwd, timeoutMs: onExpiry === 'kill' ? budget : request.timeoutMs,
|
|
246
|
+
signal: operation.signal, onExpiry, dshEnv: request.dshEnv, sandboxPolicy: policy })
|
|
247
|
+
// Core owns runtime kill deadlines. For Jobs, onExpiry:none keeps running
|
|
248
|
+
// after the bounded preparation phase and the caller's promotion deadline.
|
|
249
|
+
operation.stopDeadline()
|
|
250
|
+
proc = await executor.executeArgv(spec, [request.executable, ...request.argv])
|
|
251
|
+
const originalResult = proc.result.bind(proc)
|
|
252
|
+
const originalDone = proc.done
|
|
253
|
+
// Keep the original producer exit/status even when decoding/storage fails.
|
|
254
|
+
const result = () => resultPromise ??= originalResult().then(value => ({ ...value, status: proc.status, timeoutMs: request.timeoutMs,
|
|
255
|
+
cwd: request.cwd, sandbox: { mode: policy.mode, denied: false },
|
|
256
|
+
outputFacts: outputFacts(),
|
|
257
|
+
}))
|
|
258
|
+
const finished = originalDone.then(async () => {
|
|
259
|
+
// Normal settlement must not be relabelled as an owner cancellation
|
|
260
|
+
// merely because its now-finished execution scope is being released.
|
|
261
|
+
producerSettled = true
|
|
262
|
+
try { await result() } catch { /* result() preserves provider rejection */ }
|
|
263
|
+
await release()
|
|
264
|
+
}, async error => { producerSettled = true; await release(); throw error })
|
|
265
|
+
// Ensure lifecycle cleanup is observed even before a foreground reader attaches.
|
|
266
|
+
finished.catch(() => {})
|
|
267
|
+
return { get status() { return proc.status }, get exitCode() { return proc.exitCode }, get signal() { return proc.signal },
|
|
268
|
+
observed: proc.observed, readOutput: () => proc.readOutput(), kill: () => {
|
|
269
|
+
if (proc.status !== 'running') return false
|
|
270
|
+
cancel('native execution explicitly killed')
|
|
271
|
+
return proc.kill()
|
|
272
|
+
},
|
|
273
|
+
done: finished, result: async () => { await finished; return result() },
|
|
274
|
+
outputFacts,
|
|
275
|
+
sandbox: { mode: policy.mode, denied: false },
|
|
276
|
+
}
|
|
277
|
+
} catch (error) {
|
|
278
|
+
await release()
|
|
279
|
+
throw error
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
}
|
|
283
|
+
}
|