machine-bridge-mcp 1.2.6 → 1.2.8
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 +21 -0
- package/CONTRIBUTING.md +26 -19
- package/README.md +15 -4
- package/SECURITY.md +3 -3
- package/browser-extension/manifest.json +2 -2
- package/docs/ARCHITECTURE.md +10 -8
- package/docs/AUDIT.md +26 -0
- package/docs/ENGINEERING.md +2 -2
- package/docs/OPERATIONS.md +5 -3
- package/docs/PROJECT_STANDARDS.md +9 -5
- package/docs/RELEASING.md +64 -32
- package/docs/TESTING.md +7 -4
- package/package.json +11 -6
- package/scripts/github-push.mjs +49 -0
- package/scripts/github-release.mjs +15 -8
- package/scripts/local-release-acceptance.mjs +136 -0
- package/scripts/release-acceptance.mjs +169 -0
- package/scripts/release-impact-check.mjs +19 -3
- package/src/local/browser-bridge.mjs +67 -22
- package/src/local/cli-local-admin.mjs +2 -3
- package/src/local/daemon-process.mjs +39 -4
- package/src/local/execution-limits.mjs +36 -0
- package/src/local/job-runner.mjs +5 -8
- package/src/local/loopback-health.mjs +67 -0
- package/src/local/managed-jobs.mjs +22 -9
- package/src/local/process-contract.mjs +19 -0
- package/src/local/process-execution.mjs +11 -10
- package/src/local/process-sessions.mjs +25 -50
- package/src/local/process-tracker.mjs +1 -1
- package/src/local/process-tree.mjs +53 -0
- package/src/local/runtime-reporting.mjs +3 -1
- package/src/local/runtime.mjs +4 -5
- package/src/local/shell.mjs +2 -19
- package/src/worker/index.ts +1 -1
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
lstatSync,
|
|
4
|
+
mkdtempSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
rmSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { spawnSync } from "node:child_process";
|
|
11
|
+
|
|
12
|
+
export const ACCEPTANCE_SCHEMA_VERSION = 1;
|
|
13
|
+
export const ACCEPTANCE_POLICY_VERSION = "1.2.8";
|
|
14
|
+
export const ACCEPTANCE_CONFIRMATION = "repository-owner-local-test";
|
|
15
|
+
const MAX_ACCEPTANCE_BYTES = 64 * 1024;
|
|
16
|
+
|
|
17
|
+
export function requiresLocalAcceptance(version) {
|
|
18
|
+
return compareVersions(parseVersion(version), parseVersion(ACCEPTANCE_POLICY_VERSION)) >= 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function acceptancePath(root, version) {
|
|
22
|
+
return join(root, "release-acceptance", `v${version}.json`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function packProject(root, destination) {
|
|
26
|
+
const npmCli = process.env.npm_execpath;
|
|
27
|
+
if (!npmCli) {
|
|
28
|
+
throw new Error("release acceptance commands must run through npm so npm_execpath is available");
|
|
29
|
+
}
|
|
30
|
+
const result = spawnSync(process.execPath, [
|
|
31
|
+
npmCli,
|
|
32
|
+
"pack",
|
|
33
|
+
"--ignore-scripts",
|
|
34
|
+
"--silent",
|
|
35
|
+
"--json",
|
|
36
|
+
"--pack-destination",
|
|
37
|
+
destination,
|
|
38
|
+
], {
|
|
39
|
+
cwd: root,
|
|
40
|
+
encoding: "utf8",
|
|
41
|
+
env: process.env,
|
|
42
|
+
windowsHide: true,
|
|
43
|
+
});
|
|
44
|
+
if (result.error) throw result.error;
|
|
45
|
+
if (result.status !== 0) {
|
|
46
|
+
throw new Error(`npm pack failed: ${String(result.stderr || result.stdout).trim()}`);
|
|
47
|
+
}
|
|
48
|
+
let value;
|
|
49
|
+
try {
|
|
50
|
+
value = JSON.parse(result.stdout);
|
|
51
|
+
} catch {
|
|
52
|
+
throw new Error("npm pack did not return valid JSON");
|
|
53
|
+
}
|
|
54
|
+
const pkg = readPackage(root);
|
|
55
|
+
const record = normalizePackRecord(value, pkg.name);
|
|
56
|
+
if (!record) throw new Error("npm pack did not return package metadata");
|
|
57
|
+
const metadata = {
|
|
58
|
+
package_name: pkg.name,
|
|
59
|
+
package_version: pkg.version,
|
|
60
|
+
filename: String(record.filename || ""),
|
|
61
|
+
shasum: String(record.shasum || ""),
|
|
62
|
+
integrity: String(record.integrity || ""),
|
|
63
|
+
};
|
|
64
|
+
validatePackMetadata(metadata);
|
|
65
|
+
verifyTarball(join(destination, metadata.filename), metadata);
|
|
66
|
+
return metadata;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function readAcceptance(root, version) {
|
|
70
|
+
const path = acceptancePath(root, version);
|
|
71
|
+
const stat = lstatSync(path);
|
|
72
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
73
|
+
throw new Error(`release acceptance record must be a regular file: ${path}`);
|
|
74
|
+
}
|
|
75
|
+
if (stat.size > MAX_ACCEPTANCE_BYTES) {
|
|
76
|
+
throw new Error(`release acceptance record exceeds ${MAX_ACCEPTANCE_BYTES} bytes`);
|
|
77
|
+
}
|
|
78
|
+
let value;
|
|
79
|
+
try {
|
|
80
|
+
value = JSON.parse(readFileSync(path, "utf8"));
|
|
81
|
+
} catch (error) {
|
|
82
|
+
throw new Error(`release acceptance record is not valid JSON: ${error.message}`);
|
|
83
|
+
}
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function verifyAcceptanceRecord(record, metadata) {
|
|
88
|
+
if (!record || typeof record !== "object" || Array.isArray(record)) {
|
|
89
|
+
throw new Error("release acceptance record must be an object");
|
|
90
|
+
}
|
|
91
|
+
if (record.schema_version !== ACCEPTANCE_SCHEMA_VERSION) {
|
|
92
|
+
throw new Error(`unsupported release acceptance schema: ${record.schema_version}`);
|
|
93
|
+
}
|
|
94
|
+
if (record.result !== "passed") throw new Error("local release acceptance result is not passed");
|
|
95
|
+
if (record.confirmation !== ACCEPTANCE_CONFIRMATION) {
|
|
96
|
+
throw new Error("local release acceptance confirmation is missing");
|
|
97
|
+
}
|
|
98
|
+
const acceptedAt = Date.parse(String(record.accepted_at || ""));
|
|
99
|
+
if (!Number.isFinite(acceptedAt)) throw new Error("local release acceptance timestamp is invalid");
|
|
100
|
+
for (const key of ["package_name", "package_version", "filename", "shasum", "integrity"]) {
|
|
101
|
+
if (record[key] !== metadata[key]) {
|
|
102
|
+
throw new Error(`local release acceptance ${key} does not match the current npm package`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return record;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function verifyCurrentReleaseAcceptance(root) {
|
|
109
|
+
const pkg = readPackage(root);
|
|
110
|
+
if (!requiresLocalAcceptance(pkg.version)) {
|
|
111
|
+
return { required: false, version: pkg.version };
|
|
112
|
+
}
|
|
113
|
+
const temp = mkdtempSync(join(tmpdir(), "mbm-release-acceptance-"));
|
|
114
|
+
try {
|
|
115
|
+
const metadata = packProject(root, temp);
|
|
116
|
+
const record = readAcceptance(root, pkg.version);
|
|
117
|
+
verifyAcceptanceRecord(record, metadata);
|
|
118
|
+
return { required: true, metadata, record };
|
|
119
|
+
} finally {
|
|
120
|
+
rmSync(temp, { recursive: true, force: true });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function verifyTarball(path, metadata) {
|
|
125
|
+
const stat = lstatSync(path);
|
|
126
|
+
if (stat.isSymbolicLink() || !stat.isFile()) throw new Error("release candidate tarball is not a regular file");
|
|
127
|
+
const bytes = readFileSync(path);
|
|
128
|
+
const shasum = createHash("sha1").update(bytes).digest("hex");
|
|
129
|
+
const integrity = `sha512-${createHash("sha512").update(bytes).digest("base64")}`;
|
|
130
|
+
if (shasum !== metadata.shasum || integrity !== metadata.integrity) {
|
|
131
|
+
throw new Error("release candidate tarball hash does not match npm pack metadata");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function normalizePackRecord(value, packageName) {
|
|
136
|
+
if (Array.isArray(value)) return value[0] ?? null;
|
|
137
|
+
if (!value || typeof value !== "object") return null;
|
|
138
|
+
if (value[packageName] && typeof value[packageName] === "object") return value[packageName];
|
|
139
|
+
return Object.values(value).find((item) => item && typeof item === "object") ?? null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function readPackage(root) {
|
|
143
|
+
const value = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
144
|
+
if (typeof value.name !== "string" || !value.name) throw new Error("package.json name is invalid");
|
|
145
|
+
if (!parseVersion(value.version)) throw new Error("package.json version is invalid");
|
|
146
|
+
return value;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function validatePackMetadata(metadata) {
|
|
150
|
+
if (!metadata.filename.endsWith(".tgz") || metadata.filename.includes("/") || metadata.filename.includes("\\")) {
|
|
151
|
+
throw new Error("npm pack returned an invalid filename");
|
|
152
|
+
}
|
|
153
|
+
if (!/^[0-9a-f]{40}$/.test(metadata.shasum)) throw new Error("npm pack returned an invalid SHA-1 shasum");
|
|
154
|
+
if (!/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(metadata.integrity)) throw new Error("npm pack returned an invalid SHA-512 integrity value");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function parseVersion(value) {
|
|
158
|
+
const match = /^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?$/.exec(String(value || ""));
|
|
159
|
+
if (!match) return null;
|
|
160
|
+
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function compareVersions(left, right) {
|
|
164
|
+
if (!left || !right) throw new Error("version comparison requires semantic versions");
|
|
165
|
+
for (let index = 0; index < 3; index += 1) {
|
|
166
|
+
if (left[index] !== right[index]) return left[index] - right[index];
|
|
167
|
+
}
|
|
168
|
+
return 0;
|
|
169
|
+
}
|
|
@@ -25,20 +25,36 @@ const changed = new Set([
|
|
|
25
25
|
...lines(git(["ls-files", "--others", "--exclude-standard"])),
|
|
26
26
|
]);
|
|
27
27
|
const relevant = [...changed].sort();
|
|
28
|
+
const packageRelevant = relevant.filter((path) => isPackageRelevant(path, pkg.files));
|
|
28
29
|
|
|
29
30
|
if (!relevant.length) {
|
|
30
|
-
process.stderr.write(`release impact check ok: no
|
|
31
|
+
process.stderr.write(`release impact check ok: no repository changes since ${latestTag}\n`);
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
34
|
+
if (!packageRelevant.length) {
|
|
35
|
+
process.stderr.write(`release impact check ok: ${relevant.length} repository-only file(s) changed since ${latestTag}; npm package content is unchanged\n`);
|
|
31
36
|
process.exit(0);
|
|
32
37
|
}
|
|
33
38
|
if (compareVersions(current, latest) <= 0) {
|
|
34
|
-
fail(`
|
|
39
|
+
fail(`npm-package changes exist since ${latestTag}, but package.json is still ${pkg.version}; bump the npm version and add a CHANGELOG section before merging`);
|
|
35
40
|
}
|
|
36
41
|
|
|
37
42
|
const changelog = readFileSync(new URL("../CHANGELOG.md", import.meta.url), "utf8");
|
|
38
43
|
const heading = new RegExp(`^## ${escapeRegExp(pkg.version)}(?:\\s+-[^\\n]*)?$`, "m");
|
|
39
44
|
if (!heading.test(changelog)) fail(`CHANGELOG.md has no section for ${pkg.version}`);
|
|
40
45
|
|
|
41
|
-
process.stderr.write(`release impact check ok: ${
|
|
46
|
+
process.stderr.write(`release impact check ok: ${packageRelevant.length} npm-package file(s) changed since ${latestTag}; next npm version ${pkg.version}\n`);
|
|
47
|
+
|
|
48
|
+
export function isPackageRelevant(path, packageFiles = []) {
|
|
49
|
+
const normalized = String(path || "").replaceAll("\\", "/").replace(/^\.\//, "");
|
|
50
|
+
if (!normalized) return false;
|
|
51
|
+
if (["package.json", "package-lock.json", "npm-shrinkwrap.json"].includes(normalized)) return true;
|
|
52
|
+
for (const entry of packageFiles || []) {
|
|
53
|
+
const candidate = String(entry || "").replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/$/, "");
|
|
54
|
+
if (candidate && (normalized === candidate || normalized.startsWith(`${candidate}/`))) return true;
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
42
58
|
|
|
43
59
|
function git(args) {
|
|
44
60
|
try {
|
|
@@ -14,13 +14,14 @@ import {
|
|
|
14
14
|
} from "./browser-pairing-store.mjs";
|
|
15
15
|
import { clampInt } from "./browser-command.mjs";
|
|
16
16
|
import { BrowserOperationService } from "./browser-operation-service.mjs";
|
|
17
|
+
import { classifyOperationalError } from "./log.mjs";
|
|
17
18
|
|
|
18
19
|
const MAX_PORT_ATTEMPTS = 10;
|
|
19
20
|
const MAX_PENDING = 32;
|
|
20
21
|
const EXTENSION_HANDSHAKE_MS = 3_000;
|
|
21
22
|
|
|
22
23
|
export class BrowserBridgeManager {
|
|
23
|
-
constructor({ policy, authorizeTool = null, stateRoot = "", runProcess, readResourceText, readResourceBinary, throwIfCancelled = () => {} }) {
|
|
24
|
+
constructor({ policy, authorizeTool = null, stateRoot = "", runProcess, readResourceText, readResourceBinary, throwIfCancelled = () => {}, logger = null }) {
|
|
24
25
|
this.policy = policy || {};
|
|
25
26
|
this.authorizeTool = createToolAuthorizer(this.policy, authorizeTool);
|
|
26
27
|
this.stateRoot = stateRoot ? resolve(stateRoot) : "";
|
|
@@ -28,6 +29,7 @@ export class BrowserBridgeManager {
|
|
|
28
29
|
this.readResourceText = readResourceText;
|
|
29
30
|
this.readResourceBinary = readResourceBinary;
|
|
30
31
|
this.throwIfCancelled = throwIfCancelled;
|
|
32
|
+
this.logger = logger || { event() {} };
|
|
31
33
|
this.server = null;
|
|
32
34
|
this.wss = null;
|
|
33
35
|
this.socket = null;
|
|
@@ -44,6 +46,7 @@ export class BrowserBridgeManager {
|
|
|
44
46
|
this.stopping = false;
|
|
45
47
|
this.pending = new Map();
|
|
46
48
|
this.startPromise = null;
|
|
49
|
+
this.startGeneration = 0;
|
|
47
50
|
this.port = 0;
|
|
48
51
|
this.token = "";
|
|
49
52
|
this.extensionPath = resolve(packageRoot, "browser-extension");
|
|
@@ -124,30 +127,59 @@ export class BrowserBridgeManager {
|
|
|
124
127
|
if (this.stateRoot) assertStateMaintenanceAvailable(this.stateRoot);
|
|
125
128
|
this.stopping = false;
|
|
126
129
|
if (this.server || this.upstream?.readyState === 1) return;
|
|
127
|
-
if (!this.startPromise)
|
|
128
|
-
|
|
130
|
+
if (!this.startPromise) {
|
|
131
|
+
const generation = ++this.startGeneration;
|
|
132
|
+
this.startPromise = this.start(generation);
|
|
133
|
+
}
|
|
134
|
+
const pending = this.startPromise;
|
|
135
|
+
try { await pending; } finally {
|
|
136
|
+
if (this.startPromise === pending) this.startPromise = null;
|
|
137
|
+
}
|
|
129
138
|
}
|
|
130
139
|
|
|
131
|
-
async start() {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
140
|
+
async start(generation = this.startGeneration) {
|
|
141
|
+
try {
|
|
142
|
+
const pairing = await loadOrCreatePairing(this.stateRoot);
|
|
143
|
+
this.assertStartCurrent(generation);
|
|
144
|
+
this.token = pairing.token;
|
|
145
|
+
for (let offset = 0; offset < MAX_PORT_ATTEMPTS; offset += 1) {
|
|
146
|
+
const port = pairing.port + offset;
|
|
147
|
+
try {
|
|
148
|
+
await this.listen(port);
|
|
149
|
+
this.assertStartCurrent(generation);
|
|
150
|
+
if (port !== pairing.port && this.stateRoot) {
|
|
151
|
+
await savePairing(this.stateRoot, { token: this.token, port });
|
|
152
|
+
this.assertStartCurrent(generation);
|
|
153
|
+
}
|
|
144
154
|
return;
|
|
155
|
+
} catch (error) {
|
|
156
|
+
this.assertStartCurrent(generation);
|
|
157
|
+
if (error?.code !== "EADDRINUSE") throw error;
|
|
158
|
+
if (await this.connectProxy(port, generation)) {
|
|
159
|
+
this.assertStartCurrent(generation);
|
|
160
|
+
this.port = port;
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
this.assertStartCurrent(generation);
|
|
164
|
+
if (offset === MAX_PORT_ATTEMPTS - 1) throw error;
|
|
145
165
|
}
|
|
146
|
-
if (offset === MAX_PORT_ATTEMPTS - 1) throw error;
|
|
147
166
|
}
|
|
167
|
+
} catch (error) {
|
|
168
|
+
const cancelled = !this.isStartCurrent(generation);
|
|
169
|
+
this.closeBrokerTransports(cancelled ? "browser bridge start cancelled" : "browser bridge start failed");
|
|
170
|
+
if (cancelled) throw new Error("browser bridge start cancelled");
|
|
171
|
+
throw error;
|
|
148
172
|
}
|
|
149
173
|
}
|
|
150
174
|
|
|
175
|
+
isStartCurrent(generation) {
|
|
176
|
+
return !this.stopping && generation === this.startGeneration;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
assertStartCurrent(generation) {
|
|
180
|
+
if (!this.isStartCurrent(generation)) throw new Error("browser bridge start cancelled");
|
|
181
|
+
}
|
|
182
|
+
|
|
151
183
|
async listen(port) {
|
|
152
184
|
this.port = port;
|
|
153
185
|
const server = createServer((request, response) => this.handleHttp(request, response));
|
|
@@ -192,7 +224,7 @@ export class BrowserBridgeManager {
|
|
|
192
224
|
this.wss = wss;
|
|
193
225
|
}
|
|
194
226
|
|
|
195
|
-
async connectProxy(port) {
|
|
227
|
+
async connectProxy(port, generation = this.startGeneration) {
|
|
196
228
|
const url = `ws://127.0.0.1:${port}/runtime`;
|
|
197
229
|
return new Promise((resolvePromise) => {
|
|
198
230
|
let settled = false;
|
|
@@ -220,6 +252,11 @@ export class BrowserBridgeManager {
|
|
|
220
252
|
closeProtocolSocket(ws, 1002, "runtime hello required");
|
|
221
253
|
return;
|
|
222
254
|
}
|
|
255
|
+
if (!this.isStartCurrent(generation)) {
|
|
256
|
+
try { ws.close(1001, "runtime stopped"); } catch {}
|
|
257
|
+
finish(false);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
223
260
|
this.upstream = ws;
|
|
224
261
|
const claimedExtension = message.extension_connected === true;
|
|
225
262
|
this.proxyExtensionInfo = claimedExtension ? normalizeCompatibleExtensionInfo(message.extension_info) : null;
|
|
@@ -450,7 +487,11 @@ export class BrowserBridgeManager {
|
|
|
450
487
|
if (this.stopping || this.recoveryTimer) return;
|
|
451
488
|
this.recoveryTimer = setTimeout(() => {
|
|
452
489
|
this.recoveryTimer = null;
|
|
453
|
-
void this.ensureStarted().catch(() =>
|
|
490
|
+
void this.ensureStarted().catch((error) => {
|
|
491
|
+
this.logger.event?.("debug", "browser.broker.recovery_failed", { error_class: classifyOperationalError(error) },
|
|
492
|
+
"browser broker recovery failed; retrying");
|
|
493
|
+
this.scheduleBrokerRecovery();
|
|
494
|
+
});
|
|
454
495
|
}, 250);
|
|
455
496
|
this.recoveryTimer.unref?.();
|
|
456
497
|
}
|
|
@@ -525,9 +566,15 @@ export class BrowserBridgeManager {
|
|
|
525
566
|
|
|
526
567
|
stop() {
|
|
527
568
|
this.stopping = true;
|
|
569
|
+
this.startGeneration += 1;
|
|
528
570
|
clearTimeout(this.recoveryTimer);
|
|
529
571
|
this.recoveryTimer = null;
|
|
530
|
-
this.
|
|
572
|
+
this.closeBrokerTransports("browser bridge stopped");
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
closeBrokerTransports(message) {
|
|
576
|
+
this.rejectPending(message);
|
|
577
|
+
this.rejectProxyRoutes(message);
|
|
531
578
|
try { this.upstream?.close(1001, "runtime stopped"); } catch {}
|
|
532
579
|
try { this.socket?.close(1001, "runtime stopped"); } catch {}
|
|
533
580
|
try { this.pendingExtensionSocket?.close(1001, "runtime stopped"); } catch {}
|
|
@@ -541,8 +588,6 @@ export class BrowserBridgeManager {
|
|
|
541
588
|
this.proxyExtensionReloadRequired = false;
|
|
542
589
|
this.extensionReloadRequiredFlag = false;
|
|
543
590
|
this.runtimeClients.clear();
|
|
544
|
-
for (const route of this.proxyRoutes.values()) clearTimeout(route.timeout);
|
|
545
|
-
this.proxyRoutes.clear();
|
|
546
591
|
try { this.wss?.close(); } catch {}
|
|
547
592
|
try { this.server?.close(); } catch {}
|
|
548
593
|
this.wss = null;
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
acquireStartupLockWithWait, expandHome, loadState, ownerOnlyFile, packageRoot, saveState,
|
|
11
11
|
} from "./state.mjs";
|
|
12
12
|
import { resolvePolicy } from "./cli-policy.mjs";
|
|
13
|
+
import { readLoopbackJson } from "./loopback-health.mjs";
|
|
13
14
|
|
|
14
15
|
export function createLocalAdminCommands(dependencies) {
|
|
15
16
|
const chooseWorkspace = dependencies.chooseWorkspace;
|
|
@@ -249,9 +250,7 @@ function readBrowserPairingState(pairingFile) {
|
|
|
249
250
|
}
|
|
250
251
|
|
|
251
252
|
function readBrowserHealth(healthUrl) {
|
|
252
|
-
return
|
|
253
|
-
.then(async (response) => response.ok ? await response.json() : null)
|
|
254
|
-
.catch(() => null);
|
|
253
|
+
return readLoopbackJson(healthUrl, { pathname: "/healthz" });
|
|
255
254
|
}
|
|
256
255
|
|
|
257
256
|
function browserStatusResult(health, extensionPath, pairingUrl) {
|
|
@@ -11,6 +11,7 @@ import { inspectProcessInstance, isPidAlive, processCommandLine, splitProcessCom
|
|
|
11
11
|
|
|
12
12
|
const DEFAULT_TAKEOVER_TIMEOUT_MS = 15_000;
|
|
13
13
|
const DEFAULT_TAKEOVER_POLL_MS = 100;
|
|
14
|
+
const DEFAULT_FORCE_AFTER_MS = 2_000;
|
|
14
15
|
|
|
15
16
|
export async function acquireDaemonLockWithTakeover(state, options = {}) {
|
|
16
17
|
const ownerMetadata = options.ownerMetadata || {};
|
|
@@ -50,9 +51,10 @@ export async function acquireDaemonLockWithTakeover(state, options = {}) {
|
|
|
50
51
|
export async function stopWorkspaceServiceDaemon(state, options = {}) {
|
|
51
52
|
const timeoutMs = boundedPositiveInt(options.timeoutMs, DEFAULT_TAKEOVER_TIMEOUT_MS);
|
|
52
53
|
const pollMs = boundedPositiveInt(options.pollMs, DEFAULT_TAKEOVER_POLL_MS);
|
|
54
|
+
const forceAfterMs = Math.min(timeoutMs, boundedPositiveInt(options.forceAfterMs, DEFAULT_FORCE_AFTER_MS));
|
|
53
55
|
const logger = options.logger || { info() {}, warn() {} };
|
|
54
56
|
const deadline = createMonotonicDeadline(timeoutMs);
|
|
55
|
-
const signalled = new
|
|
57
|
+
const signalled = new Map();
|
|
56
58
|
let owner = options.owner || readDaemonLockOwner(daemonLockPathForState(state));
|
|
57
59
|
let verified = false;
|
|
58
60
|
let lastOwner = owner;
|
|
@@ -89,12 +91,42 @@ export async function stopWorkspaceServiceDaemon(state, options = {}) {
|
|
|
89
91
|
};
|
|
90
92
|
}
|
|
91
93
|
}
|
|
92
|
-
signalled.
|
|
94
|
+
signalled.set(Number(owner.pid), {
|
|
95
|
+
owner: { ...owner },
|
|
96
|
+
forceDeadline: createMonotonicDeadline(forceAfterMs),
|
|
97
|
+
forced: false,
|
|
98
|
+
});
|
|
93
99
|
}
|
|
94
100
|
}
|
|
95
101
|
|
|
96
|
-
const
|
|
97
|
-
|
|
102
|
+
for (const [pid, signalState] of signalled) {
|
|
103
|
+
if (signalState.forced || !signalState.forceDeadline.expired()) continue;
|
|
104
|
+
const current = inspectProcessInstance(signalState.owner);
|
|
105
|
+
if (!current.current) continue;
|
|
106
|
+
const identity = inspectWorkspaceDaemonOwner(state, signalState.owner);
|
|
107
|
+
if (!identity.verified_service_daemon) continue;
|
|
108
|
+
logger.warn?.(`detached background daemon ignored graceful termination; forcing process ${pid} to stop`);
|
|
109
|
+
try {
|
|
110
|
+
process.kill(pid, "SIGKILL");
|
|
111
|
+
signalState.forced = true;
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (error?.code !== "ESRCH") {
|
|
114
|
+
return {
|
|
115
|
+
ok: false,
|
|
116
|
+
found: true,
|
|
117
|
+
verified_service_daemon: true,
|
|
118
|
+
reason: "force_signal_failed",
|
|
119
|
+
timeout_ms: timeoutMs,
|
|
120
|
+
...publicDaemonOwner(signalState.owner),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const liveSignalled = [...signalled.entries()]
|
|
127
|
+
.filter(([, signalState]) => inspectProcessInstance(signalState.owner).current)
|
|
128
|
+
.map(([pid]) => pid);
|
|
129
|
+
const currentOwnerAlive = Boolean(owner?.pid && inspectProcessInstance(owner).current);
|
|
98
130
|
if (!currentOwnerAlive && liveSignalled.length === 0) break;
|
|
99
131
|
|
|
100
132
|
if (deadline.expired()) {
|
|
@@ -107,6 +139,7 @@ export async function stopWorkspaceServiceDaemon(state, options = {}) {
|
|
|
107
139
|
verified_service_daemon: verified,
|
|
108
140
|
reason: "timeout",
|
|
109
141
|
timeout_ms: timeoutMs,
|
|
142
|
+
force_after_ms: forceAfterMs,
|
|
110
143
|
pid: remainingPid,
|
|
111
144
|
mode: publicDaemonMode(lastOwner),
|
|
112
145
|
version: typeof lastOwner?.version === "string" ? lastOwner.version : "unknown",
|
|
@@ -126,6 +159,8 @@ export async function stopWorkspaceServiceDaemon(state, options = {}) {
|
|
|
126
159
|
verified_service_daemon: verified,
|
|
127
160
|
reason: verified ? "stopped" : "not_running",
|
|
128
161
|
timeout_ms: timeoutMs,
|
|
162
|
+
force_after_ms: forceAfterMs,
|
|
163
|
+
forced: [...signalled.values()].some((item) => item.forced),
|
|
129
164
|
...(lastOwner ? publicDaemonOwner(lastOwner) : {}),
|
|
130
165
|
};
|
|
131
166
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export const MAX_CONCURRENT_TOOL_CALLS = 16;
|
|
2
|
+
export const MAX_PROCESS_SESSIONS = 8;
|
|
3
|
+
export const MIN_PROCESS_TIMEOUT_SECONDS = 1;
|
|
4
|
+
export const MAX_PROCESS_TIMEOUT_SECONDS = 600;
|
|
5
|
+
export const DEFAULT_PROCESS_OUTPUT_BYTES = 512 * 1024;
|
|
6
|
+
export const MAX_PROCESS_STDIN_BYTES = 1024 * 1024;
|
|
7
|
+
export const MAX_PROCESS_SESSION_OUTPUT_BYTES = 1024 * 1024;
|
|
8
|
+
export const MAX_PROCESS_SESSION_STDIN_BYTES = 64 * 1024;
|
|
9
|
+
export const PROCESS_SESSION_RETENTION_MS = 30 * 60 * 1000;
|
|
10
|
+
|
|
11
|
+
export function executionGuardrailsSnapshot() {
|
|
12
|
+
return {
|
|
13
|
+
tool_calls: {
|
|
14
|
+
maximum_concurrent: MAX_CONCURRENT_TOOL_CALLS,
|
|
15
|
+
},
|
|
16
|
+
one_shot_processes: {
|
|
17
|
+
timeout_seconds: { minimum: MIN_PROCESS_TIMEOUT_SECONDS, maximum: MAX_PROCESS_TIMEOUT_SECONDS },
|
|
18
|
+
stdin_max_bytes: MAX_PROCESS_STDIN_BYTES,
|
|
19
|
+
output_max_bytes_per_stream: DEFAULT_PROCESS_OUTPUT_BYTES,
|
|
20
|
+
process_tree_termination: "sigterm-then-sigkill",
|
|
21
|
+
},
|
|
22
|
+
process_sessions: {
|
|
23
|
+
maximum_concurrent: MAX_PROCESS_SESSIONS,
|
|
24
|
+
retained_output_max_bytes_per_stream: MAX_PROCESS_SESSION_OUTPUT_BYTES,
|
|
25
|
+
write_stdin_max_bytes: MAX_PROCESS_SESSION_STDIN_BYTES,
|
|
26
|
+
exited_retention_ms: PROCESS_SESSION_RETENTION_MS,
|
|
27
|
+
process_tree_termination: "sigterm-then-sigkill",
|
|
28
|
+
},
|
|
29
|
+
operating_system_enforcement: {
|
|
30
|
+
cpu_quota: "not-enforced",
|
|
31
|
+
memory_quota: "not-enforced",
|
|
32
|
+
network_isolation: "not-enforced",
|
|
33
|
+
required_boundary: "dedicated-low-privilege-account-or-vm-container",
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
package/src/local/job-runner.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { chmodSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs
|
|
|
4
4
|
import { basename, join, resolve } from "node:path";
|
|
5
5
|
import { performance } from "node:perf_hooks";
|
|
6
6
|
import { executionEnv } from "./shell.mjs";
|
|
7
|
-
import {
|
|
7
|
+
import { terminateProcessTreeWithEscalation } from "./process-tree.mjs";
|
|
8
8
|
import { createExclusiveFileSync, removeOwnedJsonFileSync, replaceFileAtomicallySync } from "./exclusive-file.mjs";
|
|
9
9
|
import { createMonotonicDeadline } from "./monotonic-deadline.mjs";
|
|
10
10
|
import { currentProcessStartTimeMs } from "./process-identity.mjs";
|
|
@@ -279,8 +279,7 @@ function spawnStep(argv, { cwd, env, input, timeoutMs, cancellationAware, captur
|
|
|
279
279
|
let killTimer = null;
|
|
280
280
|
const timer = setTimeout(() => {
|
|
281
281
|
timedOut = true;
|
|
282
|
-
|
|
283
|
-
killTimer = setTimeout(() => terminateProcessTree(child, "SIGKILL"), 2000);
|
|
282
|
+
killTimer = terminateProcessTreeWithEscalation(child);
|
|
284
283
|
}, timeoutMs);
|
|
285
284
|
timer.unref?.();
|
|
286
285
|
const cancellationPoll = setInterval(() => {
|
|
@@ -474,12 +473,10 @@ function requestCancellation() {
|
|
|
474
473
|
cancelRequested = true;
|
|
475
474
|
const child = activeChild;
|
|
476
475
|
if (!child || !activeChildCancellationAware) return;
|
|
477
|
-
terminateProcessTree(child, "SIGTERM");
|
|
478
476
|
if (cancellationEscalation) return;
|
|
479
|
-
cancellationEscalation =
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
}, 2000);
|
|
477
|
+
cancellationEscalation = terminateProcessTreeWithEscalation(child, {
|
|
478
|
+
onEscalated: () => { cancellationEscalation = null; },
|
|
479
|
+
});
|
|
483
480
|
}
|
|
484
481
|
|
|
485
482
|
function isCancellationRequested() {
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { request as requestHttp } from "node:http";
|
|
2
|
+
|
|
3
|
+
export function readLoopbackJson(input, options = {}) {
|
|
4
|
+
let target;
|
|
5
|
+
try { target = new URL(String(input)); } catch { return Promise.resolve(null); }
|
|
6
|
+
const pathname = typeof options.pathname === "string" && options.pathname.startsWith("/") ? options.pathname : "/healthz";
|
|
7
|
+
const port = Number(target.port);
|
|
8
|
+
if (target.protocol !== "http:"
|
|
9
|
+
|| target.hostname !== "127.0.0.1"
|
|
10
|
+
|| target.pathname !== pathname
|
|
11
|
+
|| target.username || target.password || target.search || target.hash
|
|
12
|
+
|| !Number.isInteger(port) || port < 1024 || port > 65535) {
|
|
13
|
+
return Promise.resolve(null);
|
|
14
|
+
}
|
|
15
|
+
const request = typeof options.request === "function" ? options.request : requestHttp;
|
|
16
|
+
const timeoutMs = Number.isFinite(Number(options.timeoutMs)) ? Math.max(1, Number(options.timeoutMs)) : 2000;
|
|
17
|
+
const maximumBytes = Number.isFinite(Number(options.maximumBytes)) ? Math.max(1, Number(options.maximumBytes)) : 64 * 1024;
|
|
18
|
+
return new Promise((resolvePromise) => {
|
|
19
|
+
let settled = false;
|
|
20
|
+
let client;
|
|
21
|
+
const finish = (value) => {
|
|
22
|
+
if (settled) return;
|
|
23
|
+
settled = true;
|
|
24
|
+
resolvePromise(value);
|
|
25
|
+
};
|
|
26
|
+
try {
|
|
27
|
+
client = request({
|
|
28
|
+
protocol: "http:", hostname: "127.0.0.1", port, path: pathname, method: "GET", agent: false,
|
|
29
|
+
headers: { accept: "application/json", connection: "close" },
|
|
30
|
+
}, (response) => {
|
|
31
|
+
if (response.statusCode !== 200) {
|
|
32
|
+
response.resume();
|
|
33
|
+
finish(null);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const chunks = [];
|
|
37
|
+
let total = 0;
|
|
38
|
+
response.on("data", (chunk) => {
|
|
39
|
+
if (settled) return;
|
|
40
|
+
total += chunk.length;
|
|
41
|
+
if (total > maximumBytes) {
|
|
42
|
+
client.destroy();
|
|
43
|
+
finish(null);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
chunks.push(chunk);
|
|
47
|
+
});
|
|
48
|
+
response.on("end", () => {
|
|
49
|
+
if (settled) return;
|
|
50
|
+
try {
|
|
51
|
+
const value = JSON.parse(Buffer.concat(chunks, total).toString("utf8"));
|
|
52
|
+
finish(value && typeof value === "object" && !Array.isArray(value) ? value : null);
|
|
53
|
+
} catch {
|
|
54
|
+
finish(null);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
response.on("error", () => finish(null));
|
|
58
|
+
});
|
|
59
|
+
} catch {
|
|
60
|
+
finish(null);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
client.setTimeout(timeoutMs, () => client.destroy(new Error("loopback health request timed out")));
|
|
64
|
+
client.on("error", () => finish(null));
|
|
65
|
+
client.end();
|
|
66
|
+
});
|
|
67
|
+
}
|