@commonlyai/cli 0.1.231 → 0.1.233
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/package.json +1 -1
- package/src/commands/agent.js +31 -2
- package/src/commands/daemon.js +54 -7
- package/src/lib/adapters/claude.js +108 -22
- package/src/lib/adapters/codex.js +27 -5
- package/src/lib/adapters/opencode.js +10 -4
- package/src/lib/adapters/qwen.js +7 -2
- package/src/lib/adapters/tested-version.js +16 -0
- package/src/lib/daemon-service-windows.js +256 -0
- package/src/lib/daemon-service.js +32 -7
- package/src/lib/daemon-store.js +8 -3
- package/src/lib/daemon-supervisor.js +8 -1
- package/src/lib/grant-broker-guard.js +10 -2
- package/src/lib/native-runtime-inventory.js +9 -2
- package/src/lib/sandbox/bwrap.js +9 -3
- package/src/lib/sandbox/host-check.js +16 -2
- package/src/lib/sandbox/windows-unsandboxed.js +131 -0
- package/src/lib/windows-command.js +348 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D245: the user's own opt-in to run an agent without a sandbox on native
|
|
3
|
+
* Windows.
|
|
4
|
+
*
|
|
5
|
+
* Commonly confines a public seat with Seatbelt on macOS and bubblewrap on
|
|
6
|
+
* Linux (mode.js). Windows has neither, so without this a public Claude Code,
|
|
7
|
+
* OpenCode or Qwen seat is refused on Windows (host-check.js). The opt-in
|
|
8
|
+
* keeps that refusal as the default and lets the person at the keyboard choose
|
|
9
|
+
* otherwise, for this machine only:
|
|
10
|
+
*
|
|
11
|
+
* - `commonly agent run <name> --unsandboxed` for one foreground run, or
|
|
12
|
+
* - `commonly daemon install --unsandboxed`, which writes the marker below; the
|
|
13
|
+
* seats that daemon spawns read it. Installing again without the flag, or
|
|
14
|
+
* uninstalling, removes it.
|
|
15
|
+
*
|
|
16
|
+
* Only a LOCAL action sets it. Nothing the server sends (a seat's declared
|
|
17
|
+
* environment, an adopted-seat row, a pod's settings) can create the marker or
|
|
18
|
+
* stand in for the flag: `hostUnconfinedForRun` takes the decision as an
|
|
19
|
+
* argument, and the adapter reads it from its spawn context, never from the
|
|
20
|
+
* environment.
|
|
21
|
+
*
|
|
22
|
+
* Only the OS jail is skipped. The seat keeps its trust, and with it the
|
|
23
|
+
* public tool policy (--setting-sources '', --strict-mcp-config, dontAsk,
|
|
24
|
+
* Read(./**), the denied tools), the sanitized environment and the TASK-294
|
|
25
|
+
* per-turn placement: on a host with no OS isolation that policy IS the
|
|
26
|
+
* sandbox. An earlier form of this rewrote the environment to `mode: 'none'`,
|
|
27
|
+
* which dropped the trust and the policy with it (review of #3388). The
|
|
28
|
+
* decision now travels as `hostUnconfined` on the adapter's spawn context,
|
|
29
|
+
* set only by `agent run`, so the environment, stored or derived, never
|
|
30
|
+
* carries it and nothing the server sends can set it.
|
|
31
|
+
*
|
|
32
|
+
* Claude Code only: it is the adapter that applies the public policy without
|
|
33
|
+
* its jail. OpenCode and Qwen stay refused on Windows, and Codex is never
|
|
34
|
+
* refused there (it confines with its own profiles). No seat on native Windows
|
|
35
|
+
* gets the grant broker (grant-broker-guard.js), and the server refuses grant
|
|
36
|
+
* use for every seat on a machine whose daemon reports the opt-in.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { existsSync, readFileSync, rmSync } from 'fs';
|
|
40
|
+
import { homedir } from 'os';
|
|
41
|
+
import { join } from 'path';
|
|
42
|
+
|
|
43
|
+
import { writeServiceFile } from '../daemon-service.js';
|
|
44
|
+
import { UNSANDBOXED_FLAG } from './host-check.js';
|
|
45
|
+
|
|
46
|
+
export { UNSANDBOXED_FLAG };
|
|
47
|
+
|
|
48
|
+
export const unsandboxedMarkerPath = (home = homedir()) => join(home, '.commonly', 'daemon', 'unsandboxed.json');
|
|
49
|
+
|
|
50
|
+
/** The flag is a Windows answer to a Windows gap; anywhere else it is refused. */
|
|
51
|
+
export const unsandboxedFlagRefusal = (platform = process.platform) => (platform === 'win32'
|
|
52
|
+
? null
|
|
53
|
+
: `${UNSANDBOXED_FLAG} is only for native Windows, which has no sandbox for agents yet. On this machine agents run in a sandbox.`);
|
|
54
|
+
|
|
55
|
+
export const loadUnsandboxedOptIn = ({
|
|
56
|
+
platform = process.platform, home = homedir(), exists = existsSync, read = readFileSync,
|
|
57
|
+
} = {}) => {
|
|
58
|
+
if (platform !== 'win32') return false;
|
|
59
|
+
const file = unsandboxedMarkerPath(home);
|
|
60
|
+
if (!exists(file)) return false;
|
|
61
|
+
try {
|
|
62
|
+
const value = JSON.parse(read(file, 'utf8'));
|
|
63
|
+
return value?.unsandboxed === true && value?.platform === 'win32';
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const saveUnsandboxedOptIn = ({ home = homedir(), writeFile = writeServiceFile, now = () => new Date() } = {}) => {
|
|
70
|
+
writeFile(unsandboxedMarkerPath(home), `${JSON.stringify({ unsandboxed: true, platform: 'win32', optedInAt: now().toISOString() })}\n`);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const clearUnsandboxedOptIn = ({ home = homedir(), exists = existsSync, remove = rmSync } = {}) => {
|
|
74
|
+
const file = unsandboxedMarkerPath(home);
|
|
75
|
+
if (!exists(file)) return false;
|
|
76
|
+
remove(file);
|
|
77
|
+
return true;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Did the user opt in for THIS run? The typed flag, or, for a seat the daemon
|
|
82
|
+
* spawned, the marker that `daemon install --unsandboxed` wrote. A foreground
|
|
83
|
+
* run never reads the marker, so installing the daemon unsandboxed does not
|
|
84
|
+
* quietly change what a hand-started `agent run` does.
|
|
85
|
+
*/
|
|
86
|
+
export const runOptedInUnsandboxed = ({ flag, env = process.env, load = loadUnsandboxedOptIn } = {}) => (
|
|
87
|
+
flag === true || (env.COMMONLY_MANAGED_RUNNER === '1' && load() === true)
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
/** The adapters that can run their trust's tool policy without the OS jail. */
|
|
91
|
+
export const POLICY_ONLY_ADAPTERS = new Set(['claude']);
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Does this run skip the OS jail? Only on win32, only by the user's own
|
|
95
|
+
* opt-in, only for an adapter that keeps its policy without the jail, and only
|
|
96
|
+
* for a seat whose trust brings that policy (public or workspace). A seat with
|
|
97
|
+
* no confined trust is the owner's own and is never jailed anywhere, so the
|
|
98
|
+
* opt-in changes nothing for it, and the notice must not promise it a policy.
|
|
99
|
+
*/
|
|
100
|
+
export const hostUnconfinedForRun = ({
|
|
101
|
+
optedIn, adapterName, environment, platform = process.platform,
|
|
102
|
+
}) => {
|
|
103
|
+
const trust = environment?.sandbox?.trust;
|
|
104
|
+
return platform === 'win32' && optedIn === true && POLICY_ONLY_ADAPTERS.has(adapterName)
|
|
105
|
+
&& (trust === 'public' || trust === 'workspace');
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* What the daemon's check-in says about this host. The server refuses grant
|
|
110
|
+
* use for every seat on a machine that reports either Windows value.
|
|
111
|
+
*/
|
|
112
|
+
export const hostSandboxReport = ({ platform = process.platform, load = () => loadUnsandboxedOptIn({ platform }) } = {}) => {
|
|
113
|
+
if (platform !== 'win32') return 'default';
|
|
114
|
+
return load() === true ? 'unsandboxed-opted-in' : 'native-windows';
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** One plain line for the run log and `daemon status`. */
|
|
118
|
+
export const UNSANDBOXED_NOTICE = 'unsandboxed (opted in): Windows has no sandbox, so nothing at the OS level confines this agent. It keeps Commonly\'s restricted tool settings, gets no shell even with --allow write, and gets no connector grants.';
|
|
119
|
+
|
|
120
|
+
export default {
|
|
121
|
+
UNSANDBOXED_FLAG,
|
|
122
|
+
UNSANDBOXED_NOTICE,
|
|
123
|
+
clearUnsandboxedOptIn,
|
|
124
|
+
loadUnsandboxedOptIn,
|
|
125
|
+
runOptedInUnsandboxed,
|
|
126
|
+
saveUnsandboxedOptIn,
|
|
127
|
+
unsandboxedFlagRefusal,
|
|
128
|
+
unsandboxedMarkerPath,
|
|
129
|
+
hostUnconfinedForRun,
|
|
130
|
+
hostSandboxReport,
|
|
131
|
+
};
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
// Launching an npm-installed CLI (claude, codex) on native Windows, without a
|
|
2
|
+
// shell (#2141).
|
|
3
|
+
//
|
|
4
|
+
// Two things stand between `spawn('codex', args)` and a running Codex on
|
|
5
|
+
// Windows. libuv's PATH search tries only the bare name plus `.com` and
|
|
6
|
+
// `.exe`, so the `codex.cmd` npm writes is ENOENT. And Node >= 18.20.2 /
|
|
7
|
+
// 20.12.2 refuses to spawn a `.cmd`/`.bat` without `shell: true` (EINVAL,
|
|
8
|
+
// CVE-2024-27980). `shell: true` is not an option here: the prompt is pod
|
|
9
|
+
// content, so it is untrusted, and it travels as an argv element. cmd.exe
|
|
10
|
+
// would parse it (a command-injection path for anyone who can post in the
|
|
11
|
+
// pod) and would cap the whole command line at 8191 characters.
|
|
12
|
+
//
|
|
13
|
+
// So the shim is read, never run. npm's cmd-shim writes one of a few fixed
|
|
14
|
+
// shapes, each naming the real target relative to the shim's own folder
|
|
15
|
+
// (`%dp0%` / `%~dp0`). A JS target runs as `node <script>`, an `.exe` target
|
|
16
|
+
// runs as itself; anything else is refused with a reason, never handed to a
|
|
17
|
+
// shell. On every other platform this module is a passthrough.
|
|
18
|
+
|
|
19
|
+
import { existsSync as realExistsSync, readFileSync as realReadFileSync } from 'fs';
|
|
20
|
+
import { win32 } from 'path';
|
|
21
|
+
|
|
22
|
+
const DEFAULT_PATHEXT = '.COM;.EXE;.BAT;.CMD';
|
|
23
|
+
const BINARY_EXTS = new Set(['.exe', '.com']);
|
|
24
|
+
const SHIM_EXTS = new Set(['.cmd', '.bat']);
|
|
25
|
+
const SCRIPT_EXTS = new Set(['.js', '.mjs', '.cjs']);
|
|
26
|
+
|
|
27
|
+
// `%dp0%\…` (cmd-shim >= 4, where `dp0` is SET from `%~dp0` in a subroutine)
|
|
28
|
+
// and `%~dp0\…` (cmd-shim <= 3). `%~dp0` already ends in a backslash, so the
|
|
29
|
+
// separator after it is optional.
|
|
30
|
+
const DP0_PREFIX = /^%(?:~dp0|dp0%)\\?/i;
|
|
31
|
+
|
|
32
|
+
// A flag the shim passes before the script (from the script's shebang line,
|
|
33
|
+
// e.g. `#!/usr/bin/env node --no-warnings`). Only plain tokens are accepted:
|
|
34
|
+
// nothing a batch file would expand or redirect.
|
|
35
|
+
const SAFE_SHIM_ARG = /^[A-Za-z0-9_.=:/+,-]+$/;
|
|
36
|
+
|
|
37
|
+
const SEPARATORS = new Set(['&', '&&', '|', '||']);
|
|
38
|
+
|
|
39
|
+
const extOf = (file) => win32.extname(file).toLowerCase();
|
|
40
|
+
|
|
41
|
+
// Windows environment keys are case-insensitive, but a copied env object is
|
|
42
|
+
// not: `{ ...process.env, PATH }` can carry both `Path` and `PATH`. Read
|
|
43
|
+
// `Path` first (the casing Windows itself uses), then `PATH`, then any other
|
|
44
|
+
// casing, and drop duplicates.
|
|
45
|
+
const pathEntries = (env = {}) => {
|
|
46
|
+
const keys = Object.keys(env).filter((key) => key.toLowerCase() === 'path');
|
|
47
|
+
const rank = (key) => (key === 'Path' ? 0 : key === 'PATH' ? 1 : 2);
|
|
48
|
+
keys.sort((a, b) => rank(a) - rank(b));
|
|
49
|
+
const seen = new Set();
|
|
50
|
+
const entries = [];
|
|
51
|
+
for (const key of keys) {
|
|
52
|
+
for (const raw of String(env[key] || '').split(';')) {
|
|
53
|
+
const dir = raw.trim().replace(/^"(.*)"$/, '$1');
|
|
54
|
+
if (!dir || seen.has(dir.toLowerCase())) continue;
|
|
55
|
+
seen.add(dir.toLowerCase());
|
|
56
|
+
entries.push(dir);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return entries;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const pathExtensions = (env = {}) => {
|
|
63
|
+
const key = Object.keys(env).find((k) => k.toLowerCase() === 'pathext');
|
|
64
|
+
const raw = (key && env[key]) || DEFAULT_PATHEXT;
|
|
65
|
+
return String(raw).split(';').map((ext) => ext.trim()).filter((ext) => ext.startsWith('.'));
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// Split one batch line into tokens: a "quoted string" or a run of
|
|
69
|
+
// non-space characters. Quotes are kept as a flag, not in the value.
|
|
70
|
+
const tokenize = (line) => {
|
|
71
|
+
const tokens = [];
|
|
72
|
+
const re = /"([^"]*)"|([^\s"]+)/g;
|
|
73
|
+
let match;
|
|
74
|
+
while ((match = re.exec(line)) !== null) {
|
|
75
|
+
tokens.push(match[1] !== undefined
|
|
76
|
+
? { value: match[1], quoted: true }
|
|
77
|
+
: { value: match[2], quoted: false });
|
|
78
|
+
}
|
|
79
|
+
return tokens;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const fromShimDir = (value, shimDir) => {
|
|
83
|
+
if (!DP0_PREFIX.test(value)) return null;
|
|
84
|
+
const rest = value.replace(DP0_PREFIX, '');
|
|
85
|
+
return win32.resolve(shimDir, rest);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const isNodeName = (value) => /^node(?:\.exe)?$/i.test(value);
|
|
89
|
+
|
|
90
|
+
const isNodeBesideShim = (value, shimDir) => {
|
|
91
|
+
const abs = fromShimDir(value, shimDir);
|
|
92
|
+
return Boolean(abs) && win32.basename(abs).toLowerCase() === 'node.exe'
|
|
93
|
+
&& win32.dirname(abs).toLowerCase() === win32.resolve(shimDir).toLowerCase();
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// The program a JS shim runs must be node: bare `node`, `node.exe` beside
|
|
97
|
+
// the shim, or the `%_prog%` variable the current cmd-shim SETs to one of
|
|
98
|
+
// those two. A shim whose program is anything else (cmd-shim writes
|
|
99
|
+
// `/bin/sh` for a shell-script bin) is refused.
|
|
100
|
+
const programIsNode = (token, shimDir, text) => {
|
|
101
|
+
if (!token) return false;
|
|
102
|
+
if (!token.quoted && isNodeName(token.value)) return true;
|
|
103
|
+
if (token.quoted && isNodeBesideShim(token.value, shimDir)) return true;
|
|
104
|
+
if (token.quoted && token.value.toLowerCase() === '%_prog%') {
|
|
105
|
+
const values = [...text.matchAll(/SET\s+"_prog=([^"]*)"/gi)].map((m) => m[1]);
|
|
106
|
+
return values.length > 0
|
|
107
|
+
&& values.every((value) => isNodeName(value) || isNodeBesideShim(value, shimDir));
|
|
108
|
+
}
|
|
109
|
+
return false;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const envValue = (env, name) => {
|
|
113
|
+
const key = Object.keys(env || {}).find((k) => k.toLowerCase() === name.toLowerCase());
|
|
114
|
+
return key ? env[key] : undefined;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// npm's own `npm.cmd` / `npx.cmd` (npm/bin/*.cmd, which the Node installer
|
|
118
|
+
// copies next to node.exe) is hand-written, not a cmd-shim:
|
|
119
|
+
//
|
|
120
|
+
// SET "NODE_EXE=%~dp0\node.exe" (else `node`)
|
|
121
|
+
// SET "NPX_CLI_JS=%~dp0\node_modules\npm\bin\npx-cli.js"
|
|
122
|
+
// FOR /F … ('CALL "%NODE_EXE%" "%NPM_PREFIX_JS%"') … SET "NPM_PREFIX_NPX_CLI_JS=%%F\node_modules\npm\bin\npx-cli.js"
|
|
123
|
+
// IF EXIST "%NPM_PREFIX_NPX_CLI_JS%" ( SET "NPX_CLI_JS=%NPM_PREFIX_NPX_CLI_JS%" )
|
|
124
|
+
// "%NODE_EXE%" "%NPX_CLI_JS%" %*
|
|
125
|
+
//
|
|
126
|
+
// The FOR line runs npm-prefix.js to find the global prefix, so an npm the
|
|
127
|
+
// person upgraded with `npm i -g npm` wins over the bundled one. That prefix
|
|
128
|
+
// is %APPDATA%\npm unless an npmrc says otherwise; it is checked here, and a
|
|
129
|
+
// custom prefix falls back to the npm that shipped with node.
|
|
130
|
+
const NPM_LAUNCH = /^"%NODE_EXE%"\s+"%(NP[MX]_CLI_JS)%"\s+%\*$/i;
|
|
131
|
+
|
|
132
|
+
const parseNpmLauncher = (lines, shimPath, { existsSync, execPath, env }) => {
|
|
133
|
+
const launch = lines.map((line) => line.match(NPM_LAUNCH)).find(Boolean);
|
|
134
|
+
if (!launch) return null;
|
|
135
|
+
const variable = launch[1];
|
|
136
|
+
const shimDir = win32.dirname(shimPath);
|
|
137
|
+
const declared = lines
|
|
138
|
+
.map((line) => line.match(new RegExp(`^SET\\s+"${variable}=([^"]*)"$`, 'i')))
|
|
139
|
+
.find((match) => match && DP0_PREFIX.test(match[1]));
|
|
140
|
+
const bundled = declared ? fromShimDir(declared[1], shimDir) : null;
|
|
141
|
+
if (!bundled) return { command: null, reason: `${shimPath} does not name npm's script beside itself` };
|
|
142
|
+
const appData = envValue(env, 'APPDATA');
|
|
143
|
+
const upgraded = appData
|
|
144
|
+
? win32.join(appData, 'npm', 'node_modules', 'npm', 'bin', win32.basename(bundled))
|
|
145
|
+
: null;
|
|
146
|
+
const script = upgraded && existsSync(upgraded) ? upgraded : bundled;
|
|
147
|
+
if (!existsSync(script)) return { command: null, reason: `${shimPath} points at ${script}, which does not exist` };
|
|
148
|
+
const nodeBeside = win32.join(shimDir, 'node.exe');
|
|
149
|
+
return { command: existsSync(nodeBeside) ? nodeBeside : execPath, args: [script] };
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Read an npm cmd-shim and return what it would run, as an argv prefix.
|
|
154
|
+
* Shapes, from cmd-shim's generator (npm/cmd-shim lib/index.js, read at
|
|
155
|
+
* v8.0.0 as bundled with npm, and its pre-4.0 output):
|
|
156
|
+
*
|
|
157
|
+
* current JS: endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" [flags] "%dp0%\…\cli.js" %*
|
|
158
|
+
* current exe: "%dp0%\…\tool.exe" %*
|
|
159
|
+
* npm's own: "%NODE_EXE%" "%NPX_CLI_JS%" %* (see parseNpmLauncher)
|
|
160
|
+
* legacy JS: @IF EXIST "%~dp0\node.exe" ( "%~dp0\node.exe" "%~dp0\…\cli.js" %* ) ELSE ( … node "%~dp0\…\cli.js" %* )
|
|
161
|
+
*
|
|
162
|
+
* @returns {{ command: string, args: string[] } | { command: null, reason: string }}
|
|
163
|
+
*/
|
|
164
|
+
export const parseCmdShim = (text, shimPath, {
|
|
165
|
+
existsSync = realExistsSync, execPath = process.execPath, env = {},
|
|
166
|
+
} = {}) => {
|
|
167
|
+
const shimDir = win32.dirname(shimPath);
|
|
168
|
+
const lines = String(text)
|
|
169
|
+
.split(/\r?\n/)
|
|
170
|
+
.map((line) => line.trim().replace(/^@/, ''))
|
|
171
|
+
.filter(Boolean);
|
|
172
|
+
const npmLauncher = parseNpmLauncher(lines, shimPath, { existsSync, execPath, env });
|
|
173
|
+
if (npmLauncher) return npmLauncher;
|
|
174
|
+
// The launch line is the one that forwards the caller's arguments. The
|
|
175
|
+
// legacy shape has two (node beside the shim, else node on PATH) that name
|
|
176
|
+
// the same script; the last one is the fallback branch.
|
|
177
|
+
const launches = lines
|
|
178
|
+
.map(tokenize)
|
|
179
|
+
.filter((tokens) => tokens.length > 0 && !tokens[tokens.length - 1].quoted
|
|
180
|
+
&& tokens[tokens.length - 1].value === '%*');
|
|
181
|
+
if (launches.length === 0) {
|
|
182
|
+
return { command: null, reason: `${shimPath} is not an npm cmd-shim: no line forwards %*` };
|
|
183
|
+
}
|
|
184
|
+
let segment = launches[launches.length - 1];
|
|
185
|
+
// Drop everything up to the last command separator (the current shape
|
|
186
|
+
// prefixes `endLocal & goto … & ` to the launch).
|
|
187
|
+
for (let i = segment.length - 1; i >= 0; i -= 1) {
|
|
188
|
+
if (!segment[i].quoted && SEPARATORS.has(segment[i].value)) {
|
|
189
|
+
segment = segment.slice(i + 1);
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const legacyIfBranch = segment[0] && !segment[0].quoted && /^\($/.test(segment[0].value);
|
|
194
|
+
if (legacyIfBranch) segment = segment.slice(1);
|
|
195
|
+
const unsafe = segment.slice(0, -1).find((token) => !token.quoted && /[&|<>^()%]/.test(token.value));
|
|
196
|
+
if (unsafe) {
|
|
197
|
+
return { command: null, reason: `${shimPath} has a launch line this CLI will not interpret (${unsafe.value})` };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const targetToken = segment[segment.length - 2];
|
|
201
|
+
const target = targetToken?.quoted ? fromShimDir(targetToken.value, shimDir) : null;
|
|
202
|
+
if (!target) {
|
|
203
|
+
return { command: null, reason: `${shimPath} does not name a target beside itself` };
|
|
204
|
+
}
|
|
205
|
+
if (!existsSync(target)) {
|
|
206
|
+
return { command: null, reason: `${shimPath} points at ${target}, which does not exist` };
|
|
207
|
+
}
|
|
208
|
+
const ext = extOf(target);
|
|
209
|
+
|
|
210
|
+
if (BINARY_EXTS.has(ext)) {
|
|
211
|
+
if (segment.length !== 2) {
|
|
212
|
+
return { command: null, reason: `${shimPath} runs ${target} through another program` };
|
|
213
|
+
}
|
|
214
|
+
return { command: target, args: [] };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (SCRIPT_EXTS.has(ext)) {
|
|
218
|
+
const program = segment[0];
|
|
219
|
+
const shimArgs = segment.slice(1, -2);
|
|
220
|
+
if (segment.length < 3 || !programIsNode(program, shimDir, String(text))) {
|
|
221
|
+
return { command: null, reason: `${shimPath} runs ${target} with a program other than node` };
|
|
222
|
+
}
|
|
223
|
+
const badArg = shimArgs.find((token) => token.quoted || !SAFE_SHIM_ARG.test(token.value));
|
|
224
|
+
if (badArg) {
|
|
225
|
+
return { command: null, reason: `${shimPath} passes an argument this CLI will not interpret (${badArg.value})` };
|
|
226
|
+
}
|
|
227
|
+
const nodeBeside = win32.join(shimDir, 'node.exe');
|
|
228
|
+
const node = existsSync(nodeBeside) ? nodeBeside : execPath;
|
|
229
|
+
return { command: node, args: [...shimArgs.map((token) => token.value), target] };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return { command: null, reason: `${shimPath} launches ${target}, which is neither a script nor an .exe` };
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const classify = (file, deps) => {
|
|
236
|
+
const ext = extOf(file);
|
|
237
|
+
if (BINARY_EXTS.has(ext)) return { command: file, args: [], source: file };
|
|
238
|
+
if (SHIM_EXTS.has(ext)) {
|
|
239
|
+
let text;
|
|
240
|
+
try {
|
|
241
|
+
text = deps.readFileSync(file, 'utf8');
|
|
242
|
+
} catch (err) {
|
|
243
|
+
return { command: null, reason: `${file} could not be read: ${err.message}`, source: file };
|
|
244
|
+
}
|
|
245
|
+
return { ...parseCmdShim(text, file, deps), source: file };
|
|
246
|
+
}
|
|
247
|
+
return null;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Resolve `name` to something spawnable without a shell, with the reason when
|
|
252
|
+
* it cannot be. On anything but win32, `{ command: name, args: [] }`.
|
|
253
|
+
* On win32, `source` is the file found on PATH (the shim, or the .exe).
|
|
254
|
+
*
|
|
255
|
+
* @returns {{ command: string, args: string[], source?: string }
|
|
256
|
+
* | { command: null, reason: string, source?: string }}
|
|
257
|
+
*/
|
|
258
|
+
export const resolveWindowsCommandWithReason = (name, {
|
|
259
|
+
platform = process.platform,
|
|
260
|
+
env = process.env,
|
|
261
|
+
existsSync = realExistsSync,
|
|
262
|
+
readFileSync = realReadFileSync,
|
|
263
|
+
execPath = process.execPath,
|
|
264
|
+
} = {}) => {
|
|
265
|
+
if (platform !== 'win32') return { command: name, args: [] };
|
|
266
|
+
const deps = { existsSync, readFileSync, execPath, env };
|
|
267
|
+
const exts = pathExtensions(env);
|
|
268
|
+
const hasKnownExt = [...BINARY_EXTS, ...SHIM_EXTS].includes(extOf(name));
|
|
269
|
+
// PATHEXT is upper case by convention and NTFS ignores case, so the
|
|
270
|
+
// extension is appended in lower case: the same file, a tidier path.
|
|
271
|
+
const candidatesFor = (base) => (hasKnownExt ? [base] : exts.map((ext) => base + ext.toLowerCase()));
|
|
272
|
+
|
|
273
|
+
// An explicit path is checked where it points, never searched for.
|
|
274
|
+
if (/[\\/]/.test(name) || win32.isAbsolute(name)) {
|
|
275
|
+
// A path that names an existing file is that file, whatever its extension.
|
|
276
|
+
for (const candidate of existsSync(name) ? [name] : candidatesFor(name)) {
|
|
277
|
+
if (!existsSync(candidate)) continue;
|
|
278
|
+
return classify(candidate, deps) || {
|
|
279
|
+
command: null,
|
|
280
|
+
reason: `${candidate} is a ${extOf(candidate)} file, which can only run through a shell`,
|
|
281
|
+
source: candidate,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
return { command: null, reason: `${name} was not found` };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Windows' own order: each PATH folder in turn, each PATHEXT extension
|
|
288
|
+
// within it. The first file that exists is what a shell would run, so it
|
|
289
|
+
// is decisive: when it is something this CLI cannot launch without a shell
|
|
290
|
+
// (a `.vbs`, a `.js`, a `.ps1` someone added to PATHEXT), the answer is a
|
|
291
|
+
// refusal, never a later match, which would be a different program from the
|
|
292
|
+
// one the person runs by typing the name.
|
|
293
|
+
for (const dir of pathEntries(env)) {
|
|
294
|
+
for (const candidate of candidatesFor(win32.join(dir, name))) {
|
|
295
|
+
if (!existsSync(candidate)) continue;
|
|
296
|
+
return classify(candidate, deps) || {
|
|
297
|
+
command: null,
|
|
298
|
+
reason: `${candidate} is first on PATH, and a ${extOf(candidate)} file can only run through a shell`,
|
|
299
|
+
source: candidate,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return { command: null, reason: `${name} was not found on PATH (searched ${exts.join(', ')})` };
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
/** `resolveWindowsCommandWithReason` without the reason: null when unresolvable. */
|
|
307
|
+
export const resolveWindowsCommand = (name, deps) => {
|
|
308
|
+
const result = resolveWindowsCommandWithReason(name, deps);
|
|
309
|
+
return result.command ? { command: result.command, args: result.args } : null;
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* The command and argv an adapter spawns: unchanged off Windows; on Windows,
|
|
314
|
+
* the resolved target with its argv prefix ahead of the adapter's own args.
|
|
315
|
+
* Throws (code ENOENT) with the reason when the name cannot be launched
|
|
316
|
+
* without a shell.
|
|
317
|
+
*/
|
|
318
|
+
export const resolveLaunch = (command, args, deps = {}) => {
|
|
319
|
+
const resolved = resolveWindowsCommandWithReason(command, deps);
|
|
320
|
+
if (!resolved.command) {
|
|
321
|
+
const err = new Error(`cannot launch ${command} on Windows without a shell: ${resolved.reason}`);
|
|
322
|
+
err.code = 'ENOENT';
|
|
323
|
+
throw err;
|
|
324
|
+
}
|
|
325
|
+
return { command: resolved.command, args: [...resolved.args, ...args], source: resolved.source };
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* A stdio MCP server's argv as the runtime (Claude Code, Codex) should launch
|
|
330
|
+
* it. Those runtimes spawn the server themselves, with no shell, so on Windows
|
|
331
|
+
* the default `npx -y @commonlyai/mcp@latest` would hit the same `npx.cmd`
|
|
332
|
+
* wall. On win32, `argv[0]` is resolved here and its prefix spliced in (an npm
|
|
333
|
+
* shim becomes `node.exe …\npx-cli.js -y @commonlyai/mcp@latest`). When it
|
|
334
|
+
* cannot be resolved the entry is left as declared and one line says why.
|
|
335
|
+
* Off Windows the argv is returned as is (the same array).
|
|
336
|
+
*/
|
|
337
|
+
export const resolveMcpCommand = (argv, deps = {}, { label = 'mcp', name = '', warn } = {}) => {
|
|
338
|
+
if ((deps.platform || process.platform) !== 'win32') return argv;
|
|
339
|
+
if (!Array.isArray(argv) || argv.length === 0 || typeof argv[0] !== 'string') return argv;
|
|
340
|
+
const resolved = resolveWindowsCommandWithReason(argv[0], deps);
|
|
341
|
+
if (!resolved.command) {
|
|
342
|
+
const line = `[${label}] MCP server ${name || argv[0]}: left as declared, ${resolved.reason}`;
|
|
343
|
+
// eslint-disable-next-line no-console
|
|
344
|
+
(warn || console.warn)(line);
|
|
345
|
+
return argv;
|
|
346
|
+
}
|
|
347
|
+
return [resolved.command, ...resolved.args, ...argv.slice(1)];
|
|
348
|
+
};
|