@commonlyai/cli 0.1.76 → 0.1.77
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 +4 -1
- package/src/lib/adapters/claude.js +13 -3
- package/src/lib/adapters/codex.js +12 -5
- package/src/lib/adapters/pi.js +7 -2
- package/src/lib/mcp-home.js +465 -0
- package/src/lib/mcp-server-version.js +34 -23
- package/src/lib/mcp-warm-child.mjs +388 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonlyai/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.77",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "The Commonly CLI — connect agents, manage pods, iterate fast",
|
|
6
6
|
"type": "module",
|
|
@@ -42,6 +42,9 @@
|
|
|
42
42
|
},
|
|
43
43
|
"jest": {
|
|
44
44
|
"testEnvironment": "node",
|
|
45
|
+
"setupFiles": [
|
|
46
|
+
"<rootDir>/jest.setup.cjs"
|
|
47
|
+
],
|
|
45
48
|
"transform": {},
|
|
46
49
|
"testMatch": [
|
|
47
50
|
"**/__tests__/**/*.[jt]s?(x)",
|
|
@@ -74,6 +74,7 @@ import {
|
|
|
74
74
|
} from '../sandbox/seatbelt.js';
|
|
75
75
|
import { CREDENTIAL_FILE_VAR, CREDENTIAL_KEY, writeCredentialFile } from '../credential-file.js';
|
|
76
76
|
import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credential-delivery.js';
|
|
77
|
+
import { prepareMcpSpawn } from '../mcp-home.js';
|
|
77
78
|
import { buildMemoryPreamble } from '../memory-bridge.js';
|
|
78
79
|
import { adapterFailure, spawnCredentials } from '../upstream-refusal.js';
|
|
79
80
|
|
|
@@ -341,10 +342,19 @@ const buildMcpConfig = (mcpServers, ctx = {}) => {
|
|
|
341
342
|
// config, which claude's `--mcp-config` reads directly.
|
|
342
343
|
const mcpServersMap = {};
|
|
343
344
|
for (const server of mcpServers) {
|
|
345
|
+
// TASK-174: the command this seat will EXECUTE, not the one it declared —
|
|
346
|
+
// `node <home>/<version>/node_modules/@commonlyai/mcp/<bin>` when the seat's
|
|
347
|
+
// MCP home has a warmed build, and otherwise the unchanged declaration. The
|
|
348
|
+
// credential channel below is decided on that same command, so the two can
|
|
349
|
+
// never disagree about which release is running.
|
|
350
|
+
const spawnCommand = server.command
|
|
351
|
+
? prepareMcpSpawn(server.command, { apiUrl: ctx.instanceUrl })
|
|
352
|
+
: null;
|
|
353
|
+
const executed = spawnCommand ? { ...server, command: spawnCommand } : server;
|
|
344
354
|
const entry = { type: server.transport || 'stdio' };
|
|
345
355
|
if (server.url) entry.url = server.url;
|
|
346
|
-
if (
|
|
347
|
-
const [command, ...args] =
|
|
356
|
+
if (spawnCommand) {
|
|
357
|
+
const [command, ...args] = spawnCommand;
|
|
348
358
|
entry.command = command;
|
|
349
359
|
if (args.length) entry.args = args;
|
|
350
360
|
}
|
|
@@ -352,7 +362,7 @@ const buildMcpConfig = (mcpServers, ctx = {}) => {
|
|
|
352
362
|
// config claude actually reads, so the value never has to exist in the
|
|
353
363
|
// runtime's environment at all (TASK-083).
|
|
354
364
|
if (server.env) {
|
|
355
|
-
entry.env = deliverSeatCredential(
|
|
365
|
+
entry.env = deliverSeatCredential(executed, {
|
|
356
366
|
credentialFile: ctx.credentialFile,
|
|
357
367
|
label: 'claude',
|
|
358
368
|
}).env;
|
|
@@ -58,6 +58,7 @@ import {
|
|
|
58
58
|
} from 'path';
|
|
59
59
|
import { CREDENTIAL_KEY, writeCredentialFile } from '../credential-file.js';
|
|
60
60
|
import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credential-delivery.js';
|
|
61
|
+
import { prepareMcpSpawn } from '../mcp-home.js';
|
|
61
62
|
import { buildMemoryPreamble } from '../memory-bridge.js';
|
|
62
63
|
import { isLegacySandboxTrust, normalizeSandboxTrust } from '../environment.js';
|
|
63
64
|
import { adapterFailure, spawnCredentials } from '../upstream-refusal.js';
|
|
@@ -138,20 +139,26 @@ const buildMcpOverrideArgs = (mcpServers, ctx = {}) => {
|
|
|
138
139
|
const flags = [];
|
|
139
140
|
const forwardedEnv = {};
|
|
140
141
|
for (const server of mcpServers || []) {
|
|
142
|
+
const transport = typeof server?.transport === 'string' ? server.transport.trim().toLowerCase() : 'stdio';
|
|
143
|
+
if (!server?.name || transport !== 'stdio'
|
|
144
|
+
|| !Array.isArray(server.command) || !server.command.length) continue;
|
|
145
|
+
// TASK-174: the command that will EXECUTE — the warmed build from the seat's
|
|
146
|
+
// MCP home when there is one — and the credential channel is decided on that
|
|
147
|
+
// same command, so the two can never disagree about which release runs. The
|
|
148
|
+
// rewrite is before `substitutePlaceholders` because it introduces no
|
|
149
|
+
// placeholder: it is a path this host resolved.
|
|
150
|
+
const spawnCommand = prepareMcpSpawn(server.command, { apiUrl: ctx.instanceUrl });
|
|
141
151
|
// A declared credential is rewritten to the file channel before anything is
|
|
142
152
|
// substituted, so for our server the token is not token-bearing at all: it
|
|
143
153
|
// lands in `env={...}` as a path and never reaches `env_vars`, which is the
|
|
144
154
|
// one path by which the value ends up in codex's own environment and from
|
|
145
155
|
// there in every MCP child it spawns (TASK-083, measured on a live codex
|
|
146
156
|
// seat before the change).
|
|
147
|
-
const declaredEnv = deliverSeatCredential(server, {
|
|
157
|
+
const declaredEnv = deliverSeatCredential({ ...server, command: spawnCommand }, {
|
|
148
158
|
credentialFile: ctx.credentialFile,
|
|
149
159
|
label: 'codex',
|
|
150
160
|
}).env;
|
|
151
|
-
const
|
|
152
|
-
if (!server?.name || transport !== 'stdio'
|
|
153
|
-
|| !Array.isArray(server.command) || !server.command.length) continue;
|
|
154
|
-
const [command, ...rest] = server.command.map((a) => substitutePlaceholders(a, ctx));
|
|
161
|
+
const [command, ...rest] = spawnCommand.map((a) => substitutePlaceholders(a, ctx));
|
|
155
162
|
// The doc block above promises bearer tokens never ride in argv, and this is
|
|
156
163
|
// the place that had to hold it: an env value that carries the token is
|
|
157
164
|
// diverted to env_vars, but a COMMAND ARGUMENT has no such route — codex
|
package/src/lib/adapters/pi.js
CHANGED
|
@@ -46,6 +46,7 @@ import { buildMemoryPreamble } from '../memory-bridge.js';
|
|
|
46
46
|
import { effectiveSandboxTrust } from '../environment.js';
|
|
47
47
|
import { GRANT_BROKER_REFUSAL, isGrantBrokerUrl } from './pi-mcp-client.mjs';
|
|
48
48
|
import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credential-delivery.js';
|
|
49
|
+
import { prepareMcpSpawn } from '../mcp-home.js';
|
|
49
50
|
import { removeCredentialFile, writeCredentialFile } from '../credential-file.js';
|
|
50
51
|
|
|
51
52
|
const DEFAULT_TIMEOUT_MS = (() => {
|
|
@@ -207,13 +208,17 @@ export const resolveMcpServers = (mcpServers, ctx = {}) => {
|
|
|
207
208
|
}
|
|
208
209
|
continue;
|
|
209
210
|
}
|
|
211
|
+
// TASK-174: what will EXECUTE — the warmed build from the seat's MCP home
|
|
212
|
+
// when there is one — and the credential channel is decided on that same
|
|
213
|
+
// command, so the two can never disagree about which release runs.
|
|
214
|
+
const spawnCommand = prepareMcpSpawn(server.command, { apiUrl: ctx.instanceUrl });
|
|
210
215
|
carried.push({
|
|
211
216
|
name: server.name,
|
|
212
|
-
command:
|
|
217
|
+
command: spawnCommand.map((a) => substitutePlaceholders(a, ctx)),
|
|
213
218
|
// Rewritten before substitution, so our own server is handed the file
|
|
214
219
|
// (whose value the bridge pipes) instead of the token itself.
|
|
215
220
|
env: Object.fromEntries(Object.entries(
|
|
216
|
-
deliverSeatCredential(server, {
|
|
221
|
+
deliverSeatCredential({ ...server, command: spawnCommand }, {
|
|
217
222
|
credentialFile: ctx.credentialFile,
|
|
218
223
|
label: 'pi',
|
|
219
224
|
}).env,
|
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a seat's `@commonlyai/mcp` is executed FROM.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists (TASK-174). Every seat's default declaration is
|
|
5
|
+
* `npx -y @commonlyai/mcp@latest` (default-environment.js), and npx names its
|
|
6
|
+
* cache dir by the hash of the SPEC STRING — npm 11.12.1,
|
|
7
|
+
* libnpmexec/lib/index.js:238-250, sha512(spec)[0:16]. So
|
|
8
|
+
* sha512('@commonlyai/mcp@latest')[0:16] = 6d82e98be466b586 is ONE dir shared by
|
|
9
|
+
* every seat, rewritten in place when the first spawn after a publish sees a new
|
|
10
|
+
* version, and every npx run does registry + lock work before the server is even
|
|
11
|
+
* started. Two failures were measured on 2026-09-27: seats lost the server for
|
|
12
|
+
* minutes after the 0.3.13 publish at 10:47:57Z (the in-place rebuild), and Vera
|
|
13
|
+
* got a 30 s CONNECT_TIMEOUT with no publish at all (the shared dir's mtime
|
|
14
|
+
* moves on every npx run, so a slow registry round-trip alone exceeds the
|
|
15
|
+
* client's connect budget).
|
|
16
|
+
*
|
|
17
|
+
* The fix takes npx OFF the spawn path. A spawn executes
|
|
18
|
+
* `node <home>/<version>/node_modules/@commonlyai/mcp/<bin>` for the version a
|
|
19
|
+
* pointer file names; versions are installed by EXACT spec into their own
|
|
20
|
+
* version dir by a background single-flight warm (mcp-warm-child.mjs), and the
|
|
21
|
+
* pointer moves only after the new bin answers MCP `initialize`.
|
|
22
|
+
*
|
|
23
|
+
* The DECLARATION is not touched. `declared-mcp-guard.isShippedCommonlyMcpEntry`
|
|
24
|
+
* still audits `npx -y @commonlyai/mcp@latest` verbatim (TASK-150) — that guard
|
|
25
|
+
* judges what an operator declared, and this module only changes what a spawn
|
|
26
|
+
* executes. A declaration that PINS a version is never rewritten: that is the
|
|
27
|
+
* operator saying which build to run, and the npx cache already gives a pinned
|
|
28
|
+
* spec its own dir.
|
|
29
|
+
*
|
|
30
|
+
* Every filesystem answer here is injected, because the two states that matter
|
|
31
|
+
* (a home that has never been warmed, and a home whose pointed-at version dir was
|
|
32
|
+
* deleted under it) cannot both be produced on one host.
|
|
33
|
+
*/
|
|
34
|
+
import { spawn } from 'node:child_process';
|
|
35
|
+
import {
|
|
36
|
+
existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync,
|
|
37
|
+
} from 'node:fs';
|
|
38
|
+
import { homedir } from 'node:os';
|
|
39
|
+
import { dirname, join, sep } from 'node:path';
|
|
40
|
+
import { fileURLToPath } from 'node:url';
|
|
41
|
+
import { isShippedCommonlyMcpCommand } from './declared-mcp-guard.js';
|
|
42
|
+
import { MCP_PACKAGE, parseVersion } from './mcp-server-version.js';
|
|
43
|
+
|
|
44
|
+
/** The env override, so tests (and an operator) can point the home elsewhere. */
|
|
45
|
+
export const MCP_HOME_ENV = 'COMMONLY_MCP_HOME';
|
|
46
|
+
|
|
47
|
+
export const WARM_LOCK_NAME = '.warm.lock';
|
|
48
|
+
export const REGISTRY_CACHE_NAME = '.registry.json';
|
|
49
|
+
|
|
50
|
+
/** How long a cached registry answer is trusted before the warm re-checks. */
|
|
51
|
+
export const REGISTRY_TTL_MS = 15 * 60 * 1000;
|
|
52
|
+
|
|
53
|
+
/** A lock older than this is a warm that died; reclaim rather than wedge. */
|
|
54
|
+
export const STALE_LOCK_MS = 10 * 60 * 1000;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Version dirs kept after a successful advance.
|
|
58
|
+
*
|
|
59
|
+
* This is a DISK BOUND, not the safety rule. Retention by count cannot express
|
|
60
|
+
* "not while someone is running it": keeping the newest three protects the
|
|
61
|
+
* newest three, and a seat still executing the fourth-newest dir is exactly the
|
|
62
|
+
* case that matters. A DIR IS REMOVABLE only when it is not `current`, carries
|
|
63
|
+
* no LIVE liveness claim, and is past `PRUNE_GRACE_MS` (Vera, 74878/74887).
|
|
64
|
+
*/
|
|
65
|
+
export const KEEP_VERSION_DIRS = 3;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A version dir younger than this is never pruned, even with no live claimant.
|
|
69
|
+
*
|
|
70
|
+
* Time, not count, is what bounds the exposure: a dir inside the window may
|
|
71
|
+
* belong to a spawn that is starting up and has not written its claim yet, or
|
|
72
|
+
* whose claim was written by a pid the kernel has since recycled.
|
|
73
|
+
*/
|
|
74
|
+
export const PRUNE_GRACE_MS = 24 * 60 * 60 * 1000;
|
|
75
|
+
|
|
76
|
+
/** Where a spawn records that a process may still be executing from a version. */
|
|
77
|
+
export const IN_USE_DIR_NAME = '.inuse';
|
|
78
|
+
|
|
79
|
+
/** The warm child's own exit codes, read by the spawn path's logs. */
|
|
80
|
+
export const WARM_RESULTS = Object.freeze({
|
|
81
|
+
LOCKED: 'locked',
|
|
82
|
+
CURRENT: 'current',
|
|
83
|
+
ADVANCED: 'advanced',
|
|
84
|
+
REGISTRY_UNAVAILABLE: 'registry-unavailable',
|
|
85
|
+
INSTALL_FAILED: 'install-failed',
|
|
86
|
+
PROBE_FAILED: 'probe-failed',
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
export const mcpHomeDir = (env = process.env) => {
|
|
90
|
+
const configured = env && env[MCP_HOME_ENV];
|
|
91
|
+
// A DEFECT THIS LITERALLY PREVENTED, 2026-09-27: `process.env.X = undefined`
|
|
92
|
+
// does not unset X, it sets the STRING "undefined" (Node coerces env values),
|
|
93
|
+
// so a caller that saved and restored an unset override handed the warm the
|
|
94
|
+
// home `./undefined/` — which it created, populated and pointed at, inside the
|
|
95
|
+
// package directory. This value is a PATH; a value that is not a plausible
|
|
96
|
+
// path is not a home, and the real home is the safe answer.
|
|
97
|
+
if (typeof configured === 'string' && configured && configured !== 'undefined' && configured !== 'null') {
|
|
98
|
+
return configured;
|
|
99
|
+
}
|
|
100
|
+
return join(homedir(), '.commonly', 'mcp');
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/** True for a home a warm may write into: a real, absolute-looking path. */
|
|
104
|
+
export const isUsableWarmHome = (home) => (
|
|
105
|
+
typeof home === 'string'
|
|
106
|
+
&& home.length > 1
|
|
107
|
+
&& home !== 'undefined'
|
|
108
|
+
&& home !== 'null'
|
|
109
|
+
&& (home.startsWith('/') || /^[A-Za-z]:[\\/]/.test(home))
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
/** The home a caller asked for, or the configured one — never a junk string. */
|
|
113
|
+
export const resolveHomeDir = (home) => (isUsableWarmHome(home) ? home : mcpHomeDir());
|
|
114
|
+
|
|
115
|
+
export const currentPointerPath = (home) => join(home, 'current');
|
|
116
|
+
export const versionDirFor = (home, version) => join(home, version);
|
|
117
|
+
export const packageDirFor = (home, version) => join(home, version, 'node_modules', MCP_PACKAGE);
|
|
118
|
+
export const lockPathFor = (home) => join(home, WARM_LOCK_NAME);
|
|
119
|
+
|
|
120
|
+
/** `<home>/<version>/.inuse/` — one empty file per claiming pid. */
|
|
121
|
+
export const inUseDirFor = (home, version) => join(versionDirFor(home, version), IN_USE_DIR_NAME);
|
|
122
|
+
export const inUseMarkerPath = (home, version, pid) => join(inUseDirFor(home, version), String(pid));
|
|
123
|
+
|
|
124
|
+
/** A pid is a positive integer, or it is not a pid. */
|
|
125
|
+
const parsePid = (value) => {
|
|
126
|
+
const pid = Number(value);
|
|
127
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Is this pid still running? `kill(pid, 0)` asks the kernel and sends nothing.
|
|
132
|
+
*
|
|
133
|
+
* EPERM means the pid exists and belongs to another user — alive. ESRCH means
|
|
134
|
+
* gone — dead. Anything else, including a value that was never a pid, is DEAD,
|
|
135
|
+
* because the two errors are not symmetric: guessing "alive" pins a version dir
|
|
136
|
+
* forever (unbounded disk with extra steps), while guessing "dead" costs one
|
|
137
|
+
* reinstall on a later warm.
|
|
138
|
+
*/
|
|
139
|
+
export const isPidAlive = (pid, { kill = process.kill } = {}) => {
|
|
140
|
+
const parsed = parsePid(pid);
|
|
141
|
+
if (parsed === null) return false;
|
|
142
|
+
try {
|
|
143
|
+
kill(parsed, 0);
|
|
144
|
+
return true;
|
|
145
|
+
} catch (error) {
|
|
146
|
+
return Boolean(error) && error.code === 'EPERM';
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The claimants under a version dir that are still running.
|
|
152
|
+
*
|
|
153
|
+
* The marker's PRESENCE is not the signal — a seat that is killed or crashes
|
|
154
|
+
* leaves its file behind, and a pruner that read presence would never remove
|
|
155
|
+
* that version again. The check is whether the claimant still exists; a dead or
|
|
156
|
+
* unparseable marker counts as absent.
|
|
157
|
+
*/
|
|
158
|
+
export const liveInUsePids = (home, version, {
|
|
159
|
+
readDir = (dir) => {
|
|
160
|
+
try {
|
|
161
|
+
return readdirSync(dir);
|
|
162
|
+
} catch {
|
|
163
|
+
return [];
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
isAlive = isPidAlive,
|
|
167
|
+
} = {}) => readDir(inUseDirFor(home, version))
|
|
168
|
+
.map(parsePid)
|
|
169
|
+
.filter((pid) => pid !== null && isAlive(pid));
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Claim a version dir for a process that may be executing from it.
|
|
173
|
+
*
|
|
174
|
+
* Called by `prepareMcpSpawn`, keyed by THIS process: the MCP server runs as a
|
|
175
|
+
* descendant of the process that materialised the command, so while that process
|
|
176
|
+
* lives its version dir must not be pruned. Best-effort by construction — a home
|
|
177
|
+
* that cannot be written to costs the seat nothing but its claim.
|
|
178
|
+
*/
|
|
179
|
+
export const claimInUse = ({
|
|
180
|
+
home,
|
|
181
|
+
version,
|
|
182
|
+
pid = process.pid,
|
|
183
|
+
mkdir = mkdirSync,
|
|
184
|
+
writeFile = writeFileSync,
|
|
185
|
+
} = {}) => {
|
|
186
|
+
try {
|
|
187
|
+
if (!isUsableWarmHome(home) || !parseVersion(version) || parsePid(pid) === null) return false;
|
|
188
|
+
mkdir(inUseDirFor(home, version), { recursive: true });
|
|
189
|
+
writeFile(inUseMarkerPath(home, version, pid), '');
|
|
190
|
+
return true;
|
|
191
|
+
} catch {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const defaultRead = (path) => {
|
|
197
|
+
try {
|
|
198
|
+
return existsSync(path) ? readFileSync(path, 'utf8') : null;
|
|
199
|
+
} catch {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const defaultExists = (path) => {
|
|
205
|
+
try {
|
|
206
|
+
return existsSync(path);
|
|
207
|
+
} catch {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The version the pointer file names, or null.
|
|
214
|
+
*
|
|
215
|
+
* A pointer that does not parse is treated as no pointer at all: a spawn must
|
|
216
|
+
* never build a path out of a string it did not validate, and the fallback is
|
|
217
|
+
* the declared command, not a guess.
|
|
218
|
+
*/
|
|
219
|
+
export const readCurrentVersion = (home, { readFile = defaultRead } = {}) => {
|
|
220
|
+
const raw = readFile(currentPointerPath(home));
|
|
221
|
+
if (typeof raw !== 'string') return null;
|
|
222
|
+
const version = raw.trim();
|
|
223
|
+
return parseVersion(version) ? version : null;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The bin a package.json beside a script names, resolved — never guessed.
|
|
228
|
+
*
|
|
229
|
+
* `bin` is `{ 'commonly-mcp': 'src/index.js' }` at 0.3.13 and a bare string in
|
|
230
|
+
* older releases, so both shapes are read. A dir whose bin is missing on disk is
|
|
231
|
+
* not a build this seat can run: returning null there is what sends a spawn back
|
|
232
|
+
* to the declared command instead of exec'ing a path that does not exist.
|
|
233
|
+
*/
|
|
234
|
+
const readBinInPackageDir = (pkgDir, {
|
|
235
|
+
readFile = defaultRead, exists = defaultExists,
|
|
236
|
+
} = {}) => {
|
|
237
|
+
const raw = readFile(join(pkgDir, 'package.json'));
|
|
238
|
+
if (typeof raw !== 'string') return null;
|
|
239
|
+
let pkg;
|
|
240
|
+
try {
|
|
241
|
+
pkg = JSON.parse(raw);
|
|
242
|
+
} catch {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
if (!pkg || typeof pkg !== 'object') return null;
|
|
246
|
+
// A package.json naming another package settles it: this dir is not ours.
|
|
247
|
+
if (pkg.name !== undefined && pkg.name !== MCP_PACKAGE) return null;
|
|
248
|
+
const bin = pkg.bin;
|
|
249
|
+
let relative = null;
|
|
250
|
+
if (typeof bin === 'string' && bin) {
|
|
251
|
+
relative = bin;
|
|
252
|
+
} else if (bin && typeof bin === 'object') {
|
|
253
|
+
const keys = Object.keys(bin).filter((k) => typeof bin[k] === 'string' && bin[k]);
|
|
254
|
+
if (keys.length === 1) [relative] = keys.map((k) => bin[k]);
|
|
255
|
+
else if (typeof bin['commonly-mcp'] === 'string' && bin['commonly-mcp']) relative = bin['commonly-mcp'];
|
|
256
|
+
}
|
|
257
|
+
if (!relative) return null;
|
|
258
|
+
const binPath = join(pkgDir, relative);
|
|
259
|
+
// `join` resolves `..`, so this is the whole confinement. The package is our
|
|
260
|
+
// own published build and today `bin` cannot escape — but a check that costs
|
|
261
|
+
// one line makes the exec path self-evident instead of trusted (Vera, 74880).
|
|
262
|
+
if (!binPath.startsWith(`${pkgDir}${sep}`)) return null;
|
|
263
|
+
return exists(binPath) ? binPath : null;
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
/** The bin of a version dir inside the seat's home. */
|
|
267
|
+
export const readBinPath = (home, version, options = {}) => {
|
|
268
|
+
if (!parseVersion(version)) return null;
|
|
269
|
+
return readBinInPackageDir(packageDirFor(home, version), options);
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The bin of an npm PREFIX dir, which is the shape the warm installs into:
|
|
274
|
+
* `npm install --prefix <dir>` leaves `<dir>/node_modules/...`, with no version
|
|
275
|
+
* component in the path. Reading the two shapes with one function would mean
|
|
276
|
+
* looking for a version dir inside the temp prefix and concluding the install
|
|
277
|
+
* failed when it did not.
|
|
278
|
+
*/
|
|
279
|
+
export const readBinInPrefix = (prefix, options = {}) => (
|
|
280
|
+
readBinInPackageDir(join(prefix, 'node_modules', MCP_PACKAGE), options)
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* The newest version dir in the home that actually has a runnable bin.
|
|
285
|
+
*
|
|
286
|
+
* The pointer is only written after a verified warm, so it is normally the whole
|
|
287
|
+
* answer. This is the recovery path for the case the pointer cannot cover: the
|
|
288
|
+
* pointed-at version dir deleted under a live seat (hand-pruned, a wiped home,
|
|
289
|
+
* npm falling back to a reinstall). Falling back to the next-newest local build
|
|
290
|
+
* keeps the spawn off npx even then, which is the whole point of the change.
|
|
291
|
+
*/
|
|
292
|
+
export const newestInstalledVersion = (home, {
|
|
293
|
+
readDir = (dir) => {
|
|
294
|
+
try {
|
|
295
|
+
return readdirSync(dir);
|
|
296
|
+
} catch {
|
|
297
|
+
return [];
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
readBin = (h, v) => readBinPath(h, v),
|
|
301
|
+
} = {}) => {
|
|
302
|
+
const versions = readDir(home)
|
|
303
|
+
.filter((name) => parseVersion(name))
|
|
304
|
+
.filter((name) => readBin(home, name))
|
|
305
|
+
.sort((a, b) => {
|
|
306
|
+
const [am, an, ap] = parseVersion(a);
|
|
307
|
+
const [bm, bn, bp] = parseVersion(b);
|
|
308
|
+
return (am - bm) || (an - bn) || (ap - bp);
|
|
309
|
+
});
|
|
310
|
+
return versions.length ? versions[versions.length - 1] : null;
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
/** True for the shipped, UNPINNED spec — the only command shape this rewrites. */
|
|
314
|
+
const isUnpinnedShippedSpec = (spec) => (
|
|
315
|
+
spec === MCP_PACKAGE || spec === `${MCP_PACKAGE}@latest`
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* What a spawn should execute, and where that decision came from.
|
|
320
|
+
*
|
|
321
|
+
* `source: 'home'` — the command is `node <bin>` from the version dir, no npx
|
|
322
|
+
* and no network. `source: 'declared'` — the declaration runs as written, which
|
|
323
|
+
* is either an operator's pinned spec or an EMPTY HOME (fresh host, or a home
|
|
324
|
+
* that was deleted). The empty-home path is the old failure mode and it is
|
|
325
|
+
* accepted, written down, and once per empty home rather than once per publish.
|
|
326
|
+
* `reason` names which of those it was, for the spawn path's logs.
|
|
327
|
+
*/
|
|
328
|
+
export const planMcpSpawn = (command, {
|
|
329
|
+
home,
|
|
330
|
+
readFile = defaultRead,
|
|
331
|
+
exists = defaultExists,
|
|
332
|
+
readBin = (h, v) => readBinPath(h, v, { readFile, exists }),
|
|
333
|
+
newest = (h) => newestInstalledVersion(h, { readBin }),
|
|
334
|
+
} = {}) => {
|
|
335
|
+
const target = resolveHomeDir(home);
|
|
336
|
+
if (!isShippedCommonlyMcpCommand(command) || !isUnpinnedShippedSpec(String(command[2]))) {
|
|
337
|
+
return { command, source: 'declared', version: null, reason: 'not-the-shipped-unpinned-spec' };
|
|
338
|
+
}
|
|
339
|
+
const pointer = readCurrentVersion(target, { readFile });
|
|
340
|
+
if (pointer) {
|
|
341
|
+
const bin = readBin(target, pointer);
|
|
342
|
+
if (bin) return { command: ['node', bin], source: 'home', version: pointer, reason: 'pointer' };
|
|
343
|
+
}
|
|
344
|
+
const fallback = newest(target);
|
|
345
|
+
if (fallback) {
|
|
346
|
+
const bin = readBin(target, fallback);
|
|
347
|
+
if (bin) {
|
|
348
|
+
return {
|
|
349
|
+
command: ['node', bin],
|
|
350
|
+
source: 'home',
|
|
351
|
+
version: fallback,
|
|
352
|
+
reason: pointer ? 'pointer-bin-missing' : 'no-pointer',
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return { command, source: 'declared', version: null, reason: 'home-empty' };
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
/** The warm's cached registry answer, with its age. */
|
|
360
|
+
export const readRegistryCache = (home, { readFile = defaultRead } = {}) => {
|
|
361
|
+
const raw = readFile(join(home, REGISTRY_CACHE_NAME));
|
|
362
|
+
if (typeof raw !== 'string') return null;
|
|
363
|
+
try {
|
|
364
|
+
const parsed = JSON.parse(raw);
|
|
365
|
+
const version = parsed && typeof parsed.version === 'string' ? parsed.version : null;
|
|
366
|
+
const checkedAt = parsed && Number.isFinite(parsed.checkedAt) ? parsed.checkedAt : null;
|
|
367
|
+
if (!parseVersion(version) || checkedAt === null) return null;
|
|
368
|
+
return { version, checkedAt };
|
|
369
|
+
} catch {
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
/** True when the cached "latest" is newer than what this spawn is about to run. */
|
|
375
|
+
export const warmLooksWanted = (home, version, { now = Date.now(), readFile = defaultRead } = {}) => {
|
|
376
|
+
const cached = readRegistryCache(home, { readFile });
|
|
377
|
+
if (!cached) return true;
|
|
378
|
+
if (now - cached.checkedAt > REGISTRY_TTL_MS) return true;
|
|
379
|
+
if (!version) return true;
|
|
380
|
+
const target = parseVersion(cached.version);
|
|
381
|
+
const running = parseVersion(version);
|
|
382
|
+
if (!target || !running) return true;
|
|
383
|
+
return (target[0] > running[0])
|
|
384
|
+
|| (target[0] === running[0] && target[1] > running[1])
|
|
385
|
+
|| (target[0] === running[0] && target[1] === running[1] && target[2] > running[2]);
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
export const warmChildPath = () => join(dirname(fileURLToPath(import.meta.url)), 'mcp-warm-child.mjs');
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Start the warm and walk away. Detached + unref'd, so a seat that spawns and
|
|
392
|
+
* exits does not wait on it, and the child is written to never throw at the
|
|
393
|
+
* parent: a warm that fails costs the NEXT spawn nothing but the old version.
|
|
394
|
+
*/
|
|
395
|
+
export const kickWarm = ({
|
|
396
|
+
home,
|
|
397
|
+
apiUrl = null,
|
|
398
|
+
spawnImpl = spawn,
|
|
399
|
+
childPath = warmChildPath(),
|
|
400
|
+
} = {}) => {
|
|
401
|
+
try {
|
|
402
|
+
// argv entries are coerced with String(), so an undefined home reaches the
|
|
403
|
+
// child as the literal 'undefined' and it warms a directory by that name.
|
|
404
|
+
const targetHome = resolveHomeDir(home);
|
|
405
|
+
const child = spawnImpl(process.execPath, [childPath, targetHome], {
|
|
406
|
+
detached: true,
|
|
407
|
+
stdio: 'ignore',
|
|
408
|
+
env: {
|
|
409
|
+
...process.env,
|
|
410
|
+
COMMONLY_MCP_HOME: targetHome,
|
|
411
|
+
...(apiUrl ? { COMMONLY_WARM_API_URL: String(apiUrl) } : {}),
|
|
412
|
+
},
|
|
413
|
+
});
|
|
414
|
+
if (child && typeof child.unref === 'function') child.unref();
|
|
415
|
+
return true;
|
|
416
|
+
} catch {
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
const lockPresent = (home, { exists = defaultExists } = {}) => exists(lockPathFor(home));
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* The one entry the adapters call: what to execute, plus a warm if one is due.
|
|
425
|
+
*
|
|
426
|
+
* Sync and network-free by construction — the registry check, the install and
|
|
427
|
+
* the probe all live in the child. A lock already held means another spawn is
|
|
428
|
+
* warming, so this spawn just runs the current version.
|
|
429
|
+
*/
|
|
430
|
+
export const prepareMcpSpawn = (command, {
|
|
431
|
+
home,
|
|
432
|
+
apiUrl = null,
|
|
433
|
+
spawnImpl,
|
|
434
|
+
childPath,
|
|
435
|
+
now = Date.now(),
|
|
436
|
+
readFile = defaultRead,
|
|
437
|
+
exists = defaultExists,
|
|
438
|
+
} = {}) => {
|
|
439
|
+
const resolvedHome = resolveHomeDir(home);
|
|
440
|
+
try {
|
|
441
|
+
const plan = planMcpSpawn(command, { home: resolvedHome, readFile, exists });
|
|
442
|
+
// Claim the version this spawn is about to execute from, BEFORE returning the
|
|
443
|
+
// command, so a prune that runs while the seat is starting cannot take it.
|
|
444
|
+
if (plan.source === 'home') claimInUse({ home: resolvedHome, version: plan.version });
|
|
445
|
+
const due = !lockPresent(resolvedHome, { exists })
|
|
446
|
+
&& warmLooksWanted(resolvedHome, plan.version, { now, readFile });
|
|
447
|
+
if (due) kickWarm({ home: resolvedHome, apiUrl, spawnImpl, childPath });
|
|
448
|
+
return plan.command;
|
|
449
|
+
} catch {
|
|
450
|
+
// A rewrite that throws must not cost the seat its tools: the declaration
|
|
451
|
+
// is what the guard approved, and it still runs.
|
|
452
|
+
return command;
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
/** Kept for callers that want the plan and the command in one call. */
|
|
457
|
+
export const resolveMcpSpawnCommand = prepareMcpSpawn;
|
|
458
|
+
|
|
459
|
+
export const statOrNull = (path) => {
|
|
460
|
+
try {
|
|
461
|
+
return statSync(path);
|
|
462
|
+
} catch {
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
465
|
+
};
|
|
@@ -51,40 +51,51 @@ export const versionOlderThan = (version, target) => {
|
|
|
51
51
|
* `null` as the return value means "not identifiable as our server", which is a
|
|
52
52
|
* different answer and takes a different branch: a stranger's server gets its
|
|
53
53
|
* declaration honoured unchanged.
|
|
54
|
+
*
|
|
55
|
+
* The ENTRY SCRIPT is checked first, and that ordering is load-bearing since
|
|
56
|
+
* TASK-174. A seat home runs `node <home>/<version>/node_modules/@commonlyai/mcp/<bin>`,
|
|
57
|
+
* and that path CONTAINS the package name — so a spec-first order matched the
|
|
58
|
+
* path, took the `@` of `@commonlyai/mcp` for the version separator, and returned
|
|
59
|
+
* `{ version: null }` ("unpinned, so never old") for every home build. The
|
|
60
|
+
* version then decided nothing: measured, a home holding 0.3.7 was handed the
|
|
61
|
+
* credential FILE channel, whose reader does not exist before 0.3.12. Nor is
|
|
62
|
+
* "a spec has no slash" a usable rule — a SCOPED spec is `@commonlyai/mcp`.
|
|
54
63
|
*/
|
|
55
64
|
export const describeMcpCommand = (command, {
|
|
56
65
|
readTextFile = (p) => (existsSync(p) ? readFileSync(p, 'utf8') : null),
|
|
57
66
|
} = {}) => {
|
|
58
67
|
if (!Array.isArray(command) || command.length === 0) return null;
|
|
59
68
|
const parts = command.map(String);
|
|
69
|
+
const scriptPath = parts.find((p) => p.endsWith('.js') || p.endsWith('.mjs'));
|
|
70
|
+
if (scriptPath) {
|
|
71
|
+
// `src/index.js` → `../package.json`; also try one level further up, because
|
|
72
|
+
// a bin shim can live in `bin/` beside `src/`.
|
|
73
|
+
for (const candidate of [join(dirname(scriptPath), '..', 'package.json'), join(dirname(scriptPath), 'package.json')]) {
|
|
74
|
+
let raw;
|
|
75
|
+
try {
|
|
76
|
+
raw = readTextFile(candidate);
|
|
77
|
+
} catch {
|
|
78
|
+
raw = null;
|
|
79
|
+
}
|
|
80
|
+
if (!raw) continue;
|
|
81
|
+
try {
|
|
82
|
+
const pkg = JSON.parse(raw);
|
|
83
|
+
if (!pkg || typeof pkg !== 'object') continue;
|
|
84
|
+
if (pkg.name === MCP_PACKAGE) return { isCommonly: true, version: parseVersion(pkg.version) };
|
|
85
|
+
// A package.json that names another package settles it: not ours, so its
|
|
86
|
+
// declaration is none of this function's business.
|
|
87
|
+
return null;
|
|
88
|
+
} catch {
|
|
89
|
+
// A malformed package.json is not an answer; keep looking.
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
60
94
|
const pkgArg = parts.find((p) => p.includes(MCP_PACKAGE));
|
|
61
95
|
if (pkgArg) {
|
|
62
96
|
const at = pkgArg.lastIndexOf('@');
|
|
63
97
|
if (at <= pkgArg.indexOf(MCP_PACKAGE)) return { isCommonly: true, version: null };
|
|
64
98
|
return { isCommonly: true, version: parseVersion(pkgArg.slice(at + 1)) };
|
|
65
99
|
}
|
|
66
|
-
const scriptPath = parts.find((p) => p.endsWith('.js') || p.endsWith('.mjs'));
|
|
67
|
-
if (!scriptPath) return null;
|
|
68
|
-
// `src/index.js` → `../package.json`; also try one level further up, because a
|
|
69
|
-
// bin shim can live in `bin/` beside `src/`.
|
|
70
|
-
for (const candidate of [join(dirname(scriptPath), '..', 'package.json'), join(dirname(scriptPath), 'package.json')]) {
|
|
71
|
-
let raw;
|
|
72
|
-
try {
|
|
73
|
-
raw = readTextFile(candidate);
|
|
74
|
-
} catch {
|
|
75
|
-
raw = null;
|
|
76
|
-
}
|
|
77
|
-
if (!raw) continue;
|
|
78
|
-
try {
|
|
79
|
-
const pkg = JSON.parse(raw);
|
|
80
|
-
if (!pkg || typeof pkg !== 'object') continue;
|
|
81
|
-
if (pkg.name === MCP_PACKAGE) return { isCommonly: true, version: parseVersion(pkg.version) };
|
|
82
|
-
// A package.json that names another package settles it: not ours, so its
|
|
83
|
-
// declaration is none of this function's business.
|
|
84
|
-
return null;
|
|
85
|
-
} catch {
|
|
86
|
-
// A malformed package.json is not an answer; keep looking.
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
100
|
return null;
|
|
90
101
|
};
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The background warm for the seat MCP home — see mcp-home.js for why.
|
|
3
|
+
*
|
|
4
|
+
* This runs as a DETACHED child, never on a spawn path, because everything it
|
|
5
|
+
* does is slow or networked: a registry lookup, an `npm install`, and an MCP
|
|
6
|
+
* `initialize` handshake against the freshly installed bin. The spawn path only
|
|
7
|
+
* reads a pointer file and exec's `node <bin>`.
|
|
8
|
+
*
|
|
9
|
+
* Order of operations is the contract: install into `.tmp-<version>-<pid>`,
|
|
10
|
+
* PROBE the bin inside that temp dir, and only then `rename` it into
|
|
11
|
+
* `<home>/<version>` and move the pointer. So a version dir in the home is a
|
|
12
|
+
* version that answered the protocol at least once, and a half-installed or
|
|
13
|
+
* non-starting build can never become what seats execute.
|
|
14
|
+
*
|
|
15
|
+
* Single-flight by lock file (O_EXCL), so concurrent spawns on a fresh host
|
|
16
|
+
* trigger exactly one install. A lock older than STALE_LOCK_MS is a warm that
|
|
17
|
+
* died (a laptop sleeping mid-install is the normal way) and is reclaimed rather
|
|
18
|
+
* than allowed to wedge the home forever.
|
|
19
|
+
*
|
|
20
|
+
* The probe's env is measured, not assumed: at 0.3.13 the bin exits 1 with
|
|
21
|
+
* "[commonly-mcp] fatal: COMMONLY_API_URL is required" and, with that set, exits
|
|
22
|
+
* 1 again on "No runtime token". With both present as PLACEHOLDERS it answers
|
|
23
|
+
* `initialize` with a result — the boot check is presence-only and `initialize`
|
|
24
|
+
* does not touch the network (both measured on this host 2026-09-27), which is
|
|
25
|
+
* what makes a credential-free probe honest rather than a stub.
|
|
26
|
+
*/
|
|
27
|
+
import { spawn as spawnChild } from 'node:child_process';
|
|
28
|
+
import {
|
|
29
|
+
closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync,
|
|
30
|
+
} from 'node:fs';
|
|
31
|
+
import { join } from 'node:path';
|
|
32
|
+
import {
|
|
33
|
+
KEEP_VERSION_DIRS, PRUNE_GRACE_MS, REGISTRY_CACHE_NAME, REGISTRY_TTL_MS, STALE_LOCK_MS,
|
|
34
|
+
WARM_RESULTS, currentPointerPath, isUsableWarmHome, liveInUsePids, lockPathFor,
|
|
35
|
+
readBinInPrefix, versionDirFor,
|
|
36
|
+
} from './mcp-home.js';
|
|
37
|
+
import { MCP_PACKAGE, parseVersion } from './mcp-server-version.js';
|
|
38
|
+
|
|
39
|
+
export const PROBE_API_URL_PLACEHOLDER = 'http://127.0.0.1:1';
|
|
40
|
+
export const PROBE_TOKEN_PLACEHOLDER = 'cm_agent_warm_probe';
|
|
41
|
+
export const PROBE_TIMEOUT_MS = 10 * 1000;
|
|
42
|
+
export const REGISTRY_TIMEOUT_MS = 20 * 1000;
|
|
43
|
+
|
|
44
|
+
const INITIALIZE_REQUEST = JSON.stringify({
|
|
45
|
+
jsonrpc: '2.0',
|
|
46
|
+
id: 1,
|
|
47
|
+
method: 'initialize',
|
|
48
|
+
params: {
|
|
49
|
+
protocolVersion: '2024-11-05',
|
|
50
|
+
capabilities: {},
|
|
51
|
+
clientInfo: { name: 'commonly-cli-warm', version: '1' },
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const exists = (path) => {
|
|
56
|
+
try {
|
|
57
|
+
return existsSync(path);
|
|
58
|
+
} catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const readIfAny = (path) => {
|
|
64
|
+
try {
|
|
65
|
+
return existsSync(path) ? readFileSync(path, 'utf8') : null;
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Take the warm lock, reclaiming one that is too old to belong to a live warm.
|
|
73
|
+
*
|
|
74
|
+
* Returns the open fd, or null when another process holds a fresh lock.
|
|
75
|
+
*/
|
|
76
|
+
export const acquireWarmLock = (home, {
|
|
77
|
+
now = Date.now(),
|
|
78
|
+
open = (path) => openSync(path, 'wx'),
|
|
79
|
+
stat = (path) => statSync(path),
|
|
80
|
+
remove = (path) => unlinkSync(path),
|
|
81
|
+
staleMs = STALE_LOCK_MS,
|
|
82
|
+
} = {}) => {
|
|
83
|
+
const lockPath = lockPathFor(home);
|
|
84
|
+
try {
|
|
85
|
+
return open(lockPath);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (error && error.code !== 'EEXIST') return null;
|
|
88
|
+
}
|
|
89
|
+
let age;
|
|
90
|
+
try {
|
|
91
|
+
age = now - stat(lockPath).mtimeMs;
|
|
92
|
+
} catch {
|
|
93
|
+
return null; // vanished between EEXIST and stat: not ours to break
|
|
94
|
+
}
|
|
95
|
+
if (age <= staleMs) return null;
|
|
96
|
+
try {
|
|
97
|
+
remove(lockPath);
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
return open(lockPath);
|
|
103
|
+
} catch {
|
|
104
|
+
// Lost the reclaim race to another spawn's warm. Fine: that one is working.
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** Default `npm` runner. Resolves `{ ok, stdout }`; never rejects. */
|
|
110
|
+
export const runCommand = (bin, args, { timeout = REGISTRY_TIMEOUT_MS } = {}) => new Promise((resolve) => {
|
|
111
|
+
let settled = false;
|
|
112
|
+
let child;
|
|
113
|
+
const done = (result) => {
|
|
114
|
+
if (settled) return;
|
|
115
|
+
settled = true;
|
|
116
|
+
resolve(result);
|
|
117
|
+
};
|
|
118
|
+
try {
|
|
119
|
+
child = spawnChild(bin, args, { stdio: ['ignore', 'pipe', 'pipe'], env: process.env });
|
|
120
|
+
} catch (error) {
|
|
121
|
+
done({ ok: false, stdout: '', stderr: String(error && error.message) });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
let stdout = '';
|
|
125
|
+
let stderr = '';
|
|
126
|
+
child.stdout.on('data', (d) => { stdout += String(d); });
|
|
127
|
+
child.stderr.on('data', (d) => { stderr += String(d); });
|
|
128
|
+
const timer = setTimeout(() => {
|
|
129
|
+
try {
|
|
130
|
+
child.kill('SIGKILL');
|
|
131
|
+
} catch { /* already gone */ }
|
|
132
|
+
done({ ok: false, stdout, stderr: `${stderr}\n[commonly] timed out after ${timeout}ms` });
|
|
133
|
+
}, timeout);
|
|
134
|
+
child.on('error', (error) => { clearTimeout(timer); done({ ok: false, stdout, stderr: String(error && error.message) }); });
|
|
135
|
+
child.on('close', (code) => { clearTimeout(timer); done({ ok: code === 0, code, stdout, stderr }); });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
/** The registry's current version for the package, cached on disk with a TTL. */
|
|
139
|
+
export const resolveRegistryLatest = async (home, {
|
|
140
|
+
now = Date.now(),
|
|
141
|
+
exec = runCommand,
|
|
142
|
+
ttlMs = REGISTRY_TTL_MS,
|
|
143
|
+
writeFile = (path, text) => writeFileSync(path, text),
|
|
144
|
+
} = {}) => {
|
|
145
|
+
const cachePath = join(home, REGISTRY_CACHE_NAME);
|
|
146
|
+
const cached = readIfAny(cachePath);
|
|
147
|
+
if (cached) {
|
|
148
|
+
try {
|
|
149
|
+
const parsed = JSON.parse(cached);
|
|
150
|
+
if (parsed && Number.isFinite(parsed.checkedAt) && now - parsed.checkedAt < ttlMs
|
|
151
|
+
&& parseVersion(parsed.version)) {
|
|
152
|
+
return { version: parsed.version, source: 'cache' };
|
|
153
|
+
}
|
|
154
|
+
} catch { /* unreadable cache is just a miss */ }
|
|
155
|
+
}
|
|
156
|
+
const result = await exec('npm', ['view', MCP_PACKAGE, 'version', '--json'], { timeout: REGISTRY_TIMEOUT_MS });
|
|
157
|
+
const raw = result && result.ok ? String(result.stdout || '').trim() : '';
|
|
158
|
+
let version = null;
|
|
159
|
+
if (raw) {
|
|
160
|
+
try {
|
|
161
|
+
const parsed = JSON.parse(raw);
|
|
162
|
+
version = typeof parsed === 'string' ? parsed : null;
|
|
163
|
+
} catch {
|
|
164
|
+
version = raw.replace(/^"|"$/g, '');
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (version && parseVersion(version)) {
|
|
168
|
+
try {
|
|
169
|
+
writeFile(cachePath, JSON.stringify({ version, checkedAt: now }));
|
|
170
|
+
} catch { /* a cache we cannot write is not a warm we should fail */ }
|
|
171
|
+
return { version, source: 'registry' };
|
|
172
|
+
}
|
|
173
|
+
// Registry down: the last known answer is better than none, and better than
|
|
174
|
+
// moving the pointer backwards.
|
|
175
|
+
if (cached) {
|
|
176
|
+
try {
|
|
177
|
+
const parsed = JSON.parse(cached);
|
|
178
|
+
if (parseVersion(parsed.version)) return { version: parsed.version, source: 'stale-cache' };
|
|
179
|
+
} catch { /* fall through */ }
|
|
180
|
+
}
|
|
181
|
+
return { version: null, source: 'unavailable' };
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Does the installed bin start and speak the protocol?
|
|
186
|
+
*
|
|
187
|
+
* Accepts a JSON-RPC response to id 1 whether it is a `result` or an `error`:
|
|
188
|
+
* what this proves is that the build runs and answers `initialize`, which is
|
|
189
|
+
* exactly what the pointer's contract claims. It is not an authorization check —
|
|
190
|
+
* the seat's own token is what makes calls work.
|
|
191
|
+
*/
|
|
192
|
+
export const probeBin = (binPath, {
|
|
193
|
+
spawnImpl = spawnChild,
|
|
194
|
+
timeoutMs = PROBE_TIMEOUT_MS,
|
|
195
|
+
apiUrl = process.env.COMMONLY_WARM_API_URL || PROBE_API_URL_PLACEHOLDER,
|
|
196
|
+
} = {}) => new Promise((resolve) => {
|
|
197
|
+
let settled = false;
|
|
198
|
+
let child;
|
|
199
|
+
const done = (ok) => {
|
|
200
|
+
if (settled) return;
|
|
201
|
+
settled = true;
|
|
202
|
+
try {
|
|
203
|
+
if (child) child.kill('SIGKILL');
|
|
204
|
+
} catch { /* already gone */ }
|
|
205
|
+
resolve(ok);
|
|
206
|
+
};
|
|
207
|
+
try {
|
|
208
|
+
child = spawnImpl(process.execPath, [binPath], {
|
|
209
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
210
|
+
env: {
|
|
211
|
+
PATH: process.env.PATH,
|
|
212
|
+
HOME: process.env.HOME,
|
|
213
|
+
COMMONLY_API_URL: String(apiUrl),
|
|
214
|
+
COMMONLY_AGENT_TOKEN: PROBE_TOKEN_PLACEHOLDER,
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
} catch {
|
|
218
|
+
resolve(false);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const timer = setTimeout(() => done(false), timeoutMs);
|
|
222
|
+
let seen = '';
|
|
223
|
+
const onData = (chunk) => {
|
|
224
|
+
seen += String(chunk);
|
|
225
|
+
if (/"id"\s*:\s*1/.test(seen)) {
|
|
226
|
+
clearTimeout(timer);
|
|
227
|
+
done(true);
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
child.stdout.on('data', onData);
|
|
231
|
+
child.on('error', () => { clearTimeout(timer); done(false); });
|
|
232
|
+
child.on('close', () => { clearTimeout(timer); done(false); });
|
|
233
|
+
try {
|
|
234
|
+
child.stdin.write(`${INITIALIZE_REQUEST}\n`);
|
|
235
|
+
} catch {
|
|
236
|
+
clearTimeout(timer);
|
|
237
|
+
done(false);
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const writePointerAtomic = (home, version, { writeTemp = writeFileSync } = {}) => {
|
|
242
|
+
const target = currentPointerPath(home);
|
|
243
|
+
const temp = join(home, `.current-${process.pid}`);
|
|
244
|
+
writeTemp(temp, `${version}\n`);
|
|
245
|
+
renameSync(temp, target);
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
/** Keep the newest N version dirs; the current one is never a candidate. */
|
|
249
|
+
export const pruneVersionDirs = (home, current, {
|
|
250
|
+
keep = KEEP_VERSION_DIRS,
|
|
251
|
+
grace = PRUNE_GRACE_MS,
|
|
252
|
+
now = Date.now(),
|
|
253
|
+
readDir = (dir) => {
|
|
254
|
+
try {
|
|
255
|
+
return readdirSync(dir);
|
|
256
|
+
} catch {
|
|
257
|
+
return [];
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
remove = (path) => rmSync(path, { recursive: true, force: true }),
|
|
261
|
+
mtime = (path) => {
|
|
262
|
+
try {
|
|
263
|
+
return statSync(path).mtimeMs;
|
|
264
|
+
} catch {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
livePids = (version) => liveInUsePids(home, version),
|
|
269
|
+
} = {}) => {
|
|
270
|
+
const versions = readDir(home).filter((name) => parseVersion(name));
|
|
271
|
+
versions.sort((a, b) => {
|
|
272
|
+
const [am, an, ap] = parseVersion(a);
|
|
273
|
+
const [bm, bn, bp] = parseVersion(b);
|
|
274
|
+
return (bm - am) || (bn - an) || (bp - ap);
|
|
275
|
+
});
|
|
276
|
+
const removed = [];
|
|
277
|
+
versions.slice(keep).forEach((name) => {
|
|
278
|
+
if (name === current) return;
|
|
279
|
+
// A live claimant outranks the count: this dir may be executing right now.
|
|
280
|
+
if (livePids(name).length > 0) return;
|
|
281
|
+
// And time outranks the count too — a dir inside the grace window may belong
|
|
282
|
+
// to a spawn that has not written its claim yet. An unreadable/absent mtime
|
|
283
|
+
// is not evidence of age, so it is left alone.
|
|
284
|
+
const installedAt = mtime(versionDirFor(home, name));
|
|
285
|
+
if (typeof installedAt !== 'number' || now - installedAt < grace) return;
|
|
286
|
+
try {
|
|
287
|
+
remove(versionDirFor(home, name));
|
|
288
|
+
removed.push(name);
|
|
289
|
+
} catch { /* a dir we cannot remove is not a failed warm */ }
|
|
290
|
+
});
|
|
291
|
+
return removed;
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* The whole warm, in one function so a test can drive every branch with injected
|
|
296
|
+
* I/O. Returns one of WARM_RESULTS — never throws, because it runs unattended.
|
|
297
|
+
*/
|
|
298
|
+
export const warmMcpHome = async (home, {
|
|
299
|
+
now = Date.now(),
|
|
300
|
+
exec = runCommand,
|
|
301
|
+
probe = probeBin,
|
|
302
|
+
spawnProbe,
|
|
303
|
+
lock = () => acquireWarmLock(home, { now }),
|
|
304
|
+
closeLock = (fd) => closeSync(fd),
|
|
305
|
+
mkdir = (dir) => mkdirSync(dir, { recursive: true }),
|
|
306
|
+
remove = (path) => rmSync(path, { recursive: true, force: true }),
|
|
307
|
+
rename = (from, to) => renameSync(from, to),
|
|
308
|
+
readBin = (prefix) => readBinInPrefix(prefix),
|
|
309
|
+
readCurrent = (h) => {
|
|
310
|
+
const raw = readIfAny(currentPointerPath(h));
|
|
311
|
+
if (typeof raw !== 'string') return null;
|
|
312
|
+
const trimmed = raw.trim();
|
|
313
|
+
return parseVersion(trimmed) ? trimmed : null;
|
|
314
|
+
},
|
|
315
|
+
keep = KEEP_VERSION_DIRS,
|
|
316
|
+
} = {}) => {
|
|
317
|
+
mkdir(home);
|
|
318
|
+
const fd = lock();
|
|
319
|
+
if (fd === null) return WARM_RESULTS.LOCKED;
|
|
320
|
+
try {
|
|
321
|
+
const latest = await resolveRegistryLatest(home, { now, exec });
|
|
322
|
+
if (!latest.version) return WARM_RESULTS.REGISTRY_UNAVAILABLE;
|
|
323
|
+
const target = latest.version;
|
|
324
|
+
const current = readCurrent(home);
|
|
325
|
+
if (current && current === target) return WARM_RESULTS.CURRENT;
|
|
326
|
+
if (current && parseVersion(current) && parseVersion(target)) {
|
|
327
|
+
const [tm, tn, tp] = parseVersion(target);
|
|
328
|
+
const [cm, cn, cp] = parseVersion(current);
|
|
329
|
+
if ((tm < cm) || (tm === cm && tn < cn) || (tm === cm && tn === cn && tp <= cp)) {
|
|
330
|
+
return WARM_RESULTS.CURRENT;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
const temp = join(home, `.tmp-${target}-${process.pid}`);
|
|
334
|
+
remove(temp);
|
|
335
|
+
mkdir(temp);
|
|
336
|
+
const install = await exec('npm', [
|
|
337
|
+
'install', '--prefix', temp, `${MCP_PACKAGE}@${target}`, '--no-save', '--no-audit', '--no-fund',
|
|
338
|
+
], { timeout: 5 * 60 * 1000 });
|
|
339
|
+
if (!install || !install.ok) {
|
|
340
|
+
remove(temp);
|
|
341
|
+
return WARM_RESULTS.INSTALL_FAILED;
|
|
342
|
+
}
|
|
343
|
+
const bin = readBin(temp);
|
|
344
|
+
if (!bin) {
|
|
345
|
+
remove(temp);
|
|
346
|
+
return WARM_RESULTS.INSTALL_FAILED;
|
|
347
|
+
}
|
|
348
|
+
const started = await (spawnProbe || probe)(bin, { apiUrl: process.env.COMMONLY_WARM_API_URL });
|
|
349
|
+
if (!started) {
|
|
350
|
+
remove(temp);
|
|
351
|
+
return WARM_RESULTS.PROBE_FAILED;
|
|
352
|
+
}
|
|
353
|
+
const destination = versionDirFor(home, target);
|
|
354
|
+
if (exists(destination)) remove(temp);
|
|
355
|
+
else rename(temp, destination);
|
|
356
|
+
writePointerAtomic(home, target);
|
|
357
|
+
pruneVersionDirs(home, target, { keep });
|
|
358
|
+
return WARM_RESULTS.ADVANCED;
|
|
359
|
+
} catch {
|
|
360
|
+
return WARM_RESULTS.INSTALL_FAILED;
|
|
361
|
+
} finally {
|
|
362
|
+
try {
|
|
363
|
+
closeLock(fd);
|
|
364
|
+
} catch { /* nothing to close */ }
|
|
365
|
+
try {
|
|
366
|
+
rmSync(lockPathFor(home), { force: true });
|
|
367
|
+
} catch { /* lock is best-effort cleanup */ }
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
const invokedDirectly = process.argv[1] && /mcp-warm-child\.mjs$/.test(process.argv[1]);
|
|
372
|
+
if (invokedDirectly) {
|
|
373
|
+
// Validated, not trusted: argv entries are coerced with String(), so a parent
|
|
374
|
+
// that passed an undefined home sends the literal 'undefined' and this child
|
|
375
|
+
// would otherwise create and populate a directory by that name (which is how
|
|
376
|
+
// one appeared inside the package directory on 2026-09-27).
|
|
377
|
+
const home = process.argv[2];
|
|
378
|
+
if (isUsableWarmHome(home)) {
|
|
379
|
+
warmMcpHome(home, { now: Date.now() })
|
|
380
|
+
.then((result) => {
|
|
381
|
+
// A warm runs with stdio ignored; this is for a human running it by hand
|
|
382
|
+
// and for `--verbose` debugging, and it must never be on a spawn path.
|
|
383
|
+
if (process.env.COMMONLY_WARM_VERBOSE) process.stdout.write(`${result}\n`);
|
|
384
|
+
process.exitCode = 0;
|
|
385
|
+
})
|
|
386
|
+
.catch(() => { process.exitCode = 0; });
|
|
387
|
+
}
|
|
388
|
+
}
|