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.
@@ -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 ALL commands; use the pwsh tool ONLY when a Windows-native capability (cmdlets, registry, services, WMI) is truly required.',
105
- 'Fix failed bash commands in bash — never switch shells mid-task, never embed `pwsh -Command` inside a bash command (variable expansion and output encoding break there).',
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 ALL commands — it is this host\'s shell.',
111
- 'Fix failed commands in bash; there is no PowerShell tool on this host.',
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
- return executorScope.ctx.get('shell')
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
- return { executor: mounted.executor, executorScope, module }
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] === 'pwshTool')) return
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
+ }