@twinsearth/w2m-dsh-plugin 0.0.0-stage → 0.4.7

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 (60) hide show
  1. package/CHANGELOG.md +1309 -0
  2. package/LICENSE +21 -0
  3. package/PROTOCOL-v0.1.2.md +165 -0
  4. package/PROTOCOL-v0.3.0.md +454 -0
  5. package/PROTOCOL-v0.3.9.md +207 -0
  6. package/PROTOCOL-v0.4.0.md +255 -0
  7. package/PROTOCOL.md +339 -0
  8. package/README.md +665 -2
  9. package/bin/w2m-localside.mjs +303 -0
  10. package/bin/w2m-rabbit.mjs +396 -0
  11. package/bin/w2m-stun.mjs +231 -0
  12. package/cordis.patch.yml +94 -0
  13. package/deploy/caddy/Caddyfile.example +118 -0
  14. package/deploy/docker/Dockerfile +144 -0
  15. package/deploy/docker/README.md +338 -0
  16. package/deploy/docker/docker-compose.yml +179 -0
  17. package/deploy/nginx/w2m.conf.example +196 -0
  18. package/deploy/shared-server/README.md +118 -0
  19. package/deploy/shared-server/install.sh +255 -0
  20. package/deploy/systemd/w2m-rabbit.service +150 -0
  21. package/deploy/systemd/w2m-stun.service +69 -0
  22. package/deploy/windows/README.md +115 -0
  23. package/deploy/windows/install-service.ps1 +195 -0
  24. package/deploy/windows/w2m-agent-supervisor.mjs +205 -0
  25. package/deploy/windows/winsw/w2m-localside.xml +50 -0
  26. package/docs/DEPLOY.md +902 -0
  27. package/docs/LINT-AUDIT.md +492 -0
  28. package/docs/PIPELINE-DESIGN.md +95 -0
  29. package/docs/TROUBLESHOOTING.md +560 -0
  30. package/docs/UI-CARDS.md +81 -0
  31. package/lib/index.js +24 -0
  32. package/lib/tools.js +11 -0
  33. package/package.json +133 -4
  34. package/src/agent/agent.mjs +3317 -0
  35. package/src/agent/allowed-commands.mjs +116 -0
  36. package/src/agent/caps.mjs +437 -0
  37. package/src/agent/exec.mjs +356 -0
  38. package/src/agent/git.mjs +616 -0
  39. package/src/agent/identity.mjs +253 -0
  40. package/src/agent/p2p-node.mjs +1243 -0
  41. package/src/agent/p2p.mjs +605 -0
  42. package/src/agent/resolve.mjs +148 -0
  43. package/src/agent/spool.mjs +252 -0
  44. package/src/agent/stun.mjs +397 -0
  45. package/src/agent/url.mjs +127 -0
  46. package/src/plugin/auto-update.mjs +574 -0
  47. package/src/plugin/schedule.mjs +452 -0
  48. package/src/plugin/shared-config.mjs +264 -0
  49. package/src/plugin/tools.mjs +3891 -0
  50. package/src/plugin/update-install.mjs +764 -0
  51. package/src/plugin/update-source.mjs +686 -0
  52. package/src/relay/metrics.mjs +284 -0
  53. package/src/relay/peers.mjs +180 -0
  54. package/src/relay/persistence.mjs +207 -0
  55. package/src/relay/report.mjs +591 -0
  56. package/src/relay/server.mjs +1187 -0
  57. package/src/relay/state.mjs +2004 -0
  58. package/src/relay/stun-server.mjs +733 -0
  59. package/src/signing.mjs +359 -0
  60. package/src/util/cli.mjs +70 -0
@@ -0,0 +1,116 @@
1
+ /**
2
+ * The command allow-list matcher: parse the configured entries, then match one argv against them.
3
+ *
4
+ * This is the rule that decides whether an offer may run. It lives in its own module because it has
5
+ * **two** callers that must agree: the agent, which enforces the list on the machine that actually
6
+ * executes the command, and the plugin, whose dispatch-time pre-flight exists so a dispatcher does
7
+ * not send work the target would refuse. A second, independently written pre-flight is how the two
8
+ * ends drift apart, and a pre-flight that is *stricter* than the gate is the worse direction: the
9
+ * agent would have run the command, and the dispatch never happened.
10
+ *
11
+ * The comparison is deliberately token-wise and not executable-only. An entry is a **prefix
12
+ * sequence**: `"node --test"` means `['node','--test']`, which allows `['node','--test','--reporter=tap']`
13
+ * and refuses `['node','-e','...']` and a bare `['node']`. Comparing only `argv[0]`, as the plugin's
14
+ * pre-flight used to, can never match a multi-token entry at all -- the documented config
15
+ * `['node --test','git status --porcelain']` would refuse `["node","--test"]`.
16
+ */
17
+
18
+ import { basename, extname } from 'node:path';
19
+
20
+ /**
21
+ * Parse `--allowed-commands`.
22
+ *
23
+ * Accepts the JSON array form the CLI documents (`'["node --test","git status"]'`),
24
+ * an already-parsed array of strings, or an array of arrays for entries whose
25
+ * executable path contains spaces. Each string entry is split on whitespace
26
+ * into a **prefix sequence**.
27
+ *
28
+ * Commas are deliberately *not* a separator: `["node --test, git status"]`
29
+ * would silently authorise something nobody meant.
30
+ *
31
+ * @param {string|Array<string|string[]>} input
32
+ * @returns {string[][]} Prefixes.
33
+ */
34
+ export function parseAllowedCommands(input) {
35
+ let parsed = input;
36
+ if (typeof input === 'string') {
37
+ const text = input.trim();
38
+ if (text === '') return [];
39
+ try {
40
+ parsed = JSON.parse(text);
41
+ } catch (error) {
42
+ const err = new TypeError(
43
+ `--allowed-commands must be a JSON array, e.g. '["node --test","git status"]' (${error.message})`,
44
+ );
45
+ err.code = 'ALLOWED_COMMANDS_INVALID';
46
+ throw err;
47
+ }
48
+ }
49
+ if (!Array.isArray(parsed)) {
50
+ const err = new TypeError('allowed commands must be a JSON array');
51
+ err.code = 'ALLOWED_COMMANDS_INVALID';
52
+ throw err;
53
+ }
54
+ const prefixes = [];
55
+ for (const entry of parsed) {
56
+ if (typeof entry === 'string') {
57
+ const parts = entry.split(/\s+/).filter((part) => part !== '');
58
+ if (parts.length === 0) {
59
+ const err = new TypeError('allowed command entries must not be empty');
60
+ err.code = 'ALLOWED_COMMANDS_INVALID';
61
+ throw err;
62
+ }
63
+ prefixes.push(parts);
64
+ continue;
65
+ }
66
+ if (Array.isArray(entry) && entry.length > 0 && entry.every((part) => typeof part === 'string')) {
67
+ prefixes.push([...entry]);
68
+ continue;
69
+ }
70
+ const err = new TypeError('allowed command entries must be strings or arrays of strings');
71
+ err.code = 'ALLOWED_COMMANDS_INVALID';
72
+ throw err;
73
+ }
74
+ return prefixes;
75
+ }
76
+
77
+ /** Executable name without directory or extension (`C:\x\node.exe` -> `node`). */
78
+ function execName(value) {
79
+ const base = basename(value);
80
+ const ext = extname(base);
81
+ return ext === '' ? base : base.slice(0, -ext.length);
82
+ }
83
+
84
+ /** Windows paths are case-insensitive; POSIX ones are not. */
85
+ function sameName(a, b) {
86
+ return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
87
+ }
88
+
89
+ /**
90
+ * Default-deny prefix match.
91
+ *
92
+ * `['node','--test']` is allowed by the prefix `node --test`, and so is
93
+ * `['node','--test','--reporter=tap']`; `['node','-e','...']` and a bare
94
+ * `['node']` are refused.
95
+ *
96
+ * @param {string[]} argv
97
+ * @param {string[][]} prefixes
98
+ * @returns {{allowed: boolean, prefix: string[]|null}}
99
+ */
100
+ export function matchAllowedCommand(argv, prefixes) {
101
+ if (!Array.isArray(argv) || argv.length === 0) return { allowed: false, prefix: null };
102
+ for (const prefix of prefixes ?? []) {
103
+ if (!Array.isArray(prefix) || prefix.length === 0) continue;
104
+ if (argv.length < prefix.length) continue;
105
+ if (!sameName(argv[0], prefix[0]) && !sameName(execName(argv[0]), prefix[0])) continue;
106
+ let ok = true;
107
+ for (let i = 1; i < prefix.length; i += 1) {
108
+ if (argv[i] !== prefix[i]) {
109
+ ok = false;
110
+ break;
111
+ }
112
+ }
113
+ if (ok) return { allowed: true, prefix };
114
+ }
115
+ return { allowed: false, prefix: null };
116
+ }
@@ -0,0 +1,437 @@
1
+ /**
2
+ * Capability detection for the W2M Localside agent.
3
+ *
4
+ * Rules this file follows, because "guessing" here poisons every later
5
+ * comparison:
6
+ *
7
+ * * **Probe, do not assume.** Case sensitivity, symlink support and the
8
+ * POSIX execute bit are detected by actually exercising the filesystem in
9
+ * the system temp directory, not by switching on `process.platform`.
10
+ * * **PATH first, bundled runtime second.** This machine has no `python`
11
+ * and no `npm` on PATH, but DSH ships its own runtimes under
12
+ * `%DSH_HOME%\dsh-runtimes\<runtime>\dependencies\`. A capability we can
13
+ * find there is real and is reported with the real version string.
14
+ * * **Null over fiction.** When a tool cannot be found we report `null`
15
+ * instead of a plausible-looking version, and the caller refuses the task
16
+ * with `MISSING_<TOOL>` rather than silently degrading.
17
+ *
18
+ * Only the six keys the contract puts in `caps` are returned by `probeCaps()`;
19
+ * the evidence behind them is in `probeCapsDetailed().detail` so it never
20
+ * leaks into an envelope.
21
+ */
22
+
23
+ import { chmodSync, mkdtempSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
24
+ import { tmpdir, release as osRelease } from 'node:os';
25
+ import { basename, extname, join } from 'node:path';
26
+ import { randomBytes } from 'node:crypto';
27
+ import { runArgv } from './exec.mjs';
28
+ import { dshDependenciesDir, findExecutable } from './resolve.mjs';
29
+
30
+ // The executable search order is shared with the executor, so the probe and the
31
+ // run agree on what "this machine has node" means. Re-exported from here because
32
+ // this module is where callers have always imported them from.
33
+ export {
34
+ DSH_RUNTIME_RELATIVE,
35
+ bundledBinDirs,
36
+ dshDependenciesDir,
37
+ dshHome,
38
+ findExecutable,
39
+ resolveCommand,
40
+ } from './resolve.mjs';
41
+
42
+ /** Keys of the contract's `caps` object — nothing else may be added. */
43
+ export const CAPS_KEYS = ['case_sensitive_fs', 'symlinks', 'exec_bit', 'python', 'npm', 'node'];
44
+
45
+ /**
46
+ * Run `<exe> <args>` and return the first regex capture of stdout+stderr.
47
+ *
48
+ * @param {string} exe
49
+ * @param {string[]} args
50
+ * @param {RegExp} pattern
51
+ * @param {number} [timeoutMs]
52
+ * @returns {Promise<{version: string|null, error: string|null}>}
53
+ */
54
+ async function probeVersion(exe, args, pattern, timeoutMs = 8000) {
55
+ const result = await runArgv([exe, ...args], { timeoutMs, maxOutputBytes: 64 * 1024 });
56
+ if (result.spawn_error) return { version: null, error: `SPAWN_${result.spawn_error.code}` };
57
+ if (result.timed_out) return { version: null, error: 'PROBE_TIMEOUT' };
58
+ const text = `${result.stdout.toString('utf8')}\n${result.stderr.toString('utf8')}`;
59
+ const match = text.match(pattern);
60
+ return { version: match ? match[1] : null, error: match ? null : 'VERSION_UNPARSED' };
61
+ }
62
+
63
+ /** Node/npm/python version formats differ; keep the tool's own spelling. */
64
+ const RE_NODE = /\bv?(\d+\.\d+\.\d+)\b/;
65
+ const RE_PYTHON = /Python\s+(\d+\.\d+\.\d+)/i;
66
+ const RE_NPM = /\bv?(\d+\.\d+\.\d+)\b/;
67
+
68
+ /**
69
+ * Locate and version one external tool.
70
+ *
71
+ * @param {object} spec
72
+ * @param {string[]} spec.names Bare names to look for, in priority order.
73
+ * @param {string[]} spec.bundled Absolute candidate paths inside the DSH tree.
74
+ * @param {string[]} spec.versionArgs
75
+ * @param {RegExp} spec.pattern
76
+ * @param {string} [spec.versionPrefix]
77
+ * @param {Record<string,string|undefined>} env
78
+ * @returns {Promise<{version: string|null, path: string|null, source: 'path'|'bundled'|null, error: string|null}>}
79
+ */
80
+ async function probeTool(spec, env) {
81
+ const onPath = spec.names.map((n) => findExecutable(n, { env })).find(Boolean) ?? null;
82
+ const bundled = spec.bundled.map((p) => (p && isFile(p) ? p : null)).find(Boolean) ?? null;
83
+ const exe = onPath ?? bundled;
84
+ if (!exe) return { version: null, path: null, source: null, error: 'NOT_FOUND' };
85
+
86
+ const { version, error } = await probeVersion(exe, spec.versionArgs, spec.pattern);
87
+ return {
88
+ version: version && spec.versionPrefix ? spec.versionPrefix + version : version,
89
+ path: exe,
90
+ source: onPath ? 'path' : 'bundled',
91
+ error,
92
+ };
93
+ }
94
+
95
+ /** @param {string} p */
96
+ function isFile(p) {
97
+ try {
98
+ return statSync(p).isFile();
99
+ } catch {
100
+ return false;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * `node` as a *task* would resolve it: PATH first, DSH's bundled node second,
106
+ * and finally the interpreter currently running this agent.
107
+ *
108
+ * @param {Record<string,string|undefined>} env
109
+ * @param {string|null} depsDir
110
+ * @returns {Promise<{version: string|null, path: string|null, source: string|null, error: string|null}>}
111
+ */
112
+ async function probeNode(env, depsDir) {
113
+ const isWin = process.platform === 'win32';
114
+ const bundled = depsDir ? [join(depsDir, 'node', 'bin', isWin ? 'node.exe' : 'node')] : [];
115
+ const found = await probeTool(
116
+ { names: ['node'], bundled, versionArgs: ['-v'], pattern: RE_NODE, versionPrefix: 'v' },
117
+ env,
118
+ );
119
+ if (found.version) {
120
+ return { ...found, version: `v${found.version.replace(/^v/, '')}` };
121
+ }
122
+ return { version: process.version, path: process.execPath, source: 'self', error: found.error };
123
+ }
124
+
125
+ /**
126
+ * Python: PATH (`python`, `python3`) first, then the bundled interpreter.
127
+ *
128
+ * @param {Record<string,string|undefined>} env
129
+ * @param {string|null} depsDir
130
+ */
131
+ async function probePython(env, depsDir) {
132
+ const isWin = process.platform === 'win32';
133
+ const bundled = depsDir
134
+ ? [
135
+ join(depsDir, 'python', isWin ? 'python.exe' : join('bin', 'python3')),
136
+ join(depsDir, 'python', 'python.exe'),
137
+ ]
138
+ : [];
139
+ return probeTool(
140
+ {
141
+ names: ['python', 'python3'],
142
+ bundled,
143
+ versionArgs: ['--version'],
144
+ pattern: RE_PYTHON,
145
+ },
146
+ env,
147
+ );
148
+ }
149
+
150
+ /**
151
+ * npm.
152
+ *
153
+ * On Windows npm on PATH is normally `npm.cmd`, which `spawn(..., {shell:false})`
154
+ * cannot start (and we will not build a shell command line just to ask for a
155
+ * version). `npm-cli.js` next to the resolved node is the honest alternative:
156
+ * it is what `npm.cmd` would have run anyway.
157
+ *
158
+ * @param {Record<string,string|undefined>} env
159
+ * @param {string|null} depsDir
160
+ * @param {string|null} nodePath
161
+ */
162
+ async function probeNpm(env, depsDir, nodePath) {
163
+ const nodeExe = nodePath ?? process.execPath;
164
+ const cliCandidates = [];
165
+ if (depsDir) {
166
+ cliCandidates.push(
167
+ join(depsDir, 'node', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
168
+ join(depsDir, 'node', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
169
+ join(depsDir, 'npm', 'bin', 'npm-cli.js'),
170
+ );
171
+ }
172
+ if (nodePath) {
173
+ const binDir = join(nodePath, '..');
174
+ cliCandidates.push(
175
+ join(binDir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
176
+ join(binDir, 'node_modules', 'npm', 'bin', 'npm-cli.js'),
177
+ );
178
+ }
179
+
180
+ const cli = cliCandidates.find((p) => isFile(p));
181
+ if (cli) {
182
+ const { version, error } = await probeVersion(nodeExe, [cli, '--version'], RE_NPM);
183
+ return { version, path: cli, source: 'npm-cli', error };
184
+ }
185
+
186
+ // A directly executable npm (POSIX shell script, or npm.exe on Windows).
187
+ const direct = probeToolOnPath(['npm'], env);
188
+ if (direct) {
189
+ const { version, error } = await probeVersion(direct, ['--version'], RE_NPM);
190
+ return { version, path: direct, source: 'path', error };
191
+ }
192
+
193
+ const shim = probeToolOnPath(['npm.cmd', 'npm.bat'], env);
194
+ return {
195
+ version: null,
196
+ path: shim,
197
+ source: shim ? 'cmd-shim' : null,
198
+ error: shim ? 'NEEDS_SHELL' : 'NOT_FOUND',
199
+ };
200
+ }
201
+
202
+ /** @param {string[]} names @param {Record<string,string|undefined>} env */
203
+ function probeToolOnPath(names, env) {
204
+ for (const name of names) {
205
+ const found = findExecutable(name, { env, extensions: [''] });
206
+ if (found) return found;
207
+ }
208
+ return null;
209
+ }
210
+
211
+ /**
212
+ * Probe the three filesystem capabilities in a scratch directory.
213
+ *
214
+ * @param {string} root Scratch root (usually `os.tmpdir()`).
215
+ * @returns {{case_sensitive_fs: boolean, case_sensitive_source: string, symlinks: boolean, symlinks_source: string, exec_bit: boolean, exec_bit_source: string, notes: string[]}}
216
+ */
217
+ export function probeFilesystemCaps(root = tmpdir()) {
218
+ const notes = [];
219
+ let dir = null;
220
+ const out = {
221
+ case_sensitive_fs: false,
222
+ case_sensitive_source: 'fallback',
223
+ symlinks: false,
224
+ symlinks_source: 'fallback',
225
+ exec_bit: false,
226
+ exec_bit_source: 'fallback',
227
+ notes,
228
+ };
229
+
230
+ try {
231
+ dir = mkdtempSync(join(root, 'w2m-caps-'));
232
+ } catch (error) {
233
+ notes.push(`scratch dir unavailable: ${error?.message ?? error}`);
234
+ out.case_sensitive_fs = process.platform !== 'win32' && process.platform !== 'darwin';
235
+ out.symlinks = process.platform !== 'win32';
236
+ out.exec_bit = process.platform !== 'win32';
237
+ return out;
238
+ }
239
+
240
+ try {
241
+ // --- case sensitivity -------------------------------------------------
242
+ const token = randomBytes(6).toString('hex');
243
+ const upper = join(dir, `W2M-Case-${token}`);
244
+ try {
245
+ writeFileSync(upper, 'probe');
246
+ let insensitive = false;
247
+ try {
248
+ statSync(join(dir, `w2m-case-${token}`));
249
+ insensitive = true;
250
+ } catch {
251
+ insensitive = false;
252
+ }
253
+ out.case_sensitive_fs = !insensitive;
254
+ out.case_sensitive_source = 'probe';
255
+ } catch (error) {
256
+ notes.push(`case probe failed: ${error?.message ?? error}`);
257
+ }
258
+
259
+ // --- symlinks ---------------------------------------------------------
260
+ const target = join(dir, 'symlink-target.txt');
261
+ const link = join(dir, 'symlink-link.txt');
262
+ try {
263
+ writeFileSync(target, 'probe');
264
+ symlinkSync(target, link, 'file');
265
+ out.symlinks = true;
266
+ out.symlinks_source = 'probe';
267
+ } catch (error) {
268
+ const code = error?.code ?? 'UNKNOWN';
269
+ out.symlinks = false;
270
+ out.symlinks_source = `probe:${code}`;
271
+ notes.push(`symlink probe denied (${code})`);
272
+ }
273
+
274
+ // --- POSIX execute bit ------------------------------------------------
275
+ const execFile = join(dir, 'exec-probe.sh');
276
+ try {
277
+ writeFileSync(execFile, '#!/bin/sh\nexit 0\n');
278
+ chmodSync(execFile, 0o755);
279
+ const mode = statSync(execFile).mode & 0o777;
280
+ out.exec_bit = (mode & 0o100) !== 0;
281
+ out.exec_bit_source = `probe:${mode.toString(8)}`;
282
+ } catch (error) {
283
+ notes.push(`exec-bit probe failed: ${error?.message ?? error}`);
284
+ }
285
+ } finally {
286
+ try {
287
+ if (dir) rmSync(dir, { recursive: true, force: true });
288
+ } catch {
289
+ /* best effort */
290
+ }
291
+ }
292
+
293
+ return out;
294
+ }
295
+
296
+ /**
297
+ * Detect the platform object the contract wants in `platform` (§5.1) and in the
298
+ * pair request (§2.2).
299
+ *
300
+ * `shell` here is the machine's *interactive* shell, reported for the record;
301
+ * task execution never uses it (`shell_id` stays `direct-exec`).
302
+ *
303
+ * @param {object} [options]
304
+ * @param {Record<string,string|undefined>} [options.env]
305
+ * @returns {Promise<{os: string, os_version: string, arch: string, shell: string, shell_version: string|null, shell_path: string|null}>}
306
+ */
307
+ export async function detectPlatform(options = {}) {
308
+ const env = options.env ?? process.env;
309
+ const os = osName(process.platform);
310
+ const arch = normalizeArch(process.arch);
311
+ let osVersion = osRelease();
312
+
313
+ if (process.platform === 'darwin') {
314
+ // APFS builds report the Darwin kernel (e.g. 24.6.0); humans expect 15.x.
315
+ const swVers = isFile('/usr/bin/sw_vers') ? '/usr/bin/sw_vers' : null;
316
+ if (swVers) {
317
+ const { version } = await probeVersion(swVers, ['-productVersion'], /^(\d+(?:\.\d+)*)/m, 5000);
318
+ if (version) osVersion = version;
319
+ }
320
+ }
321
+
322
+ return { os, os_version: osVersion, arch, ...(await detectShell(env)) };
323
+ }
324
+
325
+ /**
326
+ * @param {Record<string,string|undefined>} env
327
+ * @returns {Promise<{shell: string, shell_version: string|null, shell_path: string|null}>}
328
+ */
329
+ async function detectShell(env) {
330
+ if (process.platform === 'win32') {
331
+ const pwsh = findExecutable('pwsh', { env });
332
+ if (pwsh) {
333
+ const { version } = await probeVersion(
334
+ pwsh,
335
+ ['-NoLogo', '-NoProfile', '-Command', '$PSVersionTable.PSVersion.ToString()'],
336
+ /^(\d+(?:\.\d+){1,3})/m,
337
+ );
338
+ return { shell: 'pwsh', shell_version: version, shell_path: pwsh };
339
+ }
340
+ const powershell = findExecutable('powershell', { env });
341
+ if (powershell) {
342
+ const { version } = await probeVersion(
343
+ powershell,
344
+ ['-NoLogo', '-NoProfile', '-Command', '$PSVersionTable.PSVersion.ToString()'],
345
+ /^(\d+(?:\.\d+){1,3})/m,
346
+ );
347
+ return { shell: 'powershell', shell_version: version, shell_path: powershell };
348
+ }
349
+ const cmd = (env.ComSpec && env.ComSpec.trim() !== '' ? env.ComSpec : null) ??
350
+ findExecutable('cmd', { env });
351
+ if (cmd) {
352
+ const { version } = await probeVersion(cmd, ['/d', '/c', 'ver'], /Version\s+(\d+(?:\.\d+)*)/i);
353
+ return { shell: 'cmd', shell_version: version, shell_path: cmd };
354
+ }
355
+ return { shell: 'unknown', shell_version: null, shell_path: null };
356
+ }
357
+
358
+ const fromEnv = env.SHELL && env.SHELL.trim() !== '' ? env.SHELL : null;
359
+ const exe = fromEnv ?? findExecutable('sh', { env });
360
+ if (!exe) return { shell: 'sh', shell_version: null, shell_path: null };
361
+ const name = basename(exe);
362
+ if (name === 'sh') return { shell: 'sh', shell_version: null, shell_path: exe };
363
+ const { version } = await probeVersion(exe, ['--version'], /(\d+\.\d+(?:\.\d+)?)/);
364
+ return { shell: name, shell_version: version, shell_path: exe };
365
+ }
366
+
367
+ /** @param {string} platform @returns {string} */
368
+ export function osName(platform = process.platform) {
369
+ if (platform === 'win32') return 'windows';
370
+ if (platform === 'darwin') return 'macos';
371
+ return platform;
372
+ }
373
+
374
+ /** @param {string} arch @returns {string} */
375
+ export function normalizeArch(arch = process.arch) {
376
+ if (arch === 'x64') return 'x64';
377
+ if (arch === 'arm64') return 'arm64';
378
+ if (arch === 'ia32') return 'ia32';
379
+ return arch;
380
+ }
381
+
382
+ /**
383
+ * Produce the contract's `caps` object plus the evidence behind every value.
384
+ *
385
+ * @param {object} [options]
386
+ * @param {Record<string,string|undefined>} [options.env]
387
+ * @param {string} [options.tmpRoot] Scratch directory for filesystem probes.
388
+ * @returns {Promise<{caps: {case_sensitive_fs: boolean, symlinks: boolean, exec_bit: boolean, python: string|null, npm: string|null, node: string|null}, detail: object}>}
389
+ */
390
+ export async function probeCapsDetailed(options = {}) {
391
+ const env = options.env ?? process.env;
392
+ const fsCaps = probeFilesystemCaps(options.tmpRoot ?? tmpdir());
393
+ const depsDir = dshDependenciesDir(env);
394
+
395
+ const node = await probeNode(env, depsDir);
396
+ const python = await probePython(env, depsDir);
397
+ const npm = await probeNpm(env, depsDir, node.path);
398
+
399
+ const caps = {
400
+ case_sensitive_fs: fsCaps.case_sensitive_fs,
401
+ symlinks: fsCaps.symlinks,
402
+ exec_bit: fsCaps.exec_bit,
403
+ python: python.version,
404
+ npm: npm.version,
405
+ node: node.version,
406
+ };
407
+
408
+ return {
409
+ caps,
410
+ detail: {
411
+ case_sensitive_fs: { value: caps.case_sensitive_fs, source: fsCaps.case_sensitive_source },
412
+ symlinks: { value: caps.symlinks, source: fsCaps.symlinks_source },
413
+ exec_bit: { value: caps.exec_bit, source: fsCaps.exec_bit_source },
414
+ python: { value: python.version, path: python.path, source: python.source, error: python.error },
415
+ npm: { value: npm.version, path: npm.path, source: npm.source, error: npm.error },
416
+ node: { value: node.version, path: node.path, source: node.source, error: node.error },
417
+ dsh_dependencies_dir: depsDir,
418
+ notes: fsCaps.notes,
419
+ },
420
+ };
421
+ }
422
+
423
+ /**
424
+ * Contract-shaped caps only (no detail keys), for pair requests and envelopes.
425
+ *
426
+ * @param {object} [options]
427
+ * @returns {Promise<{case_sensitive_fs: boolean, symlinks: boolean, exec_bit: boolean, python: string|null, npm: string|null, node: string|null}>}
428
+ */
429
+ export async function probeCaps(options = {}) {
430
+ return (await probeCapsDetailed(options)).caps;
431
+ }
432
+
433
+ /** The bare executable name of a resolved tool, for logging. */
434
+ export function commandLabel(exe) {
435
+ const base = basename(exe);
436
+ return extname(base) === '' ? base : base.slice(0, -extname(base).length);
437
+ }