@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.
- package/CHANGELOG.md +1309 -0
- package/LICENSE +21 -0
- package/PROTOCOL-v0.1.2.md +165 -0
- package/PROTOCOL-v0.3.0.md +454 -0
- package/PROTOCOL-v0.3.9.md +207 -0
- package/PROTOCOL-v0.4.0.md +255 -0
- package/PROTOCOL.md +339 -0
- package/README.md +665 -2
- package/bin/w2m-localside.mjs +303 -0
- package/bin/w2m-rabbit.mjs +396 -0
- package/bin/w2m-stun.mjs +231 -0
- package/cordis.patch.yml +94 -0
- package/deploy/caddy/Caddyfile.example +118 -0
- package/deploy/docker/Dockerfile +144 -0
- package/deploy/docker/README.md +338 -0
- package/deploy/docker/docker-compose.yml +179 -0
- package/deploy/nginx/w2m.conf.example +196 -0
- package/deploy/shared-server/README.md +118 -0
- package/deploy/shared-server/install.sh +255 -0
- package/deploy/systemd/w2m-rabbit.service +150 -0
- package/deploy/systemd/w2m-stun.service +69 -0
- package/deploy/windows/README.md +115 -0
- package/deploy/windows/install-service.ps1 +195 -0
- package/deploy/windows/w2m-agent-supervisor.mjs +205 -0
- package/deploy/windows/winsw/w2m-localside.xml +50 -0
- package/docs/DEPLOY.md +902 -0
- package/docs/LINT-AUDIT.md +492 -0
- package/docs/PIPELINE-DESIGN.md +95 -0
- package/docs/TROUBLESHOOTING.md +560 -0
- package/docs/UI-CARDS.md +81 -0
- package/lib/index.js +24 -0
- package/lib/tools.js +11 -0
- package/package.json +133 -4
- package/src/agent/agent.mjs +3317 -0
- package/src/agent/allowed-commands.mjs +116 -0
- package/src/agent/caps.mjs +437 -0
- package/src/agent/exec.mjs +356 -0
- package/src/agent/git.mjs +616 -0
- package/src/agent/identity.mjs +253 -0
- package/src/agent/p2p-node.mjs +1243 -0
- package/src/agent/p2p.mjs +605 -0
- package/src/agent/resolve.mjs +148 -0
- package/src/agent/spool.mjs +252 -0
- package/src/agent/stun.mjs +397 -0
- package/src/agent/url.mjs +127 -0
- package/src/plugin/auto-update.mjs +574 -0
- package/src/plugin/schedule.mjs +452 -0
- package/src/plugin/shared-config.mjs +264 -0
- package/src/plugin/tools.mjs +3891 -0
- package/src/plugin/update-install.mjs +764 -0
- package/src/plugin/update-source.mjs +686 -0
- package/src/relay/metrics.mjs +284 -0
- package/src/relay/peers.mjs +180 -0
- package/src/relay/persistence.mjs +207 -0
- package/src/relay/report.mjs +591 -0
- package/src/relay/server.mjs +1187 -0
- package/src/relay/state.mjs +2004 -0
- package/src/relay/stun-server.mjs +733 -0
- package/src/signing.mjs +359 -0
- 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
|
+
}
|