dsh-ops 0.0.0-stage → 0.2.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.
Files changed (139) hide show
  1. package/CHANGELOG.md +189 -0
  2. package/LICENSE +30 -0
  3. package/NOTICE +106 -0
  4. package/PROVENANCE.md +417 -0
  5. package/README.en.md +121 -0
  6. package/README.md +113 -2
  7. package/README.zh.md +114 -0
  8. package/bin/dsh-ops.mjs +1216 -0
  9. package/cordis.patch.yml +160 -0
  10. package/docs/manual-validation.md +53 -0
  11. package/docs/schema-baseline.json +64 -0
  12. package/docs/schema-current.json +84 -0
  13. package/docs/schema-measurement.md +17 -0
  14. package/dsh-plugin.json +88 -0
  15. package/icon.svg +12 -0
  16. package/lib/binary.js +409 -0
  17. package/lib/config.js +198 -0
  18. package/lib/handshake.js +252 -0
  19. package/lib/index.js +108 -0
  20. package/lib/jobs.js +42 -0
  21. package/lib/policy.js +64 -0
  22. package/lib/presentation.js +63 -0
  23. package/lib/profile-install.js +61 -0
  24. package/lib/rust.js +194 -0
  25. package/lib/session-shells.js +78 -0
  26. package/lib/shells.js +993 -0
  27. package/lib/tools.js +657 -0
  28. package/locale/en.json +6 -0
  29. package/locale/zh.json +6 -0
  30. package/package.json +114 -4
  31. package/vendor/fastctx/Cargo.lock +3210 -0
  32. package/vendor/fastctx/Cargo.toml +94 -0
  33. package/vendor/fastctx/FORK.md +119 -0
  34. package/vendor/fastctx/LICENSE-APACHE +201 -0
  35. package/vendor/fastctx/NOTICE +40 -0
  36. package/vendor/fastctx/README.md +439 -0
  37. package/vendor/fastctx/THIRD_PARTY_LICENSES.md +17 -0
  38. package/vendor/fastctx/THIRD_PARTY_LICENSES_RUST.md +7914 -0
  39. package/vendor/fastctx/UPSTREAM.md +49 -0
  40. package/vendor/fastctx/build.rs +413 -0
  41. package/vendor/fastctx/src/background_status.rs +403 -0
  42. package/vendor/fastctx/src/binary.rs +75 -0
  43. package/vendor/fastctx/src/bounded_sort.rs +500 -0
  44. package/vendor/fastctx/src/budget.rs +781 -0
  45. package/vendor/fastctx/src/cli/mod.rs +110 -0
  46. package/vendor/fastctx/src/context_guard.rs +289 -0
  47. package/vendor/fastctx/src/control/mod.rs +6 -0
  48. package/vendor/fastctx/src/control/paths.rs +49 -0
  49. package/vendor/fastctx/src/control/settings.rs +753 -0
  50. package/vendor/fastctx/src/control/transaction.rs +531 -0
  51. package/vendor/fastctx/src/edit/document.rs +535 -0
  52. package/vendor/fastctx/src/edit/locks.rs +371 -0
  53. package/vendor/fastctx/src/edit/mod.rs +213 -0
  54. package/vendor/fastctx/src/edit/private_storage/unix.rs +315 -0
  55. package/vendor/fastctx/src/edit/private_storage/windows.rs +793 -0
  56. package/vendor/fastctx/src/edit/private_storage.rs +234 -0
  57. package/vendor/fastctx/src/edit/replace.rs +1030 -0
  58. package/vendor/fastctx/src/edit_server.rs +53 -0
  59. package/vendor/fastctx/src/encoding/reference_v011.rs +587 -0
  60. package/vendor/fastctx/src/encoding/snapshot_pipeline.rs +1678 -0
  61. package/vendor/fastctx/src/encoding.rs +1118 -0
  62. package/vendor/fastctx/src/file_executor.rs +1151 -0
  63. package/vendor/fastctx/src/file_snapshot.rs +1491 -0
  64. package/vendor/fastctx/src/glob_filter.rs +98 -0
  65. package/vendor/fastctx/src/glob_tool.rs +653 -0
  66. package/vendor/fastctx/src/grep_sink.rs +1162 -0
  67. package/vendor/fastctx/src/grep_tool.rs +2449 -0
  68. package/vendor/fastctx/src/lib.rs +45 -0
  69. package/vendor/fastctx/src/main.rs +15 -0
  70. package/vendor/fastctx/src/model.rs +51 -0
  71. package/vendor/fastctx/src/model_guidance.rs +62 -0
  72. package/vendor/fastctx/src/operation.rs +356 -0
  73. package/vendor/fastctx/src/ordered_window.rs +1235 -0
  74. package/vendor/fastctx/src/os_environment.rs +414 -0
  75. package/vendor/fastctx/src/path_codec.rs +850 -0
  76. package/vendor/fastctx/src/paths.rs +244 -0
  77. package/vendor/fastctx/src/process_identity.rs +763 -0
  78. package/vendor/fastctx/src/process_policy.rs +74 -0
  79. package/vendor/fastctx/src/read_tool/batch.rs +496 -0
  80. package/vendor/fastctx/src/read_tool/hex_file.rs +141 -0
  81. package/vendor/fastctx/src/read_tool/image_file.rs +88 -0
  82. package/vendor/fastctx/src/read_tool/mod.rs +245 -0
  83. package/vendor/fastctx/src/read_tool/pdf.rs +470 -0
  84. package/vendor/fastctx/src/read_tool/pdf_disabled.rs +47 -0
  85. package/vendor/fastctx/src/read_tool/pdf_engine.rs +664 -0
  86. package/vendor/fastctx/src/read_tool/text_file.rs +351 -0
  87. package/vendor/fastctx/src/render_plan.rs +468 -0
  88. package/vendor/fastctx/src/runtime/activity.rs +159 -0
  89. package/vendor/fastctx/src/runtime/hosts.rs +99 -0
  90. package/vendor/fastctx/src/runtime/journal.rs +556 -0
  91. package/vendor/fastctx/src/runtime/local_ipc.rs +186 -0
  92. package/vendor/fastctx/src/runtime/mod.rs +746 -0
  93. package/vendor/fastctx/src/runtime/protocol.rs +296 -0
  94. package/vendor/fastctx/src/runtime/session.rs +536 -0
  95. package/vendor/fastctx/src/runtime/windows_process.rs +66 -0
  96. package/vendor/fastctx/src/search_parallelism.rs +106 -0
  97. package/vendor/fastctx/src/search_text.rs +227 -0
  98. package/vendor/fastctx/src/server.rs +359 -0
  99. package/vendor/fastctx/src/server_manifest.rs +468 -0
  100. package/vendor/fastctx/src/server_support.rs +826 -0
  101. package/vendor/fastctx/src/session.rs +629 -0
  102. package/vendor/fastctx/src/shell/apply_patch_hint.rs +41 -0
  103. package/vendor/fastctx/src/shell/bash.rs +263 -0
  104. package/vendor/fastctx/src/shell/buffer.rs +108 -0
  105. package/vendor/fastctx/src/shell/encoding.rs +403 -0
  106. package/vendor/fastctx/src/shell/foreground.rs +115 -0
  107. package/vendor/fastctx/src/shell/jobs/admission.rs +91 -0
  108. package/vendor/fastctx/src/shell/jobs/background.rs +146 -0
  109. package/vendor/fastctx/src/shell/jobs/host.rs +830 -0
  110. package/vendor/fastctx/src/shell/jobs/identity.rs +29 -0
  111. package/vendor/fastctx/src/shell/jobs/mod.rs +1513 -0
  112. package/vendor/fastctx/src/shell/jobs/model.rs +244 -0
  113. package/vendor/fastctx/src/shell/jobs/output_log.rs +1148 -0
  114. package/vendor/fastctx/src/shell/jobs/store.rs +1300 -0
  115. package/vendor/fastctx/src/shell/mod.rs +345 -0
  116. package/vendor/fastctx/src/shell/normalize.rs +389 -0
  117. package/vendor/fastctx/src/shell/output.rs +406 -0
  118. package/vendor/fastctx/src/shell/process.rs +493 -0
  119. package/vendor/fastctx/src/shell_server.rs +156 -0
  120. package/vendor/fastctx/src/skip_report.rs +83 -0
  121. package/vendor/fastctx/src/stdio_transport.rs +177 -0
  122. package/vendor/fastctx/src/tool_schema.rs +204 -0
  123. package/vendor/fastctx/src/traversal.rs +846 -0
  124. package/vendor/fastctx/third-party/pdfium-7763/LICENSE +9 -0
  125. package/vendor/fastctx/third-party/pdfium-7763/licenses/abseil.txt +202 -0
  126. package/vendor/fastctx/third-party/pdfium-7763/licenses/agg23.txt +14 -0
  127. package/vendor/fastctx/third-party/pdfium-7763/licenses/fast_float.txt +27 -0
  128. package/vendor/fastctx/third-party/pdfium-7763/licenses/freetype.txt +169 -0
  129. package/vendor/fastctx/third-party/pdfium-7763/licenses/icu.txt +542 -0
  130. package/vendor/fastctx/third-party/pdfium-7763/licenses/lcms.txt +27 -0
  131. package/vendor/fastctx/third-party/pdfium-7763/licenses/libjpeg_turbo.ijg +260 -0
  132. package/vendor/fastctx/third-party/pdfium-7763/licenses/libjpeg_turbo.md +135 -0
  133. package/vendor/fastctx/third-party/pdfium-7763/licenses/libopenjpeg.txt +32 -0
  134. package/vendor/fastctx/third-party/pdfium-7763/licenses/libpng.txt +134 -0
  135. package/vendor/fastctx/third-party/pdfium-7763/licenses/libtiff.txt +21 -0
  136. package/vendor/fastctx/third-party/pdfium-7763/licenses/llvm-libc.txt +278 -0
  137. package/vendor/fastctx/third-party/pdfium-7763/licenses/pdfium.txt +230 -0
  138. package/vendor/fastctx/third-party/pdfium-7763/licenses/simdutf.txt +18 -0
  139. package/vendor/fastctx/third-party/pdfium-7763/licenses/zlib.txt +29 -0
package/lib/shells.js ADDED
@@ -0,0 +1,993 @@
1
+ /**
2
+ * The two shell rungs below FastCtx: the plugin's own bash and PowerShell 7.
3
+ *
4
+ * This module is the ONLY place that answers "can this deployment run the
5
+ * plugin's bash / pwsh right now, and where is the executable". It is pure and
6
+ * read-only: it probes configured paths, the provisioned copies
7
+ * `dsh-ops provision-shells` installed under `<DSH_HOME>/dsh-ops/shells`, the
8
+ * copies a plugin package carries, and the conventional install locations, and
9
+ * it NEVER mutates ambient state (no PATH writes, no profile writes, no
10
+ * `process.chdir`). A rung that cannot be resolved reports `available: false`
11
+ * with the reason; it never throws, so a missing shell degrades the prompt
12
+ * ladder instead of failing the plugin.
13
+ *
14
+ * The binaries themselves are not redistributed: {@link SHELL_UPSTREAM_PINS}
15
+ * names the upstream release and digest each shell is pinned to, and
16
+ * `dsh-ops provision-shells` is what turns those pointers into a local,
17
+ * digest-checked copy.
18
+ *
19
+ * Contract (both halves are implemented beside each other):
20
+ *
21
+ * - {@link resolveShells} is called synchronously during mount and its result
22
+ * feeds the prompt ladder (`lib/policy.js` renders only the available rungs).
23
+ * - {@link publishShellTools} is called inside one `ctx.effect` during mount and
24
+ * returns the disposer for whatever tool registrations it made (today: the
25
+ * `ops_bash` tool backed by the plugin's own bash). Returning a no-op disposer
26
+ * is valid.
27
+ *
28
+ * Why L2 publishes a tool instead of configuring an executor: `bash-local` has
29
+ * no executable field (`packages/shell/bash-local/src/index.ts` — its `Config`
30
+ * is `cwd`/`timeoutMs`/`maxTimeoutMs`/`maxOutputBytes`/`maxSpillBytes`/
31
+ * `graceMs` only), so the only zero-internal-dependency way to run the plugin's
32
+ * own bash is to publish `ops_bash` ourselves and execute through
33
+ * `ctx.subprocess`. L3 needs no tool: `pwsh-sandbox` inherits `pwshPath`
34
+ * verbatim (`packages/shell/pwsh-sandbox/src/index.ts:40`,
35
+ * `type Config = LocalConfig`), so `cordis.patch.yml` points that row at the
36
+ * executable this module resolves — provisioned copy first, bundled layouts
37
+ * after it, in the order {@link PWSH_LAYOUT_ORDER} names. That is also why L3
38
+ * has no `available` beyond "the executable is there": the rung and the host row
39
+ * are the same fact, and a config key could not reach the row anyway (a bundle
40
+ * patch is evaluated before this plugin's own row is mounted).
41
+ *
42
+ * Invariants this module keeps (PLAN §2.7):
43
+ * - the child environment is exactly the terminal overrides; credential-shaped
44
+ * names and `DSH_*` facts are never forwarded by it
45
+ * (`packages/subprocess/README.md:80`);
46
+ * - `process.env` is read, never written; `process.chdir` is never called;
47
+ * - a rung that cannot be resolved reports `available: false` and publishes
48
+ * nothing — losing the bash rung must never fail plugin activation.
49
+ *
50
+ * @module dsh-ops/shells
51
+ */
52
+
53
+ import fs from 'node:fs'
54
+ import path from 'node:path'
55
+ import { createRequire } from 'node:module'
56
+ import { PACKAGE_ROOT, dshHome } from './binary.js'
57
+ import { publicToolName } from './policy.js'
58
+
59
+ /** The directory this module was loaded from; the resolution root of a checkout. */
60
+ const MODULE_ROOT = PACKAGE_ROOT
61
+
62
+ /** Model-facing name of the tool this module publishes for the bash rung. */
63
+ export const BASH_TOOL_NAME = publicToolName('bash')
64
+
65
+ /** Default foreground deadline for one `ops_bash` command, in milliseconds. */
66
+ export const DEFAULT_BASH_TIMEOUT_MS = 120_000
67
+
68
+ /** Upper bound for a per-call `timeoutMs`, in milliseconds. */
69
+ export const MAX_BASH_TIMEOUT_MS = 2_147_483_647
70
+
71
+ /**
72
+ * Grace period handed to `ctx.subprocess` for its SIGTERM→SIGKILL escalation,
73
+ * matching the upstream bash executor's default
74
+ * (`packages/shell/bash-local/src/index.ts:35`).
75
+ */
76
+ export const DEFAULT_GRACE_MS = 3_000
77
+
78
+ /** Per-stream in-memory cap, matching the upstream executor default. */
79
+ export const DEFAULT_MAX_OUTPUT_BYTES = 64_000
80
+
81
+ /** Per-stream spill-file cap; a larger stream keeps only its in-memory tail. */
82
+ export const DEFAULT_MAX_SPILL_BYTES = 64 * 1024 * 1024
83
+
84
+ /**
85
+ * Model-friendly environment overrides: disable colors, pagers, and interactive
86
+ * terminal features that would garble tool output. This is the upstream bash
87
+ * executor's set verbatim (`packages/shell/bash-local/src/index.ts:27-32`),
88
+ * passed as an explicit `env`, which the subprocess service merges AFTER its
89
+ * ambient credential scrub.
90
+ */
91
+ export const ENV_OVERRIDES = Object.freeze({
92
+ NO_COLOR: '1',
93
+ TERM: 'dumb',
94
+ PAGER: 'cat',
95
+ GIT_PAGER: 'cat',
96
+ })
97
+
98
+ /**
99
+ * The platform packages a deployment MAY carry a shell in, and the subdirectory
100
+ * each keeps its shell in. Nothing requires them: the pinned upstream download
101
+ * (`dsh-ops provision-shells`) is the route that needs no package at all, and
102
+ * this layout is still probed so a deployment that installed one — or a checkout
103
+ * that vendors its own copy — is honoured. {@link BUNDLED_LAYOUT_ORDER} names
104
+ * the order of the two bundled layouts.
105
+ */
106
+ export const PLATFORM_PACKAGES = Object.freeze({
107
+ bash: { prefix: '@dsh-ops/bash-', executable: 'bash.exe', vendor: 'bash' },
108
+ pwsh: { prefix: '@dsh-ops/pwsh-', executable: 'pwsh.exe', vendor: 'pwsh' },
109
+ })
110
+
111
+ /**
112
+ * The upstream release each bundled shell is pinned to.
113
+ *
114
+ * THIS TABLE IS THE SINGLE SOURCE for "which upstream bytes does this
115
+ * distribution point at". `dsh-ops provision-shells` downloads exactly these
116
+ * assets and checks exactly these digests, and the release build imports the
117
+ * same entries to publish the pinned facts beside the packages it assembles —
118
+ * so a version, a URL, or a digest is edited in one place.
119
+ *
120
+ * - `sha256` is the digest of the archive exactly as published upstream;
121
+ * - `executableSha256` is the digest of the file at `executableRelativePath`
122
+ * inside it, which the provisioner re-checks after unpacking;
123
+ * - `extractor` says how the archive is opened (`zip` through Windows' own
124
+ * bsdtar, `sfx-7z` by running the archive's own self-extractor) and
125
+ * `extractTo` where the archive's ROOT lands inside the version directory, so
126
+ * the executable ends up at `<extractTo>/<file name>`.
127
+ *
128
+ * The two layouts differ on purpose: the PowerShell zip holds `pwsh.exe` at its
129
+ * root and is unpacked into `bin/`, while PortableGit's self-extracting archive
130
+ * holds the whole Git tree — `bin/bash.exe` included — and is unpacked at the
131
+ * version root.
132
+ */
133
+ export const SHELL_UPSTREAM_PINS = Object.freeze([
134
+ Object.freeze({
135
+ name: 'bash',
136
+ label: 'Git for Windows PortableGit',
137
+ platform: 'win32',
138
+ arch: 'x64',
139
+ upstreamRepo: 'https://github.com/git-for-windows/git',
140
+ releaseTag: 'v2.56.0.windows.2',
141
+ version: '2.56.0.2',
142
+ assetFile: 'PortableGit-2.56.0.2-64-bit.7z.exe',
143
+ url: 'https://github.com/git-for-windows/git/releases/download/v2.56.0.windows.2/PortableGit-2.56.0.2-64-bit.7z.exe',
144
+ sha256: '075e158ef8e1f0ab80b347e245405d3eca735c2dc88fd8e032e137d0ca61f61b',
145
+ bytes: 60_027_568,
146
+ license: 'GPL-2.0-only',
147
+ extractor: 'sfx-7z',
148
+ extractTo: '.',
149
+ executableRelativePath: 'bin/bash.exe',
150
+ executableSha256: '6cc575e9112efe6253b6a6999c12aa0240cb3f511aeda3480ae026edc0dc5280',
151
+ }),
152
+ Object.freeze({
153
+ name: 'pwsh',
154
+ label: 'PowerShell',
155
+ platform: 'win32',
156
+ arch: 'x64',
157
+ upstreamRepo: 'https://github.com/PowerShell/PowerShell',
158
+ releaseTag: 'v7.6.6',
159
+ version: '7.6.6',
160
+ assetFile: 'PowerShell-7.6.6-win-x64.zip',
161
+ url: 'https://github.com/PowerShell/PowerShell/releases/download/v7.6.6/PowerShell-7.6.6-win-x64.zip',
162
+ sha256: '02fe458be20493fbdf43f61ea20610b811ee6c738ab1676c61b9cfcd1a33c860',
163
+ bytes: 106_328_873,
164
+ license: 'MIT',
165
+ extractor: 'zip',
166
+ extractTo: 'bin',
167
+ executableRelativePath: 'bin/pwsh.exe',
168
+ executableSha256: 'bfb46af89433268872ddb43d1ca7a3f433452ee91ed356a9786940f90118e285',
169
+ }),
170
+ ])
171
+
172
+ /**
173
+ * The pin for one shell on one platform.
174
+ * @param {object} options - the lookup.
175
+ * @param {'bash'|'pwsh'} options.kind - which shell.
176
+ * @param {string} [options.platform] - `process.platform`.
177
+ * @param {string} [options.arch] - `process.arch`.
178
+ * @returns {object|undefined} the pin, or undefined when this platform has none.
179
+ */
180
+ export function shellUpstreamPin({ kind, platform = process.platform, arch = process.arch }) {
181
+ return SHELL_UPSTREAM_PINS.find((pin) => (
182
+ pin.name === kind && pin.platform === platform && pin.arch === arch
183
+ ))
184
+ }
185
+
186
+ /**
187
+ * The provisioned-shell store: where `dsh-ops provision-shells` installs the
188
+ * copies the plugin prefers over anything the machine happens to have.
189
+ * @param {object} [options] - resolution inputs.
190
+ * @param {NodeJS.ProcessEnv} [options.env] - the environment to read `DSH_HOME` from.
191
+ * @returns {string} the absolute directory (`<DSH_HOME>/dsh-ops/shells`).
192
+ */
193
+ export function provisionedShellStore({ env = process.env } = {}) {
194
+ return path.join(dshHome(env), 'dsh-ops', 'shells')
195
+ }
196
+
197
+ /**
198
+ * The version directory one pin provisions into.
199
+ * @param {object} options - resolution inputs.
200
+ * @param {'bash'|'pwsh'} options.kind - which shell.
201
+ * @param {string} options.version - the pin's version.
202
+ * @param {NodeJS.ProcessEnv} [options.env] - the environment to read `DSH_HOME` from.
203
+ * @returns {string} the absolute directory (`<store>/<name>/<version>`).
204
+ */
205
+ export function provisionedShellVersionDir({ kind, version, env = process.env }) {
206
+ return path.join(provisionedShellStore({ env }), kind, version)
207
+ }
208
+
209
+ /**
210
+ * Every place this plugin's own PowerShell 7 can come from, most preferred
211
+ * first — the order `cordis.patch.yml`'s L3 expression implements, in the same
212
+ * order and with the same existence rule.
213
+ * @type {readonly ['provisioned', 'package', 'vendor']}
214
+ */
215
+ export const PWSH_LAYOUT_ORDER = Object.freeze(['provisioned', 'package', 'vendor'])
216
+
217
+ /**
218
+ * One shell rung's resolution.
219
+ * @typedef {object} ShellResolution
220
+ * @property {boolean} available - whether this rung can run right now.
221
+ * @property {string|undefined} file - the resolved executable, when available.
222
+ * @property {string} source - where the answer came from, for reports and tests.
223
+ * One of `disabled`, `config`, `provisioned`, `bundled`, `path`, `well-known`,
224
+ * `missing`.
225
+ * @property {string} detail - a one-line English explanation, for reports.
226
+ */
227
+
228
+ /**
229
+ * Whether a candidate names something this process can spawn.
230
+ *
231
+ * `lstat` is used deliberately: a symlink (the Node shape of a Windows Store
232
+ * app-execution alias) is a valid executable even when its target is not
233
+ * statable, a real directory never is, and any unexpected error answers `false`
234
+ * rather than throwing out of a load-time probe.
235
+ * @param {string|undefined} file - the candidate path.
236
+ * @returns {boolean} whether the candidate exists and is not a directory.
237
+ */
238
+ function isSpawnableFile(file) {
239
+ if (typeof file !== 'string' || file === '') return false
240
+ try {
241
+ const stat = fs.lstatSync(file)
242
+ return stat.isFile() || stat.isSymbolicLink()
243
+ } catch {
244
+ return false
245
+ }
246
+ }
247
+
248
+ /**
249
+ * The `<platform>-<arch>` infix both the platform packages and the vendored
250
+ * layout use (`win32-x64`).
251
+ * @param {string} platform - `process.platform`.
252
+ * @param {string} arch - `process.arch`.
253
+ * @returns {string} the key.
254
+ */
255
+ function platformKey(platform, arch) {
256
+ return `${platform}-${arch}`
257
+ }
258
+
259
+ /**
260
+ * The two layouts the plugin's own shell can arrive in, in the order they are
261
+ * preferred. Phase C publishes `@dsh-ops/<kind>-<platform>-<arch>` (the shell
262
+ * at `bin/<exe>`); a checkout that vendors its own copy uses
263
+ * `vendor/<kind>/<platform>-<arch>/<exe>`.
264
+ *
265
+ * The ORDER is part of the contract, not an implementation detail: the L3
266
+ * `!!js` override in `cordis.patch.yml` probes the same two places in the same
267
+ * order (it cannot import this module — a bundle patch is plain YAML), so the
268
+ * ladder's `available`/`file` and the host row's executable must agree for
269
+ * every layout a deployment can present. `test/shells.test.mjs` asserts that
270
+ * agreement against both layouts.
271
+ * @type {readonly ['package', 'vendor']}
272
+ */
273
+ export const BUNDLED_LAYOUT_ORDER = Object.freeze(['package', 'vendor'])
274
+
275
+ /**
276
+ * The vendored bundled shell inside the package, whether or not it exists.
277
+ * @param {object} options - resolution inputs.
278
+ * @param {'bash'|'pwsh'} options.kind - which rung.
279
+ * @param {string} options.bundleRoot - the plugin package root.
280
+ * @param {string} options.platform - `process.platform`.
281
+ * @param {string} options.arch - `process.arch`.
282
+ * @returns {string} the path the bundled copy would occupy.
283
+ */
284
+ export function bundledShellPath({ kind, bundleRoot, platform, arch }) {
285
+ const target = PLATFORM_PACKAGES[kind]
286
+ return path.join(bundleRoot, 'vendor', target.vendor, platformKey(platform, arch), target.executable)
287
+ }
288
+
289
+ /**
290
+ * Where the installed platform package's shell would live, when that package is
291
+ * resolvable at all.
292
+ * @param {object} options - resolution inputs.
293
+ * @param {'bash'|'pwsh'} options.kind - which rung.
294
+ * @param {string} options.bundleRoot - the plugin package root.
295
+ * @param {string} options.platform - `process.platform`.
296
+ * @param {string} options.arch - `process.arch`.
297
+ * @returns {{found: true, file: string}|{found: false, detail: string}} the candidate.
298
+ */
299
+ function platformPackageShell({ kind, bundleRoot, platform, arch }) {
300
+ const target = PLATFORM_PACKAGES[kind]
301
+ const name = `${target.prefix}${platformKey(platform, arch)}`
302
+ let declared
303
+ try {
304
+ const manifest = JSON.parse(fs.readFileSync(path.join(bundleRoot, 'package.json'), 'utf8'))
305
+ declared = { ...manifest.optionalDependencies, ...manifest.dependencies }
306
+ } catch {
307
+ return { found: false, detail: `${name} is not installed (no readable plugin manifest)` }
308
+ }
309
+ if (declared[name] === undefined) {
310
+ return { found: false, detail: `${name} is not installed` }
311
+ }
312
+ let directory
313
+ try {
314
+ const require = createRequire(path.join(bundleRoot, 'package.json'))
315
+ directory = path.dirname(require.resolve(`${name}/package.json`))
316
+ } catch {
317
+ return { found: false, detail: `${name} is declared but not installed` }
318
+ }
319
+ const file = path.join(directory, 'bin', target.executable)
320
+ return isSpawnableFile(file)
321
+ ? { found: true, file }
322
+ : { found: false, detail: `${name} is installed without bin/${target.executable}` }
323
+ }
324
+
325
+ /**
326
+ * Pick the bundled shell from the layout candidates, first existing wins.
327
+ *
328
+ * This is the ONE preference rule both the probe and the patch expression
329
+ * implement; see {@link BUNDLED_LAYOUT_ORDER}.
330
+ * @param {(string|undefined)[]} candidates - candidate paths, most preferred first.
331
+ * @returns {string|undefined} the first existing candidate.
332
+ */
333
+ export function preferredBundledFile(candidates) {
334
+ for (const candidate of candidates) {
335
+ if (isSpawnableFile(candidate)) return candidate
336
+ }
337
+ return undefined
338
+ }
339
+
340
+ /**
341
+ * The plugin's own copy of one shell, from whichever layout carries it: the
342
+ * installed platform package first, then the vendored subdirectory.
343
+ * @param {object} options - resolution inputs.
344
+ * @param {'bash'|'pwsh'} options.kind - which rung.
345
+ * @param {string} options.bundleRoot - the plugin package root.
346
+ * @param {string} options.platform - `process.platform`.
347
+ * @param {string} options.arch - `process.arch`.
348
+ * @returns {{file: string|undefined, detail: string|undefined}} the executable
349
+ * and, when there is none, why the plugin's own copy is unusable.
350
+ */
351
+ export function bundledShell({ kind, bundleRoot, platform, arch }) {
352
+ const packaged = platformPackageShell({ kind, bundleRoot, platform, arch })
353
+ const vendored = bundledShellPath({ kind, bundleRoot, platform, arch })
354
+ // The same preference rule, in the same order, as the L3 patch expression.
355
+ const file = preferredBundledFile([packaged.found ? packaged.file : undefined, vendored])
356
+ return { file, detail: file === undefined ? packaged.detail : undefined }
357
+ }
358
+
359
+ /**
360
+ * The provisioned copy of one shell, out of the plugin-managed store.
361
+ *
362
+ * Layout: `<DSH_HOME>/dsh-ops/shells/<kind>/<version>/<executableRelativePath>`
363
+ * — the directory `dsh-ops provision-shells` installs the pinned upstream
364
+ * release into. Every version directory that actually holds the executable is a
365
+ * candidate, and the greatest version name wins.
366
+ *
367
+ * That comparison IS the whole rule, deliberately: `cordis.patch.yml`'s L3
368
+ * expression implements the identical one, because a bundle patch is plain YAML
369
+ * and cannot import this module, and the ladder's PowerShell rung and the host's
370
+ * `pwsh-sandbox` row must keep pointing at the same file. The store holds one
371
+ * version per shell in normal use; a leftover version directory is what the
372
+ * comparison is there for.
373
+ *
374
+ * A copy installed here was digest-checked against {@link SHELL_UPSTREAM_PINS}
375
+ * when it arrived, which is why it outranks anything already on the machine
376
+ * while still sitting below `config.<kind>Path` and below a system fallback's
377
+ * own off switch.
378
+ * @param {object} options - resolution inputs.
379
+ * @param {'bash'|'pwsh'} options.kind - which rung.
380
+ * @param {NodeJS.ProcessEnv} options.env - the environment to read `DSH_HOME` from.
381
+ * @param {string} [options.platform] - `process.platform`.
382
+ * @param {string} [options.arch] - `process.arch`.
383
+ * @returns {{file: string, version: string}|undefined} the executable and the
384
+ * version directory it came from, or undefined when the store has none.
385
+ */
386
+ export function provisionedShell({ kind, env, platform = process.platform, arch = process.arch }) {
387
+ const store = path.join(provisionedShellStore({ env }), kind)
388
+ let versions
389
+ try {
390
+ versions = fs.readdirSync(store, { withFileTypes: true })
391
+ .filter((entry) => entry.isDirectory())
392
+ .map((entry) => entry.name)
393
+ .sort()
394
+ .reverse()
395
+ } catch {
396
+ return undefined
397
+ }
398
+ const relative = shellUpstreamPin({ kind, platform, arch })?.executableRelativePath
399
+ ?? path.join('bin', PLATFORM_PACKAGES[kind].executable)
400
+ for (const version of versions) {
401
+ const file = path.join(store, version, relative)
402
+ if (isSpawnableFile(file)) return { file, version }
403
+ }
404
+ return undefined
405
+ }
406
+
407
+ /**
408
+ * One executable found by walking a PATH-shaped string.
409
+ *
410
+ * This is a read-only walk of the value it was handed: it never writes PATH and
411
+ * never consults anything but the string it is given.
412
+ * @param {object} options - search inputs.
413
+ * @param {string[]} options.names - executable names to accept, in order.
414
+ * @param {string|undefined} options.pathValue - the PATH value to walk.
415
+ * @returns {string|undefined} the first existing entry, or undefined.
416
+ */
417
+ function fromPath({ names, pathValue }) {
418
+ if (typeof pathValue !== 'string' || pathValue.trim() === '') return undefined
419
+ for (const entry of pathValue.split(path.delimiter)) {
420
+ // PATH entries may carry surrounding quotes from `setx`-style definitions.
421
+ const directory = entry.trim().replace(/^"(.*)"$/, '$1')
422
+ if (directory === '' || !path.isAbsolute(directory)) continue
423
+ for (const name of names) {
424
+ const file = path.join(directory, name)
425
+ if (isSpawnableFile(file)) return file
426
+ }
427
+ }
428
+ return undefined
429
+ }
430
+
431
+ /**
432
+ * The well-known install locations to probe for one rung.
433
+ *
434
+ * The bash list deliberately omits `%SystemRoot%\System32\bash.exe`: that is the
435
+ * WSL distribution launcher, not a POSIX shell over the caller's filesystem, so
436
+ * spawning it would silently run the command on a different machine. The pwsh
437
+ * list keeps Windows PowerShell 5.1 last, matching upstream's own resolution
438
+ * order (`packages/shell/pwsh-local/src/resolve.ts:21-37`).
439
+ * @param {object} options - probe inputs.
440
+ * @param {'bash'|'pwsh'} options.kind - which rung.
441
+ * @param {NodeJS.ProcessEnv} options.env - the environment to read.
442
+ * @param {string} options.platform - `process.platform`.
443
+ * @returns {string[]} the candidate paths, in order.
444
+ */
445
+ function wellKnownCandidates({ kind, env, platform }) {
446
+ if (platform !== 'win32') return []
447
+ const programFiles = env.ProgramFiles ?? env.PROGRAMFILES ?? 'C:\\Program Files'
448
+ const programFilesX86 = env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)'
449
+ const localAppData = env.LOCALAPPDATA ?? env.LocalAppData ?? ''
450
+ const userProfile = env.USERPROFILE ?? ''
451
+ const candidates = kind === 'bash'
452
+ ? [
453
+ path.join(programFiles, 'Git', 'bin', 'bash.exe'),
454
+ path.join(programFiles, 'Git', 'usr', 'bin', 'bash.exe'),
455
+ path.join(programFilesX86, 'Git', 'bin', 'bash.exe'),
456
+ localAppData === '' ? '' : path.join(localAppData, 'Programs', 'Git', 'bin', 'bash.exe'),
457
+ userProfile === '' ? '' : path.join(userProfile, 'scoop', 'shims', 'bash.exe'),
458
+ userProfile === '' ? '' : path.join(userProfile, 'scoop', 'apps', 'git', 'current', 'bin', 'bash.exe'),
459
+ 'C:\\ProgramData\\chocolatey\\bin\\bash.exe',
460
+ 'C:\\tools\\msys64\\usr\\bin\\bash.exe',
461
+ 'C:\\msys64\\usr\\bin\\bash.exe',
462
+ ]
463
+ : [
464
+ path.join(programFiles, 'PowerShell', '7', 'pwsh.exe'),
465
+ path.join(programFiles, 'PowerShell', '7-preview', 'pwsh.exe'),
466
+ path.join(programFilesX86, 'PowerShell', '7', 'pwsh.exe'),
467
+ localAppData === '' ? '' : path.join(localAppData, 'Microsoft', 'WindowsApps', 'pwsh.exe'),
468
+ userProfile === '' ? '' : path.join(userProfile, 'scoop', 'shims', 'pwsh.exe'),
469
+ 'C:\\ProgramData\\chocolatey\\bin\\pwsh.exe',
470
+ ]
471
+ return candidates.filter((file) => file !== '')
472
+ }
473
+
474
+ /**
475
+ * The executable names one rung answers to.
476
+ * @param {'bash'|'pwsh'} kind - which rung.
477
+ * @param {string} platform - `process.platform`.
478
+ * @returns {string[]} the names, in probe order.
479
+ */
480
+ function executableNames(kind, platform) {
481
+ const base = kind === 'bash' ? 'bash' : 'pwsh'
482
+ return platform === 'win32' ? [`${base}.exe`, base] : [base]
483
+ }
484
+
485
+ /**
486
+ * Resolve the bash rung: explicit config, the provisioned copy, the plugin's own
487
+ * copy, then — only while the deployment allows a system shell — PATH and the
488
+ * well-known install locations.
489
+ *
490
+ * A configured path is authoritative: when it is unusable the rung reports
491
+ * `available: false` instead of quietly running a different shell.
492
+ * @param {object} options - resolution inputs.
493
+ * @param {string|undefined} options.configured - the configured executable.
494
+ * @param {{file: string, version: string}|undefined} options.provisioned - the
495
+ * copy `provision-shells` installed, when the store has one.
496
+ * @param {string|undefined} options.bundled - the plugin's own executable, when it has one.
497
+ * @param {string|undefined} options.bundledDetail - why the plugin's own copy is unusable, when it has none.
498
+ * @param {boolean} options.allowSystemFallback - whether system installations may be used.
499
+ * @param {NodeJS.ProcessEnv} options.env - the environment to read.
500
+ * @param {string} options.platform - `process.platform`.
501
+ * @returns {ShellResolution} the resolution.
502
+ */
503
+ function resolveBash({
504
+ configured,
505
+ provisioned,
506
+ bundled,
507
+ bundledDetail,
508
+ allowSystemFallback,
509
+ env,
510
+ platform,
511
+ }) {
512
+ if (configured !== undefined) {
513
+ return isSpawnableFile(configured)
514
+ ? { available: true, file: configured, source: 'config', detail: 'configured bash executable' }
515
+ : {
516
+ available: false,
517
+ file: undefined,
518
+ source: 'config',
519
+ detail: `the configured bash executable does not exist: ${configured}`,
520
+ }
521
+ }
522
+ if (provisioned !== undefined) {
523
+ return {
524
+ available: true,
525
+ file: provisioned.file,
526
+ source: 'provisioned',
527
+ detail: `the bash this deployment provisioned from upstream (version ${provisioned.version})`,
528
+ }
529
+ }
530
+ if (bundled !== undefined) {
531
+ return {
532
+ available: true,
533
+ file: bundled,
534
+ source: 'bundled',
535
+ detail: "the plugin's own bash",
536
+ }
537
+ }
538
+ if (!allowSystemFallback) {
539
+ return {
540
+ available: false,
541
+ file: undefined,
542
+ source: 'disabled',
543
+ detail: 'no provisioned or bundled bash and allowSystemShellFallback is false'
544
+ + (bundledDetail === undefined ? '' : ` (${bundledDetail})`),
545
+ }
546
+ }
547
+ const onPath = fromPath({ names: executableNames('bash', platform), pathValue: env.PATH ?? env.Path })
548
+ if (onPath !== undefined) {
549
+ return { available: true, file: onPath, source: 'path', detail: 'bash found on PATH' }
550
+ }
551
+ for (const candidate of wellKnownCandidates({ kind: 'bash', env, platform })) {
552
+ if (isSpawnableFile(candidate)) {
553
+ return { available: true, file: candidate, source: 'well-known', detail: 'well-known bash install' }
554
+ }
555
+ }
556
+ return {
557
+ available: false,
558
+ file: undefined,
559
+ source: 'missing',
560
+ detail: 'no bash found by config, a provisioned copy, a bundled copy, PATH, or a well-known '
561
+ + 'install location'
562
+ + (bundledDetail === undefined ? '' : ` (${bundledDetail})`)
563
+ + '; run `dsh-ops provision-shells --bash` to install the pinned upstream copy',
564
+ }
565
+ }
566
+
567
+ /**
568
+ * Resolve the PowerShell 7 rung.
569
+ *
570
+ * L3 is "the plugin's own pwsh 7", and whether that rung runs is decided by the
571
+ * executable it needs actually being there: `cordis.patch.yml` points the
572
+ * host's `pwsh-sandbox` row at this same path whenever it exists, so
573
+ * `available` here and the host row's behaviour are one fact, not two. There is
574
+ * deliberately no configured-path form: the row the plugin would have to
575
+ * rewrite is resolved before any dsh-ops config is read.
576
+ * @param {object} options - resolution inputs.
577
+ * @param {{file: string, version: string}|undefined} options.provisioned - the
578
+ * copy `provision-shells` installed, when the store has one.
579
+ * @param {string|undefined} options.bundled - the plugin's own pwsh, when it has one.
580
+ * @param {string|undefined} options.bundledDetail - why the plugin's own copy is unusable, when it has none.
581
+ * @param {boolean} options.allowSystemFallback - whether the host's pwsh may serve as the last rung.
582
+ * @returns {ShellResolution} the resolution.
583
+ */
584
+ function resolvePwsh({ provisioned, bundled, bundledDetail, allowSystemFallback }) {
585
+ if (provisioned !== undefined) {
586
+ return {
587
+ available: true,
588
+ file: provisioned.file,
589
+ source: 'provisioned',
590
+ detail: 'the PowerShell 7 this deployment provisioned from upstream '
591
+ + `(version ${provisioned.version}), which the pwsh-sandbox override points at`,
592
+ }
593
+ }
594
+ if (bundled !== undefined) {
595
+ return {
596
+ available: true,
597
+ file: bundled,
598
+ source: 'bundled',
599
+ detail: "the plugin's own PowerShell 7, which the pwsh-sandbox override points at",
600
+ }
601
+ }
602
+ return {
603
+ available: false,
604
+ file: undefined,
605
+ source: 'missing',
606
+ detail: 'the plugin carries no PowerShell 7 for this platform'
607
+ + (bundledDetail === undefined ? '' : ` (${bundledDetail})`)
608
+ + '; run `dsh-ops provision-shells --pwsh` to install the pinned upstream copy'
609
+ + (allowSystemFallback
610
+ ? "; the host's own pwsh tool is the ladder's last rung"
611
+ : '; allowSystemShellFallback is false, so the ladder has no PowerShell rung'),
612
+ }
613
+ }
614
+
615
+ /**
616
+ * Resolve both shell rungs for this configuration.
617
+ *
618
+ * Synchronous, side-effect free, and total: every failure mode becomes a
619
+ * resolution with `available: false`, never a throw, because this runs during
620
+ * mount where an exception would take the whole plugin down.
621
+ * @param {import('./config.js').ResolvedConfig} config - the plugin configuration.
622
+ * @param {object} [options] - resolution overrides, for tests and for a host
623
+ * resolving against a different root. Production callers pass none.
624
+ * @param {string} [options.bundleRoot] - the plugin package root.
625
+ * @param {NodeJS.ProcessEnv} [options.env] - the environment to probe.
626
+ * @param {string} [options.platform] - `process.platform`.
627
+ * @param {string} [options.arch] - `process.arch`.
628
+ * @returns {{bash: ShellResolution, pwsh: ShellResolution}} one resolution per rung.
629
+ */
630
+ export function resolveShells(config, options = {}) {
631
+ const bundleRoot = options.bundleRoot ?? MODULE_ROOT
632
+ const env = options.env ?? process.env
633
+ const platform = options.platform ?? process.platform
634
+ const arch = options.arch ?? process.arch
635
+ const allowSystemFallback = config?.allowSystemShellFallback !== false
636
+ const probe = { bundleRoot, env, platform, arch }
637
+ const pwshBundled = bundledShell({ kind: 'pwsh', ...probe })
638
+ const pwshProvisioned = provisionedShell({ kind: 'pwsh', env, platform, arch })
639
+
640
+ const pwsh = resolvePwsh({
641
+ provisioned: pwshProvisioned,
642
+ bundled: pwshBundled.file,
643
+ bundledDetail: pwshBundled.detail,
644
+ allowSystemFallback,
645
+ })
646
+
647
+ if (config?.publishBashTool === false) {
648
+ return {
649
+ bash: {
650
+ available: false,
651
+ file: undefined,
652
+ source: 'disabled',
653
+ detail: 'publishBashTool is false',
654
+ },
655
+ pwsh,
656
+ }
657
+ }
658
+
659
+ const bashBundled = bundledShell({ kind: 'bash', ...probe })
660
+ return {
661
+ bash: resolveBash({
662
+ configured: config?.bashPath,
663
+ provisioned: provisionedShell({ kind: 'bash', env, platform, arch }),
664
+ bundled: bashBundled.file,
665
+ bundledDetail: bashBundled.detail,
666
+ allowSystemFallback,
667
+ env,
668
+ platform,
669
+ }),
670
+ pwsh,
671
+ }
672
+ }
673
+
674
+ /**
675
+ * Reject a value this tool cannot run with, naming the field.
676
+ * @param {string} name - the argument name.
677
+ * @param {number} value - the value to check.
678
+ * @returns {void}
679
+ * @throws {Error} when the value is not a positive finite number.
680
+ */
681
+ function assertPositiveFinite(name, value) {
682
+ if (!Number.isFinite(value) || value <= 0) {
683
+ throw new Error(`invalid ${name}: expected a positive finite number, got ${JSON.stringify(value)}`)
684
+ }
685
+ }
686
+
687
+ /**
688
+ * Compose the model-facing text for one settled run.
689
+ *
690
+ * The shape matches the host's own bash tool
691
+ * (`packages/shell/tool-bash/src/render.ts`): the body is stdout, then a marked
692
+ * stderr section, then interruption markers, with `[exit code: N]` last so a
693
+ * reader can anchor on it. A non-zero exit is reported rather than raised; only
694
+ * an infrastructure failure (a spawn failure) rejects the call.
695
+ * @param {object} value - the canonical result value.
696
+ * @returns {string} the rendered text.
697
+ */
698
+ export function renderBashResult(value) {
699
+ const stream = (output) => (output.truncated
700
+ ? `${output.text}\n[output truncated; full output: ${output.spillPath ?? '(unavailable)'}]`
701
+ : output.text)
702
+ const out = stream(value.stdout)
703
+ const err = stream(value.stderr)
704
+ let body = out
705
+ if (err.length > 0) {
706
+ if (body.length > 0 && !body.endsWith('\n')) body += '\n'
707
+ body += `[stderr]\n${err}`
708
+ }
709
+ if (body.length === 0) body = '(no output)'
710
+ const markers = []
711
+ if (value.timedOut === true) markers.push(`[timed out after ${value.timeoutMs}ms]`)
712
+ if (value.signal !== undefined) markers.push(`[signal: ${value.signal}]`)
713
+ if (value.exitCode !== undefined && value.exitCode !== 0) markers.push(`[exit code: ${value.exitCode}]`)
714
+ return [body, ...markers].join('\n')
715
+ }
716
+
717
+ /**
718
+ * Read one settled collect-mode stream as its complete retained output.
719
+ * @param {{readFrom: (from: number) => {text: string, lossy: boolean, spillPath?: string}}|undefined} reader - the reader.
720
+ * @returns {{text: string, truncated: boolean, spillPath?: string}} the canonical stream value.
721
+ */
722
+ function settledStream(reader) {
723
+ if (reader === undefined || typeof reader.readFrom !== 'function') return { text: '', truncated: false }
724
+ const read = reader.readFrom(0)
725
+ return {
726
+ text: read.text,
727
+ truncated: read.lossy === true,
728
+ ...read.spillPath !== undefined ? { spillPath: read.spillPath } : {},
729
+ }
730
+ }
731
+
732
+ /**
733
+ * The canonical output contract of `ops_bash`, as plain JSON Schema.
734
+ *
735
+ * EVERY `type` here is a single type string: the harness's
736
+ * `assertSupportedJsonSchema` rejects type arrays outright ("type arrays are
737
+ * not supported", `packages/core/tools/src/json-schema.ts:302-307`), so
738
+ * `['integer', 'null']` is a definition the real registry refuses to register.
739
+ * "Nothing to report" is expressed by OMITTING the key, never by a `null`, which
740
+ * is why `exitCode` and `signal` are optional rather than nullable.
741
+ */
742
+ const BASH_OUTPUT_SCHEMA = Object.freeze({
743
+ type: 'object',
744
+ additionalProperties: false,
745
+ required: ['timedOut', 'timeoutMs', 'stdout', 'stderr'],
746
+ properties: {
747
+ exitCode: { type: 'integer' },
748
+ signal: { type: 'string' },
749
+ timedOut: { type: 'boolean' },
750
+ timeoutMs: { type: 'number' },
751
+ stdout: {
752
+ type: 'object',
753
+ additionalProperties: false,
754
+ required: ['text', 'truncated'],
755
+ properties: {
756
+ text: { type: 'string' },
757
+ truncated: { type: 'boolean' },
758
+ spillPath: { type: 'string' },
759
+ },
760
+ },
761
+ stderr: {
762
+ type: 'object',
763
+ additionalProperties: false,
764
+ required: ['text', 'truncated'],
765
+ properties: {
766
+ text: { type: 'string' },
767
+ truncated: { type: 'boolean' },
768
+ spillPath: { type: 'string' },
769
+ },
770
+ },
771
+ },
772
+ })
773
+
774
+ /**
775
+ * The model-facing description of the bash rung.
776
+ *
777
+ * It states the rung's position in the ladder and the recovery step on failure,
778
+ * so the model fixes the command instead of switching shells — the same contract
779
+ * `lib/policy.js` renders into the prompt.
780
+ * @returns {string} the description.
781
+ */
782
+ export function bashToolDescription() {
783
+ return 'Run general commands, builds, git/gh, pipelines and scripts in bash; prefer this command '
784
+ + 'executor over PowerShell. Use file tools for file operations. Each call uses a fresh non-login '
785
+ + 'shell; set workdir explicitly. Fix failed bash commands here, do not switch shells or mix '
786
+ + 'PowerShell syntax. Output is bounded with spill paths; verify absolute targets before delete/move.'
787
+ }
788
+
789
+ /**
790
+ * One `ops_bash` tool definition, bound to the resolved bash executable.
791
+ *
792
+ * The definition is a plain object — no `@deepseek-ai/*` value import, at load
793
+ * time or later — and it is registered through the caller's own
794
+ * `ctx.tools.register`, never by rewriting the shared registry. Its one host
795
+ * dependency, the subprocess service, is read with `ctx.get('subprocess')`:
796
+ * reaching a host service through `ctx.get` needs no import, and it buys the
797
+ * harness's real credential scrub and process governance instead of a
798
+ * hand-rolled spawn.
799
+ * @param {object} options - the tool inputs.
800
+ * @param {string} options.file - the resolved bash executable.
801
+ * @param {string} options.source - where that executable came from, for diagnostics.
802
+ * @param {{spawn: (spec: object) => object}} options.subprocess - the harness subprocess service.
803
+ * @returns {object} the definition `ctx.tools.register()` accepts.
804
+ */
805
+ export function bashToolDefinition({ file, source, subprocess }) {
806
+ return {
807
+ name: BASH_TOOL_NAME,
808
+ description: bashToolDescription(),
809
+ parameters: {
810
+ command: { type: 'string', required: true, description: 'The bash command to execute.' },
811
+ workdir: {
812
+ type: 'string',
813
+ description: 'Working directory for this command. Defaults to the session workspace when omitted.',
814
+ },
815
+ timeoutMs: {
816
+ type: 'number',
817
+ description: 'Timeout in milliseconds; the command is killed on expiry. '
818
+ + `Defaults to ${DEFAULT_BASH_TIMEOUT_MS}.`,
819
+ },
820
+ graceMs: {
821
+ type: 'number',
822
+ description: 'Grace period in milliseconds between termination and a forced kill. '
823
+ + `Defaults to ${DEFAULT_GRACE_MS}.`,
824
+ },
825
+ },
826
+ output: {
827
+ schema: BASH_OUTPUT_SCHEMA,
828
+ render: (_args, value) => [{ type: 'text', text: renderBashResult(value) }],
829
+ },
830
+ /**
831
+ * Run one command through the harness subprocess service.
832
+ *
833
+ * The service owns credential scrubbing, output spilling, and process-range
834
+ * termination; this body supplies only the argv, the directory, the budgets,
835
+ * the terminal overrides, and its cancellation.
836
+ * @param {Record<string, unknown>} args - the model arguments.
837
+ * @param {{signal?: AbortSignal}} [exec] - the host execution context.
838
+ * @returns {Promise<object>} the canonical result value.
839
+ */
840
+ async execute(args, exec) {
841
+ const command = args?.command
842
+ if (typeof command !== 'string' || command.trim() === '') {
843
+ throw new Error('invalid command: expected a non-empty string')
844
+ }
845
+ const timeoutMs = args?.timeoutMs ?? DEFAULT_BASH_TIMEOUT_MS
846
+ assertPositiveFinite('timeoutMs', timeoutMs)
847
+ if (timeoutMs > MAX_BASH_TIMEOUT_MS) {
848
+ throw new Error(`invalid timeoutMs: must be no greater than ${MAX_BASH_TIMEOUT_MS}, got ${timeoutMs}`)
849
+ }
850
+ const graceMs = args?.graceMs ?? DEFAULT_GRACE_MS
851
+ assertPositiveFinite('graceMs', graceMs)
852
+ const workdir = args?.workdir
853
+ if (workdir !== undefined && typeof workdir !== 'string') {
854
+ throw new Error('invalid workdir: expected a string')
855
+ }
856
+
857
+ // No subprocess re-check here: `publishShellTools` publishes this tool
858
+ // only while `ctx.subprocess` is mounted, so a definition that exists at
859
+ // all always has the one service it needs.
860
+
861
+ // One deadline plus the caller's cancellation, fused into the signal the
862
+ // subprocess service terminates its process range on.
863
+ const controller = new AbortController()
864
+ let timedOut = false
865
+ const timer = setTimeout(() => {
866
+ timedOut = true
867
+ controller.abort(new Error(`command timed out after ${timeoutMs}ms`))
868
+ }, timeoutMs)
869
+ timer.unref?.()
870
+ const callerSignal = exec?.signal
871
+ const onCallerAbort = () => controller.abort(callerSignal?.reason)
872
+ if (callerSignal !== undefined) {
873
+ if (callerSignal.aborted) onCallerAbort()
874
+ else callerSignal.addEventListener('abort', onCallerAbort, { once: true })
875
+ }
876
+
877
+ try {
878
+ const handle = subprocess.spawn({
879
+ argv: [file, '-c', command],
880
+ cwd: workdir,
881
+ stdio: {
882
+ stdin: 'ignore',
883
+ stdout: { maxBytes: DEFAULT_MAX_OUTPUT_BYTES, spill: { maxBytes: DEFAULT_MAX_SPILL_BYTES } },
884
+ stderr: { maxBytes: DEFAULT_MAX_OUTPUT_BYTES, spill: { maxBytes: DEFAULT_MAX_SPILL_BYTES } },
885
+ },
886
+ graceMs,
887
+ signal: controller.signal,
888
+ // The terminal overrides and nothing else: the subprocess service's
889
+ // own scrub decides what else a child may inherit, and this plugin
890
+ // never adds a credential-shaped name or a `DSH_*` fact to that set.
891
+ env: { ...ENV_OVERRIDES },
892
+ })
893
+ const outcome = await handle.done
894
+ // "Nothing to report" is an ABSENT key, not a null: the output schema
895
+ // declares single types only (see BASH_OUTPUT_SCHEMA).
896
+ return {
897
+ ...outcome?.exitCode !== undefined && outcome?.exitCode !== null ? { exitCode: outcome.exitCode } : {},
898
+ ...outcome?.signal !== undefined && outcome?.signal !== null ? { signal: outcome.signal } : {},
899
+ timedOut,
900
+ timeoutMs,
901
+ stdout: settledStream(handle.collected?.stdout),
902
+ stderr: settledStream(handle.collected?.stderr),
903
+ }
904
+ } finally {
905
+ clearTimeout(timer)
906
+ callerSignal?.removeEventListener('abort', onCallerAbort)
907
+ }
908
+ },
909
+ }
910
+ }
911
+
912
+ /**
913
+ * Report one load-time shell problem without letting a hostile logger hide it.
914
+ * @param {object} ctx - a Cordis context.
915
+ * @param {string} message - the report.
916
+ * @returns {void}
917
+ */
918
+ function reportShellProblem(ctx, message) {
919
+ const logger = ctx?.logger
920
+ if (typeof logger?.warn === 'function') {
921
+ logger.warn(message)
922
+ return
923
+ }
924
+ if (typeof logger?.error === 'function') {
925
+ logger.error(message)
926
+ return
927
+ }
928
+ console.warn(message)
929
+ }
930
+
931
+ /**
932
+ * Publish the tools that run on the plugin's own shells.
933
+ *
934
+ * Today that is exactly one tool — `ops_bash`, bound to the resolved bash — and
935
+ * nothing else: pwsh needs no tool, because the host's `pwsh-sandbox` row runs
936
+ * the plugin's own executable whenever the bundle override resolved one. A rung
937
+ * that is unavailable publishes nothing and reports why through the context
938
+ * logger; it never throws, because a deployment without bash must still load
939
+ * this plugin.
940
+ * @param {object} ctx - a Cordis context carrying the tool registry.
941
+ * @param {import('./config.js').ResolvedConfig} config - the plugin configuration.
942
+ * @param {{bash: ShellResolution, pwsh: ShellResolution}} shells - the resolution to publish for.
943
+ * @returns {() => void} the disposer for every registration made here.
944
+ */
945
+ export function publishShellTools(ctx, config, shells) {
946
+ if (config?.publishBashTool === false) return () => {}
947
+ const bash = shells?.bash
948
+ if (bash === undefined || bash.available !== true || typeof bash.file !== 'string') {
949
+ const reason = bash?.detail ?? 'the bash rung was not resolved'
950
+ reportShellProblem(ctx, `dsh-ops: the plugin's own bash is unavailable (${reason}); `
951
+ + `${BASH_TOOL_NAME} is not published.`)
952
+ return () => {}
953
+ }
954
+
955
+ const registry = ctx?.get?.('tools')
956
+ if (registry === undefined || typeof registry.register !== 'function') {
957
+ reportShellProblem(ctx, `dsh-ops: no tool registry (ctx.tools) is mounted; `
958
+ + `${BASH_TOOL_NAME} was not published.`)
959
+ return () => {}
960
+ }
961
+ // Reaching the host service through `ctx.get` keeps this module free of any
962
+ // `@deepseek-ai/*` value import while still running every command through the
963
+ // harness's own credential scrub and process governance.
964
+ //
965
+ // This check gates the REGISTRATION, not just the call: without the
966
+ // subprocess service the tool could be published and then fail on every
967
+ // invocation, and a deployment whose ladder advertises rung two for a tool
968
+ // that cannot run is worse than one that admits the rung is missing.
969
+ const subprocess = ctx?.get?.('subprocess')
970
+ if (subprocess === undefined || typeof subprocess.spawn !== 'function') {
971
+ reportShellProblem(ctx, `dsh-ops: no subprocess service (ctx.subprocess) is mounted, so the `
972
+ + `resolved bash (${bash.source}) cannot be run; ${BASH_TOOL_NAME} is not published.`)
973
+ return () => {}
974
+ }
975
+
976
+ let dispose
977
+ try {
978
+ dispose = registry.register(bashToolDefinition({ file: bash.file, source: bash.source, subprocess }))
979
+ } catch (error) {
980
+ // A registry conflict (a foreign `ops_bash`) or a schema rejection is
981
+ // reported and skipped: it must not fail plugin activation.
982
+ reportShellProblem(ctx, `dsh-ops: ${BASH_TOOL_NAME} could not be registered `
983
+ + `(${String(error?.message ?? error)}).`)
984
+ return () => {}
985
+ }
986
+ if (typeof dispose !== 'function') return () => {}
987
+ let disposed = false
988
+ return () => {
989
+ if (disposed) return
990
+ disposed = true
991
+ dispose()
992
+ }
993
+ }