mcp-pacemaker 2.1.0 → 2.1.2
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 +142 -2
- package/CONTRIBUTING.md +11 -4
- package/README.md +116 -764
- package/SECURITY.md +7 -0
- package/THIRD_PARTY_NOTICES.txt +1 -1
- package/autostart/windows/legacy-access.ps1 +359 -0
- package/bin/account-upgrade-controller.mjs +1 -1
- package/bin/account-upgrade-worker.mjs +7 -0
- package/bin/cli-main.mjs +61 -5
- package/bin/dependency-lock.json +642 -0
- package/bin/legacy-access.mjs +73 -0
- package/bin/legacy-installation.mjs +75 -19
- package/bin/legacy-task.mjs +24 -0
- package/bin/legacy-upgrade.mjs +66 -10
- package/bin/managed-upgrade.mjs +102 -12
- package/bin/mcp-bridge.mjs +115 -20
- package/bin/prewarm.mjs +28 -6
- package/bin/service-control.mjs +10 -5
- package/bin/upgrade-capability.json +2 -1
- package/bin/upgrade-dependencies.mjs +67 -6
- package/bin/upgrade-package.mjs +2 -0
- package/bin/windows-legacy/LegacyProcessBroker.build.json +3 -3
- package/bin/windows-legacy/LegacyProcessBroker.exe +0 -0
- package/bin/windows-legacy/src/LegacyNative.cs +60 -11
- package/bin/windows-legacy/src/LegacyProcessBroker.cs +317 -197
- package/bin/windows-legacy-process.mjs +12 -5
- package/docs/account-upgrades.md +17 -4
- package/docs/comparison.md +167 -0
- package/docs/compatibility.md +39 -0
- package/docs/configuration.md +146 -0
- package/docs/dashboards.md +125 -0
- package/docs/execution-context.md +11 -5
- package/docs/http-api.md +47 -0
- package/docs/install.md +52 -0
- package/docs/operations.md +99 -0
- package/docs/upgrades.md +324 -0
- package/package.json +11 -3
- package/tools/windows-legacy/README.md +118 -92
- package/tools/windows-legacy/inventory.mjs +8 -6
- package/ui/dist/THIRD_PARTY_NOTICES.txt +1 -1
- package/ui/dist/third-party-manifest.json +6 -6
package/bin/mcp-bridge.mjs
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
import http from 'node:http';
|
|
27
27
|
import https from 'node:https';
|
|
28
|
+
import { isIP } from 'node:net';
|
|
28
29
|
import { spawn, exec, execFile, execFileSync } from 'node:child_process';
|
|
29
30
|
import { createHash, randomUUID } from 'node:crypto';
|
|
30
31
|
import { readFileSync, existsSync, writeFileSync, appendFileSync, statSync, renameSync, watch } from 'node:fs';
|
|
@@ -80,8 +81,8 @@ let PORT = Number(getArg('--port', '8791'));
|
|
|
80
81
|
const HOST = getArg('--host', '127.0.0.1');
|
|
81
82
|
let upgradeHeld = process.env.MCP_PACEMAKER_UPGRADE_PENDING === '1';
|
|
82
83
|
let upgradeRequests = 0;
|
|
84
|
+
let upgradeCalls = 0;
|
|
83
85
|
let upgradeTraffic = false;
|
|
84
|
-
let upgradeUncertain = false;
|
|
85
86
|
if (PORT === 0 &&
|
|
86
87
|
(!process.connected || !process.env.MCP_PACEMAKER_MANAGED_INSTANCE || HOST !== '127.0.0.1')) {
|
|
87
88
|
throw new Error('Port 0 requires a managed loopback startup with actual-port readiness.');
|
|
@@ -188,6 +189,16 @@ const log = (m) => {
|
|
|
188
189
|
for (const c of logClients) { try { c.write(`data: ${JSON.stringify(line)}\n\n`); } catch { /* gone */ } }
|
|
189
190
|
};
|
|
190
191
|
|
|
192
|
+
// A crash left no trace: its stack went only to stderr, which nothing captures on Windows (see
|
|
193
|
+
// LOG_FILE). The monitor only records the error. The process still exits as before, and the
|
|
194
|
+
// supervisor restarts it.
|
|
195
|
+
process.on('uncaughtExceptionMonitor', (error, origin) => {
|
|
196
|
+
try {
|
|
197
|
+
const detail = error instanceof Error ? error.stack : String(error);
|
|
198
|
+
appendLog(`[mcp-bridge] ${new Date().toISOString()} fatal ${origin}: ${detail}`);
|
|
199
|
+
} catch { /* recording must never replace the original error */ }
|
|
200
|
+
});
|
|
201
|
+
|
|
191
202
|
// Token expiry (seconds) for an http server's cached command/az token, if any — for the dashboard.
|
|
192
203
|
//
|
|
193
204
|
// A bare `audience` is shorthand for an Azure CLI token command. It is expanded in exactly one
|
|
@@ -450,6 +461,13 @@ function handlePoolingChange(encodedName, req, res) {
|
|
|
450
461
|
trace?.record('body-end', { bytes });
|
|
451
462
|
clearTimeout(bodyTimer);
|
|
452
463
|
if (rejected) return;
|
|
464
|
+
// A body that arrives after an upgrade closed admission must not start config work while
|
|
465
|
+
// the bridge is being stopped.
|
|
466
|
+
if (upgradeHeld ||
|
|
467
|
+
shuttingDown) {
|
|
468
|
+
reply(503, { error: 'Managed upgrade in progress; no change was submitted.' });
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
453
471
|
let body;
|
|
454
472
|
let name;
|
|
455
473
|
try {
|
|
@@ -644,6 +662,10 @@ function spawnServer(name, def) {
|
|
|
644
662
|
child.once('error', () => {
|
|
645
663
|
if (!spawned) serverMetrics.failedSpawn(name);
|
|
646
664
|
});
|
|
665
|
+
// A child can exit between a write to it and the bridge handling that exit, for example when
|
|
666
|
+
// a server restarts mid-request. The write then fails with EPIPE, and a pipe 'error' with no
|
|
667
|
+
// listener crashes the whole bridge. The exit handler still fails the request that was waiting.
|
|
668
|
+
child.stdin.on('error', (error) => log(`[${name}] stdin write failed: ${error.message}`));
|
|
647
669
|
child.__spawnedAt = Date.now(); // start of the cold-start clock; stopped when initialize lands
|
|
648
670
|
return child;
|
|
649
671
|
}
|
|
@@ -2085,7 +2107,69 @@ function proxyHttp(name, def, req, res) {
|
|
|
2085
2107
|
}
|
|
2086
2108
|
|
|
2087
2109
|
/* --------------------------------- server ---------------------------------- */
|
|
2110
|
+
// Browsers can reach a loopback server too. Without these checks any web page could use the
|
|
2111
|
+
// bridge, and the credentials it adds, through the user's browser: a cross-site POST runs a tool
|
|
2112
|
+
// without a CORS preflight, and DNS rebinding lets the page read the replies. The MCP spec
|
|
2113
|
+
// requires servers to check Origin for this reason.
|
|
2114
|
+
//
|
|
2115
|
+
// - Host: rebinding only works through a DNS name, so only `localhost` and IP addresses pass.
|
|
2116
|
+
// - Origin, when sent: the bridge itself (the dashboard) or another page on this machine.
|
|
2117
|
+
// - No Origin: clients that are not browsers send none. A browser also leaves it out of a
|
|
2118
|
+
// cross-site GET, such as an image, a frame or a link, which Sec-Fetch-Site still marks. Only
|
|
2119
|
+
// opening the dashboard itself from a link on another site is let through.
|
|
2120
|
+
function hostnameOf(urlText) {
|
|
2121
|
+
try {
|
|
2122
|
+
const { hostname } = new URL(urlText);
|
|
2123
|
+
return hostname.startsWith('[') ? hostname.slice(1, -1) : hostname;
|
|
2124
|
+
} catch { return ''; }
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
function isLoopback(hostname) {
|
|
2128
|
+
const loopbackV4 = isIP(hostname) === 4 &&
|
|
2129
|
+
hostname.startsWith('127.');
|
|
2130
|
+
return hostname === 'localhost' ||
|
|
2131
|
+
hostname === '::1' ||
|
|
2132
|
+
loopbackV4;
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
function isLocalRequest(req) {
|
|
2136
|
+
if (!req.headers.host) return false;
|
|
2137
|
+
const host = hostnameOf(`http://${req.headers.host}`);
|
|
2138
|
+
const hostIsSafe = host === 'localhost' ||
|
|
2139
|
+
isIP(host) !== 0;
|
|
2140
|
+
if (!hostIsSafe) return false;
|
|
2141
|
+
const origin = req.headers.origin;
|
|
2142
|
+
if (origin !== undefined) {
|
|
2143
|
+
const sameOrigin = origin === `http://${req.headers.host}`;
|
|
2144
|
+
const loopbackPage = isLoopback(hostnameOf(origin));
|
|
2145
|
+
return sameOrigin ||
|
|
2146
|
+
loopbackPage;
|
|
2147
|
+
}
|
|
2148
|
+
const path = req.url.split('?')[0];
|
|
2149
|
+
const dashboardPath = path === '/ui' ||
|
|
2150
|
+
path.startsWith('/ui/');
|
|
2151
|
+
const opensDashboard = dashboardPath &&
|
|
2152
|
+
req.headers['sec-fetch-mode'] === 'navigate' &&
|
|
2153
|
+
req.headers['sec-fetch-dest'] === 'document';
|
|
2154
|
+
const crossSiteFetch = req.headers['sec-fetch-site'] === 'cross-site' &&
|
|
2155
|
+
!opensDashboard;
|
|
2156
|
+
return !crossSiteFetch;
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
// One line a minute explains a refusal; a page retrying in a loop must not flood the log.
|
|
2160
|
+
let lastRefusalLog = 0;
|
|
2161
|
+
function noteRefusal(req) {
|
|
2162
|
+
if (Date.now() - lastRefusalLog < 60000) return;
|
|
2163
|
+
lastRefusalLog = Date.now();
|
|
2164
|
+
log(`refused ${req.method} ${req.url} from Host ${req.headers.host ?? 'none'}, Origin ${req.headers.origin ?? 'none'}: not a local request`);
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2088
2167
|
const server = http.createServer((req, res) => {
|
|
2168
|
+
if (!isLocalRequest(req)) {
|
|
2169
|
+
noteRefusal(req);
|
|
2170
|
+
res.writeHead(403).end('forbidden: not a local request');
|
|
2171
|
+
return;
|
|
2172
|
+
}
|
|
2089
2173
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
2090
2174
|
const [name, kind] = url.pathname.split('/').filter(Boolean);
|
|
2091
2175
|
const readOnlyStatus = (name === 'status' && !kind) ||
|
|
@@ -2096,13 +2180,14 @@ const server = http.createServer((req, res) => {
|
|
|
2096
2180
|
return;
|
|
2097
2181
|
}
|
|
2098
2182
|
if (!['api', 'ui', '.well-known'].includes(name)) {
|
|
2183
|
+
// A GET is a listening stream that lasts as long as its session; anything else is a call.
|
|
2184
|
+
const call = req.method !== 'GET';
|
|
2099
2185
|
upgradeRequests++;
|
|
2186
|
+
if (call) upgradeCalls++;
|
|
2100
2187
|
upgradeTraffic = true;
|
|
2101
|
-
let finished = false;
|
|
2102
|
-
res.once('finish', () => { finished = true; });
|
|
2103
2188
|
res.once('close', () => {
|
|
2104
2189
|
upgradeRequests--;
|
|
2105
|
-
if (
|
|
2190
|
+
if (call) upgradeCalls--;
|
|
2106
2191
|
});
|
|
2107
2192
|
}
|
|
2108
2193
|
}
|
|
@@ -2208,16 +2293,26 @@ server.listen(PORT, HOST, () => {
|
|
|
2208
2293
|
|
|
2209
2294
|
function upgradeState() {
|
|
2210
2295
|
const pooling = poolingConfig.upgradeState();
|
|
2211
|
-
const unsafe =
|
|
2212
|
-
pooling.unsafe ||
|
|
2296
|
+
const unsafe = pooling.unsafe ||
|
|
2213
2297
|
hasPoolingTransaction(CONFIG);
|
|
2298
|
+
const calls = upgradeCalls > 0 ||
|
|
2299
|
+
resuming.size > 0 ||
|
|
2300
|
+
tokenInFlight.size > 0;
|
|
2214
2301
|
const busy = pooling.busy ||
|
|
2302
|
+
calls ||
|
|
2215
2303
|
upgradeRequests > 0 ||
|
|
2216
2304
|
sessions.size > 0 ||
|
|
2217
|
-
httpSessions.size > 0
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2305
|
+
httpSessions.size > 0;
|
|
2306
|
+
return { unsafe, busy, calls, configBusy: pooling.busy, held: upgradeHeld, traffic: upgradeTraffic, port: PORT, version: VERSION, instanceId };
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
// Quiesce waits for calls and config work, never for open sessions. A staged pooling batch
|
|
2310
|
+
// still commits on its own timer while admission is held.
|
|
2311
|
+
function draining() {
|
|
2312
|
+
const state = upgradeState();
|
|
2313
|
+
return state.unsafe ||
|
|
2314
|
+
state.configBusy ||
|
|
2315
|
+
state.calls;
|
|
2221
2316
|
}
|
|
2222
2317
|
|
|
2223
2318
|
let upgradeControlBusy = false;
|
|
@@ -2231,26 +2326,26 @@ process.on('message', async (message) => {
|
|
|
2231
2326
|
try {
|
|
2232
2327
|
if (message.action === 'quiesce') {
|
|
2233
2328
|
upgradeHeld = true;
|
|
2329
|
+
// Admission is closed now. Calls in progress and config work get up to eight seconds;
|
|
2330
|
+
// calls still running at the restart end with their sessions. Config work that is still
|
|
2331
|
+
// unfinished blocks the upgrade, because an upgrade must never change servers.json.
|
|
2234
2332
|
const deadline = Date.now() + Math.min(8000, Math.max(0, message.timeoutMs ?? 8000));
|
|
2235
|
-
while (
|
|
2236
|
-
!upgradeState().unsafe &&
|
|
2333
|
+
while (draining() &&
|
|
2237
2334
|
Date.now() < deadline) {
|
|
2238
2335
|
await new Promise(resolveWait => setTimeout(resolveWait, 25));
|
|
2239
2336
|
}
|
|
2240
2337
|
const state = upgradeState();
|
|
2241
|
-
if (state.
|
|
2242
|
-
state.
|
|
2243
|
-
throw new Error('
|
|
2338
|
+
if (state.unsafe ||
|
|
2339
|
+
state.configBusy) {
|
|
2340
|
+
throw new Error('Unfinished config work blocks upgrade. Let it finish and retry; nothing was replayed.');
|
|
2244
2341
|
}
|
|
2245
2342
|
} else if (message.action === 'activate' ||
|
|
2246
2343
|
message.action === 'resume') {
|
|
2247
2344
|
if (upgradeState().unsafe) throw new Error('Unresolved config transaction prevents admission.');
|
|
2248
2345
|
if (message.action === 'activate') {
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
!isDeepStrictEqual(sessions, message.expectedSessionState)) {
|
|
2253
|
-
throw new Error('Configuration or persisted session state changed before admission; the bridge remains held.');
|
|
2346
|
+
// Session records may change while the old version runs; only the config must match.
|
|
2347
|
+
if (!isDeepStrictEqual(new PoolingFiles(CONFIG).inspect(CONFIG), message.expectedAuthority)) {
|
|
2348
|
+
throw new Error('Configuration changed before admission; the bridge remains held.');
|
|
2254
2349
|
}
|
|
2255
2350
|
} else {
|
|
2256
2351
|
await poolingConfig.reloadSaved('upgrade resumed');
|
package/bin/prewarm.mjs
CHANGED
|
@@ -1,23 +1,45 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { request } from 'node:http';
|
|
2
3
|
import { dirname, join } from 'node:path';
|
|
4
|
+
import { text as readText } from 'node:stream/consumers';
|
|
3
5
|
import { fileURLToPath } from 'node:url';
|
|
4
6
|
import { canonicalPort } from './cli-selection.mjs';
|
|
5
7
|
|
|
8
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
9
|
+
|
|
10
|
+
// node:http, not fetch: on Windows, Node 23, 24 before 24.20.0, 25, and 26 before 26.7.0 can abort
|
|
11
|
+
// with a libuv assertion when the CLI exits soon after fetch (nodejs/node#56645).
|
|
12
|
+
async function sendRequest(port, path, { method = 'GET', headers = {}, body } = {}) {
|
|
13
|
+
const signal = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
|
|
14
|
+
try {
|
|
15
|
+
const response = await new Promise((resolve, reject) => {
|
|
16
|
+
const outgoing = request({ hostname: '127.0.0.1', port, path, method, headers, signal }, resolve);
|
|
17
|
+
outgoing.on('error', reject);
|
|
18
|
+
outgoing.end(body);
|
|
19
|
+
});
|
|
20
|
+
return { status: response.statusCode, text: await readText(response) };
|
|
21
|
+
} catch (error) {
|
|
22
|
+
if (signal.aborted) {
|
|
23
|
+
const advice = method === 'GET' ? '' : ' The change may still apply; reread settings before retrying.';
|
|
24
|
+
throw new Error(`Bridge on :${port} did not answer within ${REQUEST_TIMEOUT_MS / 1000}s.${advice}`);
|
|
25
|
+
}
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
6
30
|
async function jsonRequest(port, path, options = {}) {
|
|
7
|
-
const response = await
|
|
8
|
-
...options,
|
|
9
|
-
signal: AbortSignal.timeout(10_000),
|
|
10
|
-
});
|
|
31
|
+
const response = await sendRequest(port, path, options);
|
|
11
32
|
if (response.status === 401) {
|
|
12
33
|
throw new Error('Admin nonce was rejected. Check --config points to the running bridge, then retry.');
|
|
13
34
|
}
|
|
14
35
|
let result;
|
|
15
36
|
try {
|
|
16
|
-
result =
|
|
37
|
+
result = JSON.parse(response.text);
|
|
17
38
|
} catch {
|
|
18
39
|
throw new Error(`Bridge on :${port} returned a non-JSON response (${response.status})`);
|
|
19
40
|
}
|
|
20
|
-
|
|
41
|
+
const succeeded = response.status >= 200 && response.status <= 299;
|
|
42
|
+
if (!succeeded) {
|
|
21
43
|
throw new Error(result.error || `Bridge on :${port} rejected the request (${response.status})`);
|
|
22
44
|
}
|
|
23
45
|
return { status: response.status, body: result };
|
package/bin/service-control.mjs
CHANGED
|
@@ -138,10 +138,15 @@ export async function controlManagedService({ root, config, port }, action, expe
|
|
|
138
138
|
id: record.id, token: record.token, action,
|
|
139
139
|
expectedAuthority: expected.authority, expectedSessionState: expected.sessionState,
|
|
140
140
|
});
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
141
|
+
const identified = response.id === record.id &&
|
|
142
|
+
response.instanceId === record.instanceId &&
|
|
143
|
+
response.version === record.version &&
|
|
144
|
+
response.port === port;
|
|
145
|
+
if (!identified ||
|
|
146
|
+
response.ok !== true) {
|
|
147
|
+
// Only a refusal from the identified bridge is definite; anything else leaves the outcome unknown.
|
|
148
|
+
throw Object.assign(new Error(response.error || 'Managed bridge identity/admission could not be verified.'),
|
|
149
|
+
{ refused: identified && response.ok === false });
|
|
150
|
+
}
|
|
146
151
|
return response;
|
|
147
152
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
|
-
import { existsSync, lstatSync, readFileSync, readdirSync, unlinkSync } from 'node:fs';
|
|
3
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { join, relative } from 'node:path';
|
|
5
5
|
import { isDeepStrictEqual, promisify } from 'node:util';
|
|
6
6
|
import semver from 'semver';
|
|
@@ -8,6 +8,7 @@ import { npmCliPath } from './update-check.mjs';
|
|
|
8
8
|
import { PoolingFiles } from './pooling-files.mjs';
|
|
9
9
|
|
|
10
10
|
const execute = promisify(execFile);
|
|
11
|
+
const PUBLIC_REGISTRY = 'https://registry.npmjs.org/';
|
|
11
12
|
const policies = [
|
|
12
13
|
'registry', 'replace-registry-host', 'offline', 'prefer-offline', 'strict-ssl', 'ca', 'cafile',
|
|
13
14
|
'proxy', 'https-proxy', 'noproxy', 'local-address', 'min-release-age', 'min-release-age-exclude',
|
|
@@ -47,9 +48,12 @@ export function validateDependencyLock(root) {
|
|
|
47
48
|
if (!path.startsWith('node_modules/') ||
|
|
48
49
|
path.split('/').some(part => !part || part === '.' || part === '..') ||
|
|
49
50
|
entry.link ||
|
|
50
|
-
semver.valid(entry.version) !== entry.version
|
|
51
|
-
|
|
52
|
-
!entry.integrity.startsWith('sha512-'))
|
|
51
|
+
semver.valid(entry.version) !== entry.version) throw new Error('Unverifiable target dependency lock entry.');
|
|
52
|
+
if (typeof entry.integrity !== 'string' ||
|
|
53
|
+
!entry.integrity.startsWith('sha512-')) {
|
|
54
|
+
const found = typeof entry.integrity === 'string' ? entry.integrity.split('-')[0] : 'no';
|
|
55
|
+
throw new Error(`Unverifiable target dependency lock entry: ${path}@${entry.version} has ${found} integrity from the registry; SHA-512 is required.`);
|
|
56
|
+
}
|
|
53
57
|
const integrity = Buffer.from(entry.integrity.slice(7), 'base64');
|
|
54
58
|
if (integrity.length !== 64 ||
|
|
55
59
|
`sha512-${integrity.toString('base64')}` !== entry.integrity) throw new Error('Invalid dependency archive integrity.');
|
|
@@ -62,6 +66,51 @@ export function validateDependencyLock(root) {
|
|
|
62
66
|
return lock;
|
|
63
67
|
}
|
|
64
68
|
|
|
69
|
+
// From 2.1.1 a target ships the SHA-512 lock it was released and tested with. npm ci then
|
|
70
|
+
// verifies every archive against it, so registry metadata (SHA-1 on some mirrors) and newer
|
|
71
|
+
// in-range releases cannot change the staged dependency set.
|
|
72
|
+
export function bundledDependencyLock(root) {
|
|
73
|
+
const capabilityPath = join(root, 'bin', 'upgrade-capability.json');
|
|
74
|
+
if (!existsSync(capabilityPath)) return null;
|
|
75
|
+
const capability = JSON.parse(readFileSync(capabilityPath, 'utf8'));
|
|
76
|
+
if (capability.dependencyLock !== 1) return null;
|
|
77
|
+
const path = join(root, 'bin', 'dependency-lock.json');
|
|
78
|
+
const stat = existsSync(path) ? lstatSync(path) : null;
|
|
79
|
+
if (!stat?.isFile() ||
|
|
80
|
+
stat.size === 0) throw new Error('Target declares a bundled dependency lock but does not contain it.');
|
|
81
|
+
return readFileSync(path);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// npm points public-registry lock URLs at the configured registry itself, unless
|
|
85
|
+
// replace-registry-host is "never" or names only another host or URL prefix.
|
|
86
|
+
export function npmMapsPublicRegistry(setting) {
|
|
87
|
+
const value = String(setting ?? 'npmjs');
|
|
88
|
+
return ['npmjs', 'always'].includes(value) ||
|
|
89
|
+
value.includes('registry.npmjs.org');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The bundled lock names the public registry. When npm will not map those URLs, point each
|
|
93
|
+
// entry at the registry that resolving it would have used: its scope's registry, else the
|
|
94
|
+
// configured one. Integrity covers the archive bytes, not the URL.
|
|
95
|
+
export function registryLock(bytes, registry, scopes = {}) {
|
|
96
|
+
const lock = JSON.parse(bytes.toString('utf8'));
|
|
97
|
+
let changed = false;
|
|
98
|
+
for (const [path, entry] of Object.entries(lock.packages ?? {})) {
|
|
99
|
+
const publicEntry = path &&
|
|
100
|
+
typeof entry.resolved === 'string' &&
|
|
101
|
+
entry.resolved.startsWith(PUBLIC_REGISTRY);
|
|
102
|
+
if (!publicEntry) continue;
|
|
103
|
+
const name = path.slice(path.lastIndexOf('node_modules/') + 'node_modules/'.length);
|
|
104
|
+
const target = (name.startsWith('@') ? scopes[name.split('/')[0]] : undefined) ?? registry;
|
|
105
|
+
if (!target) continue;
|
|
106
|
+
const base = target.endsWith('/') ? target : `${target}/`;
|
|
107
|
+
if (base === PUBLIC_REGISTRY) continue;
|
|
108
|
+
entry.resolved = base + entry.resolved.slice(PUBLIC_REGISTRY.length);
|
|
109
|
+
changed = true;
|
|
110
|
+
}
|
|
111
|
+
return changed ? Buffer.from(`${JSON.stringify(lock, null, 2)}\n`) : bytes;
|
|
112
|
+
}
|
|
113
|
+
|
|
65
114
|
export async function stageCliDependencies(root, { registry, cache, run = execute } = {}) {
|
|
66
115
|
const cli = npmCliPath();
|
|
67
116
|
const call = async (args, options = {}) => {
|
|
@@ -91,12 +140,19 @@ export async function stageCliDependencies(root, { registry, cache, run = execut
|
|
|
91
140
|
const projectConfig = join(prefix, '.npmrc');
|
|
92
141
|
const stagedConfig = join(root, '.npmrc');
|
|
93
142
|
if (existsSync(stagedConfig)) throw new Error('Unexpected target npm configuration.');
|
|
143
|
+
const bundled = bundledDependencyLock(root);
|
|
144
|
+
if (bundled &&
|
|
145
|
+
existsSync(join(root, 'package-lock.json'))) throw new Error('Unexpected target dependency lock.');
|
|
146
|
+
const scopes = Object.fromEntries(Object.entries(configuration)
|
|
147
|
+
.filter(([key, value]) => typeof value === 'string' &&
|
|
148
|
+
/^@[^:/]+:registry$/.test(key))
|
|
149
|
+
.map(([key, value]) => [key.slice(0, -':registry'.length), value]));
|
|
150
|
+
const manifest = readFileSync(join(root, 'package.json'));
|
|
94
151
|
if (existsSync(projectConfig)) {
|
|
95
152
|
const files = new PoolingFiles(projectConfig);
|
|
96
153
|
const identity = files.inspect(projectConfig);
|
|
97
154
|
files.stage(stagedConfig, projectConfig, identity, readFileSync(projectConfig));
|
|
98
155
|
}
|
|
99
|
-
const manifest = readFileSync(join(root, 'package.json'));
|
|
100
156
|
const args = ['--ignore-scripts', '--omit=dev', '--no-audit', '--no-fund', '--bin-links=false',
|
|
101
157
|
'--cache', cache, '--registry', registry];
|
|
102
158
|
try {
|
|
@@ -108,7 +164,12 @@ export async function stageCliDependencies(root, { registry, cache, run = execut
|
|
|
108
164
|
throw new Error('Effective npm policy changed in dependency staging; no dependency resolution was attempted.');
|
|
109
165
|
}
|
|
110
166
|
}
|
|
111
|
-
|
|
167
|
+
if (bundled) {
|
|
168
|
+
const staged = npmMapsPublicRegistry(configuration['replace-registry-host'])
|
|
169
|
+
? bundled
|
|
170
|
+
: registryLock(bundled, registry, scopes);
|
|
171
|
+
writeFileSync(join(root, 'package-lock.json'), staged, { flag: 'wx' });
|
|
172
|
+
} else await call(['install', '--package-lock-only', ...args], { cwd: root, env });
|
|
112
173
|
validateDependencyLock(root);
|
|
113
174
|
const lock = readFileSync(join(root, 'package-lock.json'));
|
|
114
175
|
await call(['ci', ...args], { cwd: root, env });
|
package/bin/upgrade-package.mjs
CHANGED
|
@@ -97,6 +97,8 @@ export function unpackUpgrade(bytes, version, destination, nodeVersion = process
|
|
|
97
97
|
capability.portZero !== true ||
|
|
98
98
|
capability.admission !== true ||
|
|
99
99
|
capability.cli !== true) throw new Error('Target does not support managed readiness/admission/port allocation/CLI dispatch.');
|
|
100
|
+
if (capability.dependencyLock === 1 &&
|
|
101
|
+
!files.get('bin/dependency-lock.json')?.bytes.length) throw new Error('Target declares a bundled dependency lock but does not contain it.');
|
|
100
102
|
for (const file of files.values()) {
|
|
101
103
|
if (![0o644, 0o755].includes(file.mode)) throw new Error('Unexpected package file mode.');
|
|
102
104
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"binary": "LegacyProcessBroker.exe",
|
|
4
|
-
"binarySha256": "
|
|
4
|
+
"binarySha256": "64323058e9c0437409522b6cca6d1a47c32e125980b83a07021932d1d5a317ef",
|
|
5
5
|
"inputs": {
|
|
6
6
|
"targetFramework": ".NETFramework,Version=v4.6.2",
|
|
7
7
|
"platform": "AnyCPU",
|
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
"sourceEncoding": "UTF-8 without BOM, LF line endings",
|
|
28
28
|
"sourceSha256": {
|
|
29
29
|
"AssemblyInfo.cs": "09f672607b66aff39c457899d270688972e440aafaa43b2cb56c1a2367576356",
|
|
30
|
-
"LegacyNative.cs": "
|
|
31
|
-
"LegacyProcessBroker.cs": "
|
|
30
|
+
"LegacyNative.cs": "28f496cba02ebfbbe07102bc70133198b40846838c0189e0bfcdb91d2e0a95c6",
|
|
31
|
+
"LegacyProcessBroker.cs": "2edd5f77787498d5323185e00b663084e140c9aec6ebca3921d66078bbc66c2a"
|
|
32
32
|
},
|
|
33
33
|
"buildScriptSha256": "2ffa31155f6aa8f6b4239facb0f062df167a01ac7a7ce6d9ae0be60e60fd0201",
|
|
34
34
|
"referenceSha256": {
|
|
Binary file
|
|
@@ -202,6 +202,14 @@ internal sealed class LegacyNative
|
|
|
202
202
|
catch { /* Diagnostic metadata must never replace the original failure. */ }
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
+
// A fixed reason the broker can report; the WMI exception text itself is never relayed.
|
|
206
|
+
internal Exception QueryTimeout(Exception error)
|
|
207
|
+
{
|
|
208
|
+
var timeout = new InvalidOperationException("Argument query timed out", error);
|
|
209
|
+
TagApi(timeout, LegacyFailureApi.WmiQuery);
|
|
210
|
+
return timeout;
|
|
211
|
+
}
|
|
212
|
+
|
|
205
213
|
internal void TagFailure(Exception error, LegacyFailureStage substage, int? pid = null,
|
|
206
214
|
long? creationTime = null, int? actualParentPid = null, int? expectedParentPid = null,
|
|
207
215
|
string expectedParentCreationTime = null, bool? alive = null, bool? parentAlive = null,
|
|
@@ -376,7 +384,7 @@ internal sealed class LegacySnapshot
|
|
|
376
384
|
if (pid == 0 ||
|
|
377
385
|
pid > int.MaxValue) throw new InvalidOperationException("Snapshot identity unavailable");
|
|
378
386
|
result.Add((int)pid);
|
|
379
|
-
if (result.Count >
|
|
387
|
+
if (result.Count > 2048) throw new InvalidOperationException("Child bound");
|
|
380
388
|
}
|
|
381
389
|
}
|
|
382
390
|
return result;
|
|
@@ -472,6 +480,8 @@ internal sealed class LegacyArgumentQueries : IDisposable
|
|
|
472
480
|
private readonly Action check;
|
|
473
481
|
private readonly List<LegacyQueryWorker> workers = new List<LegacyQueryWorker>();
|
|
474
482
|
private volatile bool cancelled;
|
|
483
|
+
// The first real worker failure. Later workers only observe cancellation, which must not hide it.
|
|
484
|
+
private Exception primary;
|
|
475
485
|
private int count;
|
|
476
486
|
private int maximum;
|
|
477
487
|
private bool disposed;
|
|
@@ -505,10 +515,13 @@ internal sealed class LegacyArgumentQueries : IDisposable
|
|
|
505
515
|
workers[index].Queue.Add(batch);
|
|
506
516
|
}
|
|
507
517
|
foreach (var batch in wave)
|
|
508
|
-
while (!batch.Done.Wait(25))
|
|
518
|
+
while (!batch.Done.Wait(25)) CheckPrimary();
|
|
519
|
+
var failure = Volatile.Read(ref primary);
|
|
509
520
|
foreach (var batch in wave)
|
|
510
|
-
if (
|
|
511
|
-
|
|
521
|
+
if (failure == null &&
|
|
522
|
+
batch.Error != null) failure = batch.Error;
|
|
523
|
+
if (failure != null) throw failure;
|
|
524
|
+
CheckPrimary();
|
|
512
525
|
if (capture)
|
|
513
526
|
{
|
|
514
527
|
foreach (var batch in wave)
|
|
@@ -548,7 +561,7 @@ internal sealed class LegacyArgumentQueries : IDisposable
|
|
|
548
561
|
native.TagApi(error, LegacyFailureApi.WmiConnect);
|
|
549
562
|
native.TagFailure(error, LegacyFailureStage.ArgumentConnect);
|
|
550
563
|
worker.Error = error;
|
|
551
|
-
|
|
564
|
+
Fail(error);
|
|
552
565
|
}
|
|
553
566
|
finally { worker.Ready.Set(); }
|
|
554
567
|
foreach (var batch in worker.Queue.GetConsumingEnumerable())
|
|
@@ -561,9 +574,11 @@ internal sealed class LegacyArgumentQueries : IDisposable
|
|
|
561
574
|
}
|
|
562
575
|
catch (Exception error)
|
|
563
576
|
{
|
|
564
|
-
native.
|
|
565
|
-
|
|
566
|
-
|
|
577
|
+
var failure = TimedOut(error) ? native.QueryTimeout(error) : error;
|
|
578
|
+
native.TagFailure(failure, LegacyFailureStage.ArgumentQuery);
|
|
579
|
+
batch.Error = failure;
|
|
580
|
+
if (IsCancellation(failure)) cancelled = true;
|
|
581
|
+
else Fail(failure);
|
|
567
582
|
}
|
|
568
583
|
finally { batch.Done.Set(); }
|
|
569
584
|
}
|
|
@@ -574,7 +589,7 @@ internal sealed class LegacyArgumentQueries : IDisposable
|
|
|
574
589
|
}
|
|
575
590
|
foreach (var worker in workers)
|
|
576
591
|
{
|
|
577
|
-
while (!worker.Ready.Wait(25))
|
|
592
|
+
while (!worker.Ready.Wait(25)) CheckPrimary();
|
|
578
593
|
if (worker.Error != null) throw worker.Error;
|
|
579
594
|
}
|
|
580
595
|
}
|
|
@@ -592,7 +607,10 @@ internal sealed class LegacyArgumentQueries : IDisposable
|
|
|
592
607
|
clauses.Add("ProcessId=" + process.Info.pid.ToString(CultureInfo.InvariantCulture));
|
|
593
608
|
}
|
|
594
609
|
var seen = new HashSet<int>();
|
|
595
|
-
|
|
610
|
+
// Loaded hosts with ~1000 processes measured close to 1 s per query, so 2 s failed under contention.
|
|
611
|
+
// The caller's phase watchdog still bounds the whole capture, so a timeout late in a long
|
|
612
|
+
// capture can surface as that bound rather than as "Argument query timed out".
|
|
613
|
+
var options = new EnumerationOptions { Timeout = TimeSpan.FromSeconds(8), ReturnImmediately = false };
|
|
596
614
|
using (var search = new ManagementObjectSearcher(scope, new ObjectQuery(
|
|
597
615
|
"SELECT ProcessId,CreationDate,CommandLine FROM Win32_Process WHERE " + string.Join(" OR ", clauses)), options))
|
|
598
616
|
using (var rows = QueryRows(search, native))
|
|
@@ -663,10 +681,41 @@ internal sealed class LegacyArgumentQueries : IDisposable
|
|
|
663
681
|
|
|
664
682
|
private void Check()
|
|
665
683
|
{
|
|
666
|
-
if (cancelled)
|
|
684
|
+
if (cancelled)
|
|
685
|
+
{
|
|
686
|
+
var cancellation = new InvalidOperationException("Argument query cancelled");
|
|
687
|
+
cancellation.Data["LegacyQueryCancelled"] = true;
|
|
688
|
+
throw cancellation;
|
|
689
|
+
}
|
|
667
690
|
check();
|
|
668
691
|
}
|
|
669
692
|
|
|
693
|
+
// Main thread only: report the first real worker failure rather than the cancellation it caused.
|
|
694
|
+
private void CheckPrimary()
|
|
695
|
+
{
|
|
696
|
+
var failure = Volatile.Read(ref primary);
|
|
697
|
+
if (failure != null) throw failure;
|
|
698
|
+
Check();
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
private void Fail(Exception error)
|
|
702
|
+
{
|
|
703
|
+
Interlocked.CompareExchange(ref primary, error, null);
|
|
704
|
+
cancelled = true;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
private static bool IsCancellation(Exception error)
|
|
708
|
+
{
|
|
709
|
+
return error.Data.Contains("LegacyQueryCancelled");
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
private static bool TimedOut(Exception error)
|
|
713
|
+
{
|
|
714
|
+
var management = error as ManagementException;
|
|
715
|
+
return management != null &&
|
|
716
|
+
management.ErrorCode == ManagementStatus.Timedout;
|
|
717
|
+
}
|
|
718
|
+
|
|
670
719
|
public void Dispose()
|
|
671
720
|
{
|
|
672
721
|
if (disposed) return;
|