machine-bridge-mcp 2.0.0 → 3.0.0-beta.11
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 +120 -0
- package/CONTRIBUTING.md +20 -25
- package/README.md +21 -14
- package/SECURITY.md +147 -123
- package/browser-extension/manifest.json +3 -2
- package/browser-extension/service-worker.js +31 -12
- package/docs/ARCHITECTURE.md +28 -13
- package/docs/AUDIT.md +101 -2
- package/docs/CLIENTS.md +2 -2
- package/docs/ENGINEERING.md +9 -3
- package/docs/GETTING_STARTED.md +10 -9
- package/docs/LOCAL_AUTHORIZATION.md +115 -72
- package/docs/LOGGING.md +14 -3
- package/docs/MANAGED_JOBS.md +24 -29
- package/docs/MULTI_ACCOUNT.md +98 -37
- package/docs/OPERATIONS.md +34 -23
- package/docs/OVERVIEW.md +7 -7
- package/docs/PROJECT_STANDARDS.md +9 -5
- package/docs/RELEASING.md +171 -64
- package/docs/TESTING.md +21 -9
- package/docs/THREAT_MODEL.md +138 -67
- package/docs/TOOL_REFERENCE.md +2 -2
- package/docs/UPGRADING.md +102 -18
- package/native/macos/MachineBridgeTrustBroker.swift +233 -0
- package/package.json +37 -6
- package/scripts/candidate-runtime-store.mjs +38 -0
- package/scripts/check-plan.mjs +19 -0
- package/scripts/check-runner.mjs +4 -1
- package/scripts/commit-message-check.mjs +5 -2
- package/scripts/coverage-check.mjs +30 -2
- package/scripts/github-push.mjs +11 -0
- package/scripts/github-release.mjs +38 -7
- package/scripts/install-published-prerelease.mjs +167 -0
- package/scripts/local-release-acceptance.mjs +23 -12
- package/scripts/npm-publication-policy.mjs +16 -0
- package/scripts/prerelease-activation.mjs +79 -0
- package/scripts/privacy-check.mjs +7 -5
- package/scripts/promotion-digest.mjs +121 -0
- package/scripts/publish-npm.mjs +51 -0
- package/scripts/published-release.mjs +87 -0
- package/scripts/release-acceptance.mjs +30 -1
- package/scripts/release-candidate-manifest.mjs +40 -0
- package/scripts/release-channel.mjs +106 -0
- package/scripts/release-impact-check.mjs +12 -22
- package/scripts/release-soak.mjs +237 -0
- package/scripts/start-release-candidate.mjs +113 -7
- package/src/local/account-access.mjs +6 -0
- package/src/local/account-admin.mjs +34 -16
- package/src/local/agent-context.mjs +45 -42
- package/src/local/app-automation.mjs +9 -4
- package/src/local/authority-context.mjs +106 -0
- package/src/local/browser-bridge-http.mjs +4 -1
- package/src/local/browser-bridge.mjs +14 -9
- package/src/local/browser-broker-routes.mjs +4 -0
- package/src/local/browser-broker-server.mjs +5 -4
- package/src/local/browser-extension-identity.mjs +50 -0
- package/src/local/browser-extension-protocol.mjs +9 -4
- package/src/local/browser-operation-service.mjs +7 -0
- package/src/local/browser-pairing-http.mjs +36 -0
- package/src/local/browser-pairing-store.mjs +56 -47
- package/src/local/cli-account-admin.mjs +41 -7
- package/src/local/cli-activate.mjs +90 -0
- package/src/local/cli-approval.mjs +14 -58
- package/src/local/cli-local-admin.mjs +5 -15
- package/src/local/cli-options.mjs +11 -7
- package/src/local/cli-service.mjs +35 -5
- package/src/local/cli.mjs +119 -58
- package/src/local/daemon-process.mjs +6 -0
- package/src/local/delegated-process-sandbox.mjs +147 -0
- package/src/local/device-identity.mjs +157 -48
- package/src/local/device-root-provider.mjs +87 -0
- package/src/local/errors.mjs +25 -3
- package/src/local/git-service.mjs +16 -15
- package/src/local/job-runner.mjs +35 -27
- package/src/local/log.mjs +18 -44
- package/src/local/macos-trust-broker.mjs +320 -0
- package/src/local/managed-job-lock.mjs +18 -0
- package/src/local/managed-job-projection.mjs +9 -1
- package/src/local/managed-job-runner.mjs +13 -1
- package/src/local/managed-job-storage.mjs +2 -1
- package/src/local/managed-job-terminal.mjs +138 -0
- package/src/local/managed-jobs.mjs +88 -32
- package/src/local/operation-authorization.mjs +75 -239
- package/src/local/operation-risk.mjs +28 -0
- package/src/local/operation-state-lock.mjs +7 -86
- package/src/local/owner-state-lock.mjs +104 -0
- package/src/local/policy.mjs +19 -0
- package/src/local/process-execution.mjs +21 -9
- package/src/local/process-sessions.mjs +27 -17
- package/src/local/process-tracker.mjs +30 -6
- package/src/local/process-tree-ownership.mjs +59 -0
- package/src/local/process-tree.mjs +22 -18
- package/src/local/relay-connection.mjs +47 -35
- package/src/local/relay-diagnostics.mjs +65 -0
- package/src/local/remote-configuration.mjs +48 -0
- package/src/local/runtime-activation.mjs +141 -0
- package/src/local/runtime-diagnostics.mjs +21 -0
- package/src/local/runtime-relay.mjs +9 -8
- package/src/local/runtime-reporting.mjs +8 -3
- package/src/local/runtime-resource-service.mjs +77 -0
- package/src/local/runtime-tool-handlers.mjs +8 -8
- package/src/local/runtime.mjs +85 -95
- package/src/local/secure-file.mjs +5 -1
- package/src/local/security-audit-log.mjs +204 -0
- package/src/local/service-convergence.mjs +17 -1
- package/src/local/service-ownership.mjs +17 -0
- package/src/local/service-restart-handoff.mjs +50 -0
- package/src/local/service-restart-scheduler.mjs +49 -0
- package/src/local/service-status.mjs +47 -0
- package/src/local/service.mjs +142 -43
- package/src/local/state-inventory.mjs +21 -3
- package/src/local/state.mjs +155 -17
- package/src/local/stdio.mjs +5 -0
- package/src/local/tool-executor.mjs +39 -6
- package/src/local/tool-result-boundary.mjs +51 -0
- package/src/local/tools.mjs +1 -0
- package/src/local/trusted-executable.mjs +53 -0
- package/src/local/trusted-git-executable.mjs +34 -0
- package/src/local/trusted-github-cli.mjs +24 -0
- package/src/local/windows-service.mjs +20 -0
- package/src/local/worker-deployment.mjs +3 -4
- package/src/local/worker-secret-file.mjs +2 -2
- package/src/local/workspace-file-service.mjs +34 -31
- package/src/shared/admin-auth.d.mts +2 -1
- package/src/shared/admin-auth.mjs +3 -3
- package/src/shared/device-session-auth.d.mts +20 -0
- package/src/shared/device-session-auth.mjs +51 -0
- package/src/shared/log-redaction.d.mts +9 -0
- package/src/shared/log-redaction.mjs +51 -0
- package/src/shared/server-metadata.json +1 -1
- package/src/shared/tool-catalog.json +2 -2
- package/src/worker/account-admin.ts +23 -22
- package/src/worker/authority.ts +23 -4
- package/src/worker/daemon-auth.ts +45 -67
- package/src/worker/daemon-sockets.ts +12 -2
- package/src/worker/device-session-verifier.ts +129 -0
- package/src/worker/dpop.ts +151 -0
- package/src/worker/http.ts +47 -16
- package/src/worker/index.ts +53 -16
- package/src/worker/nonce-store.ts +1 -5
- package/src/worker/oauth-client-admin.ts +45 -0
- package/src/worker/oauth-controller.ts +55 -6
- package/src/worker/oauth-refresh-families.ts +21 -7
- package/src/worker/oauth-state.ts +6 -0
- package/src/worker/oauth-tokens.ts +28 -8
- package/src/worker/observability.ts +4 -2
- package/src/worker/websocket-protocol.ts +8 -2
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { readBoundedRegularFileSync } from "../src/local/secure-file.mjs";
|
|
6
|
+
import { normalizePackRecord } from "./release-acceptance.mjs";
|
|
7
|
+
import { parseReleaseVersion } from "./release-channel.mjs";
|
|
8
|
+
|
|
9
|
+
const MAX_FILE_BYTES = 16 * 1024 * 1024;
|
|
10
|
+
const MAX_TOTAL_BYTES = 96 * 1024 * 1024;
|
|
11
|
+
const VERSION_PLACEHOLDER = "<machine-bridge-release-version>";
|
|
12
|
+
|
|
13
|
+
export function computePromotionContentDigest(root, options = {}) {
|
|
14
|
+
const pkg = options.packageJson || JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
15
|
+
const version = parseReleaseVersion(pkg.version).raw;
|
|
16
|
+
const record = options.packRecord || packageDryRun(root, pkg.name, options.npmCli);
|
|
17
|
+
if (!Array.isArray(record.files) || record.files.length < 1) throw new Error("npm pack dry-run omitted the package file inventory");
|
|
18
|
+
const entries = record.files.map((item) => ({
|
|
19
|
+
path: String(item?.path || ""),
|
|
20
|
+
mode: normalizePackMode(item?.mode),
|
|
21
|
+
})).sort((left, right) => left.path.localeCompare(right.path));
|
|
22
|
+
if (!entries.length || entries.some((entry) => entry.path.startsWith("/") || entry.path.includes("\\") || entry.path.split("/").includes(".."))) {
|
|
23
|
+
throw new Error("npm pack dry-run returned an invalid package path");
|
|
24
|
+
}
|
|
25
|
+
for (let index = 1; index < entries.length; index += 1) {
|
|
26
|
+
if (entries[index - 1].path === entries[index].path) throw new Error(`npm pack dry-run returned duplicate path: ${entries[index].path}`);
|
|
27
|
+
}
|
|
28
|
+
const hash = createHash("sha256");
|
|
29
|
+
let total = 0;
|
|
30
|
+
for (const entry of entries) {
|
|
31
|
+
const relative = entry.path;
|
|
32
|
+
const bytes = readBoundedRegularFileSync(join(root, relative), MAX_FILE_BYTES, `promotion package file ${relative}`, { verifyPathIdentity: true });
|
|
33
|
+
total += bytes.length;
|
|
34
|
+
if (total > MAX_TOTAL_BYTES) throw new Error(`promotion package content exceeds ${MAX_TOTAL_BYTES} bytes`);
|
|
35
|
+
const normalized = normalizeReleaseMetadata(relative, bytes, version);
|
|
36
|
+
hash.update(`${relative}\0${entry.mode}\0${normalized.length}\0`, "utf8");
|
|
37
|
+
hash.update(normalized);
|
|
38
|
+
hash.update("\0", "utf8");
|
|
39
|
+
}
|
|
40
|
+
return hash.digest("hex");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
function normalizePackMode(value) {
|
|
45
|
+
const numeric = Number(value);
|
|
46
|
+
const mode = Number.isSafeInteger(numeric) ? numeric & 0o777 : NaN;
|
|
47
|
+
if (![0o644, 0o755].includes(mode)) throw new Error(`npm pack dry-run returned an invalid file mode: ${String(value)}`);
|
|
48
|
+
return mode.toString(8).padStart(4, "0");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function packageDryRun(root, packageName, npmCli = process.env.npm_execpath) {
|
|
52
|
+
if (!npmCli) throw new Error("promotion digest must run through npm so npm_execpath is available");
|
|
53
|
+
const result = spawnSync(process.execPath, [
|
|
54
|
+
npmCli,
|
|
55
|
+
"pack",
|
|
56
|
+
"--ignore-scripts",
|
|
57
|
+
"--silent",
|
|
58
|
+
"--dry-run",
|
|
59
|
+
"--json",
|
|
60
|
+
], {
|
|
61
|
+
cwd: root,
|
|
62
|
+
encoding: "utf8",
|
|
63
|
+
env: process.env,
|
|
64
|
+
windowsHide: true,
|
|
65
|
+
});
|
|
66
|
+
if (result.error) throw result.error;
|
|
67
|
+
if (result.status !== 0) throw new Error(`npm pack dry-run failed: ${String(result.stderr || result.stdout).trim()}`);
|
|
68
|
+
let parsed;
|
|
69
|
+
try { parsed = JSON.parse(result.stdout); } catch { throw new Error("npm pack dry-run did not return valid JSON"); }
|
|
70
|
+
const record = normalizePackRecord(parsed, packageName);
|
|
71
|
+
if (!record) throw new Error("npm pack dry-run did not return package metadata");
|
|
72
|
+
return record;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function normalizeReleaseMetadata(relative, bytes, version) {
|
|
76
|
+
if (relative === "package.json") {
|
|
77
|
+
const value = parseJson(bytes, relative);
|
|
78
|
+
value.version = VERSION_PLACEHOLDER;
|
|
79
|
+
return canonicalJson(value);
|
|
80
|
+
}
|
|
81
|
+
if (relative === "package-lock.json") {
|
|
82
|
+
const value = parseJson(bytes, relative);
|
|
83
|
+
value.version = VERSION_PLACEHOLDER;
|
|
84
|
+
if (value.packages?.[""] && typeof value.packages[""] === "object") value.packages[""].version = VERSION_PLACEHOLDER;
|
|
85
|
+
return canonicalJson(value);
|
|
86
|
+
}
|
|
87
|
+
if (relative === "browser-extension/manifest.json") {
|
|
88
|
+
const value = parseJson(bytes, relative);
|
|
89
|
+
value.version = "0.0.0";
|
|
90
|
+
value.version_name = VERSION_PLACEHOLDER;
|
|
91
|
+
return canonicalJson(value);
|
|
92
|
+
}
|
|
93
|
+
if (relative === "src/worker/index.ts") {
|
|
94
|
+
const text = bytes.toString("utf8");
|
|
95
|
+
const normalized = text.replace(/const SERVER_VERSION = "[^"]+";/, `const SERVER_VERSION = "${VERSION_PLACEHOLDER}";`);
|
|
96
|
+
if (normalized === text && !text.includes(`const SERVER_VERSION = "${VERSION_PLACEHOLDER}";`)) {
|
|
97
|
+
throw new Error("Worker source omitted the synchronized SERVER_VERSION constant");
|
|
98
|
+
}
|
|
99
|
+
return Buffer.from(normalized, "utf8");
|
|
100
|
+
}
|
|
101
|
+
if (relative === "CHANGELOG.md") {
|
|
102
|
+
return Buffer.from(bytes.toString("utf8").split(version).join(VERSION_PLACEHOLDER), "utf8");
|
|
103
|
+
}
|
|
104
|
+
return bytes;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function parseJson(bytes, label) {
|
|
108
|
+
try { return JSON.parse(bytes.toString("utf8")); } catch (error) {
|
|
109
|
+
throw new Error(`${label} is not valid JSON: ${error.message}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function canonicalJson(value) {
|
|
114
|
+
return Buffer.from(`${JSON.stringify(sortValue(value))}\n`, "utf8");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function sortValue(value) {
|
|
118
|
+
if (Array.isArray(value)) return value.map(sortValue);
|
|
119
|
+
if (!value || typeof value !== "object") return value;
|
|
120
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortValue(value[key])]));
|
|
121
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
|
+
import { parseReleaseVersion, requiresSoakForStable } from "./release-channel.mjs";
|
|
8
|
+
import { verifyCurrentStableSoak } from "./release-soak.mjs";
|
|
9
|
+
|
|
10
|
+
const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
11
|
+
|
|
12
|
+
export function npmPublishArguments(version, mode) {
|
|
13
|
+
const parsed = parseReleaseVersion(version);
|
|
14
|
+
if (mode === "prerelease" && !parsed.prerelease) throw new Error("prerelease publication requires a dev, beta, or rc version");
|
|
15
|
+
if (mode === "stable" && parsed.prerelease) throw new Error("stable publication requires a version without a prerelease suffix");
|
|
16
|
+
if (!new Set(["prerelease", "stable"]).has(mode)) throw new Error("publication mode must be prerelease or stable");
|
|
17
|
+
return ["publish", "--tag", parsed.npmTag];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function publishCurrentNpmPackage(repositoryRoot, mode, options = {}) {
|
|
21
|
+
const pkg = JSON.parse(readFileSync(resolve(repositoryRoot, "package.json"), "utf8"));
|
|
22
|
+
const parsed = parseReleaseVersion(pkg.version);
|
|
23
|
+
const args = npmPublishArguments(parsed.raw, mode);
|
|
24
|
+
if (!parsed.prerelease && requiresSoakForStable(parsed.raw)) verifyCurrentStableSoak(repositoryRoot);
|
|
25
|
+
const npmCli = options.npmCli || process.env.npm_execpath;
|
|
26
|
+
if (!npmCli) throw new Error("npm publication must run through npm so npm_execpath is available");
|
|
27
|
+
const run = options.run || spawnSync;
|
|
28
|
+
const result = run(process.execPath, [npmCli, ...args], {
|
|
29
|
+
cwd: repositoryRoot,
|
|
30
|
+
encoding: "utf8",
|
|
31
|
+
env: process.env,
|
|
32
|
+
stdio: options.capture ? "pipe" : "inherit",
|
|
33
|
+
windowsHide: true,
|
|
34
|
+
});
|
|
35
|
+
if (result.error) throw result.error;
|
|
36
|
+
if (result.status !== 0) throw new Error(`npm publish failed with status ${result.status}`);
|
|
37
|
+
return { version: parsed.raw, tag: parsed.npmTag };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function main() {
|
|
41
|
+
const mode = process.argv[2] || "";
|
|
42
|
+
const result = publishCurrentNpmPackage(root, mode);
|
|
43
|
+
console.log(`npm publication completed: ${result.version} with dist-tag ${result.tag}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
|
|
47
|
+
main().catch((error) => {
|
|
48
|
+
console.error(`npm publication failed: ${error?.message || error}`);
|
|
49
|
+
process.exitCode = 1;
|
|
50
|
+
});
|
|
51
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { resolveTrustedGithubCli } from "../src/local/trusted-github-cli.mjs";
|
|
3
|
+
|
|
4
|
+
export function readPublishedNpmPrerelease(name, version, distTag, options = {}) {
|
|
5
|
+
const run = options.run || runNpmView;
|
|
6
|
+
const actualVersion = run([`${name}@${version}`, "version", "--json"]);
|
|
7
|
+
const integrity = run([`${name}@${version}`, "dist.integrity", "--json"]);
|
|
8
|
+
const shasum = run([`${name}@${version}`, "dist.shasum", "--json"]);
|
|
9
|
+
const tags = run([name, "dist-tags", "--json"]);
|
|
10
|
+
const times = run([name, "time", "--json"]);
|
|
11
|
+
return normalizePublishedNpmPrerelease({
|
|
12
|
+
requestedVersion: version,
|
|
13
|
+
requestedTag: distTag,
|
|
14
|
+
actualVersion,
|
|
15
|
+
integrity,
|
|
16
|
+
shasum,
|
|
17
|
+
tags,
|
|
18
|
+
times,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function normalizePublishedNpmPrerelease(value) {
|
|
23
|
+
const actualVersion = unwrapNpmJsonValue(value.actualVersion);
|
|
24
|
+
const tags = unwrapNpmJsonValue(value.tags);
|
|
25
|
+
const times = unwrapNpmJsonValue(value.times);
|
|
26
|
+
const version = String(actualVersion || "");
|
|
27
|
+
const requestedVersion = String(value.requestedVersion || "");
|
|
28
|
+
const distTag = String(value.requestedTag || "");
|
|
29
|
+
const integrity = String(unwrapNpmJsonValue(value.integrity) || "");
|
|
30
|
+
const shasum = String(unwrapNpmJsonValue(value.shasum) || "");
|
|
31
|
+
const publishedAt = String(times?.[requestedVersion] || "");
|
|
32
|
+
if (version !== requestedVersion) throw new Error("npm registry returned another prerelease version");
|
|
33
|
+
if (tags?.[distTag] !== requestedVersion) throw new Error(`npm dist-tag ${distTag} does not point to ${requestedVersion}`);
|
|
34
|
+
if (!/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(integrity)) throw new Error("npm prerelease integrity is invalid");
|
|
35
|
+
if (!/^[0-9a-f]{40}$/.test(shasum)) throw new Error("npm prerelease SHA-1 is invalid");
|
|
36
|
+
if (!Number.isFinite(Date.parse(publishedAt))) throw new Error("npm prerelease publication timestamp is invalid");
|
|
37
|
+
return Object.freeze({ version, integrity, shasum, distTag, publishedAt: new Date(Date.parse(publishedAt)).toISOString() });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function unwrapNpmJsonValue(value) {
|
|
41
|
+
return Array.isArray(value) && value.length === 1 ? value[0] : value;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function readGithubPrerelease(version, options = {}) {
|
|
45
|
+
const run = options.run || runGh;
|
|
46
|
+
const value = run(["release", "view", `v${version}`, "--json", "tagName,isPrerelease,publishedAt,assets"]);
|
|
47
|
+
return normalizeGithubPrerelease(value, version);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function normalizeGithubPrerelease(value, version) {
|
|
51
|
+
const tag = String(value?.tagName || "");
|
|
52
|
+
const publishedAt = String(value?.publishedAt || "");
|
|
53
|
+
if (tag !== `v${version}` || value?.isPrerelease !== true) throw new Error("GitHub prerelease is missing or not marked as a prerelease");
|
|
54
|
+
if (!Number.isFinite(Date.parse(publishedAt))) throw new Error("GitHub prerelease publication timestamp is invalid");
|
|
55
|
+
if (!Array.isArray(value?.assets)) throw new Error("GitHub prerelease asset inventory is unavailable");
|
|
56
|
+
const expectedAsset = `machine-bridge-mcp-${version}.tgz`;
|
|
57
|
+
const asset = value.assets.find((entry) => entry && typeof entry === "object" && entry.name === expectedAsset);
|
|
58
|
+
if (!asset) throw new Error(`GitHub prerelease is missing ${expectedAsset}`);
|
|
59
|
+
const size = Number(asset.size);
|
|
60
|
+
if (!Number.isSafeInteger(size) || size < 1) throw new Error("GitHub prerelease package asset size is invalid");
|
|
61
|
+
return Object.freeze({
|
|
62
|
+
tag,
|
|
63
|
+
isPrerelease: true,
|
|
64
|
+
publishedAt: new Date(Date.parse(publishedAt)).toISOString(),
|
|
65
|
+
asset: Object.freeze({ name: expectedAsset, size }),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function runNpmView(args) {
|
|
70
|
+
const npmCli = process.env.npm_execpath;
|
|
71
|
+
if (!npmCli) throw new Error("npm registry verification must run through npm");
|
|
72
|
+
const result = spawnSync(process.execPath, [npmCli, "view", ...args], {
|
|
73
|
+
encoding: "utf8",
|
|
74
|
+
env: process.env,
|
|
75
|
+
windowsHide: true,
|
|
76
|
+
});
|
|
77
|
+
if (result.error) throw result.error;
|
|
78
|
+
if (result.status !== 0) throw new Error(`npm view ${args[0]} failed: ${String(result.stderr || result.stdout).trim()}`);
|
|
79
|
+
try { return JSON.parse(result.stdout); } catch { throw new Error(`npm view ${args[0]} returned invalid JSON`); }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function runGh(args) {
|
|
83
|
+
const result = spawnSync(resolveTrustedGithubCli({ workspace: process.cwd() }), args, { encoding: "utf8", env: process.env, windowsHide: true });
|
|
84
|
+
if (result.error) throw result.error;
|
|
85
|
+
if (result.status !== 0) throw new Error(`gh ${args[0]} failed: ${String(result.stderr || result.stdout).trim()}`);
|
|
86
|
+
try { return JSON.parse(result.stdout); } catch { throw new Error("GitHub prerelease query returned invalid JSON"); }
|
|
87
|
+
}
|
|
@@ -8,12 +8,16 @@ import { tmpdir } from "node:os";
|
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import { spawnSync } from "node:child_process";
|
|
10
10
|
import { readBoundedRegularFileSync } from "../src/local/secure-file.mjs";
|
|
11
|
+
import { compareReleaseVersions, parseReleaseVersion } from "./release-channel.mjs";
|
|
11
12
|
|
|
12
13
|
export const ACCEPTANCE_SCHEMA_VERSION = 1;
|
|
13
14
|
export const ACCEPTANCE_POLICY_VERSION = "1.2.8";
|
|
14
15
|
export const AGENT_VERIFIED_ACCEPTANCE_VERSION = "1.2.9";
|
|
15
16
|
export const AGENT_OPERATED_ACCEPTANCE_VERSION = "2.0.0";
|
|
16
17
|
export const ACCEPTANCE_CONFIRMATION = "owner-authorized-agent-operated-local-candidate";
|
|
18
|
+
export const PROMOTION_DIGEST_POLICY_VERSION = "3.0.0-beta.1";
|
|
19
|
+
export const PERSISTENT_OWNER_ACTIVATED_ACCEPTANCE_VERSION = "3.0.0-beta.1";
|
|
20
|
+
export const PERSISTENT_OWNER_ACTIVATED_CONFIRMATION = "owner-activated-agent-verified-persistent-candidate";
|
|
17
21
|
export const OWNER_STARTED_ACCEPTANCE_CONFIRMATION = "owner-started-agent-verified-local-candidate";
|
|
18
22
|
export const LEGACY_ACCEPTANCE_CONFIRMATION = "repository-owner-local-test";
|
|
19
23
|
const MAX_ACCEPTANCE_BYTES = 64 * 1024;
|
|
@@ -25,6 +29,9 @@ export function requiresLocalAcceptance(version) {
|
|
|
25
29
|
|
|
26
30
|
export function acceptanceConfirmationForVersion(version) {
|
|
27
31
|
const parsed = parseVersion(version);
|
|
32
|
+
if (compareReleaseVersions(parseReleaseVersion(version), parseReleaseVersion(PERSISTENT_OWNER_ACTIVATED_ACCEPTANCE_VERSION)) >= 0) {
|
|
33
|
+
return PERSISTENT_OWNER_ACTIVATED_CONFIRMATION;
|
|
34
|
+
}
|
|
28
35
|
if (compareVersions(parsed, parseVersion(AGENT_OPERATED_ACCEPTANCE_VERSION)) >= 0) return ACCEPTANCE_CONFIRMATION;
|
|
29
36
|
if (compareVersions(parsed, parseVersion(AGENT_VERIFIED_ACCEPTANCE_VERSION)) >= 0) return OWNER_STARTED_ACCEPTANCE_CONFIRMATION;
|
|
30
37
|
return LEGACY_ACCEPTANCE_CONFIRMATION;
|
|
@@ -105,6 +112,12 @@ export function verifyAcceptanceRecord(record, metadata) {
|
|
|
105
112
|
if (!record || typeof record !== "object" || Array.isArray(record)) {
|
|
106
113
|
throw new Error("release acceptance record must be an object");
|
|
107
114
|
}
|
|
115
|
+
const allowed = new Set([
|
|
116
|
+
"schema_version", "result", "confirmation", "package_name", "package_version", "filename",
|
|
117
|
+
"shasum", "integrity", "accepted_at", "package_content_sha256", "promotion_content_sha256",
|
|
118
|
+
]);
|
|
119
|
+
const unknown = Object.keys(record).filter((key) => !allowed.has(key));
|
|
120
|
+
if (unknown.length) throw new Error(`release acceptance record contains unsupported fields: ${unknown.join(", ")}`);
|
|
108
121
|
if (record.schema_version !== ACCEPTANCE_SCHEMA_VERSION) {
|
|
109
122
|
throw new Error(`unsupported release acceptance schema: ${record.schema_version}`);
|
|
110
123
|
}
|
|
@@ -118,12 +131,28 @@ export function verifyAcceptanceRecord(record, metadata) {
|
|
|
118
131
|
if (!/^[0-9a-f]{64}$/.test(String(record.package_content_sha256 || ""))) {
|
|
119
132
|
throw new Error("local release acceptance portable package-content digest is missing or invalid");
|
|
120
133
|
}
|
|
134
|
+
if (compareReleaseVersions(parseReleaseVersion(metadata.package_version), parseReleaseVersion(PROMOTION_DIGEST_POLICY_VERSION)) >= 0
|
|
135
|
+
&& !/^[0-9a-f]{64}$/.test(String(record.promotion_content_sha256 || ""))) {
|
|
136
|
+
throw new Error("local release acceptance promotion-content digest is missing or invalid");
|
|
137
|
+
}
|
|
121
138
|
for (const key of ["package_name", "package_version", "filename", "shasum", "integrity"]) {
|
|
122
139
|
if (record[key] !== metadata[key]) {
|
|
123
140
|
throw new Error(`local release acceptance ${key} does not match the current npm package`);
|
|
124
141
|
}
|
|
125
142
|
}
|
|
126
|
-
return
|
|
143
|
+
return Object.freeze({
|
|
144
|
+
schema_version: ACCEPTANCE_SCHEMA_VERSION,
|
|
145
|
+
result: "passed",
|
|
146
|
+
confirmation: expectedConfirmation,
|
|
147
|
+
package_name: metadata.package_name,
|
|
148
|
+
package_version: metadata.package_version,
|
|
149
|
+
filename: metadata.filename,
|
|
150
|
+
shasum: metadata.shasum,
|
|
151
|
+
integrity: metadata.integrity,
|
|
152
|
+
accepted_at: new Date(acceptedAt).toISOString(),
|
|
153
|
+
package_content_sha256: String(record.package_content_sha256),
|
|
154
|
+
...(record.promotion_content_sha256 ? { promotion_content_sha256: String(record.promotion_content_sha256) } : {}),
|
|
155
|
+
});
|
|
127
156
|
}
|
|
128
157
|
|
|
129
158
|
export function verifyCurrentReleaseAcceptance(root) {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { parseReleaseVersion } from "./release-channel.mjs";
|
|
2
|
+
|
|
3
|
+
export const CANDIDATE_MANIFEST_SCHEMA_VERSION = 1;
|
|
4
|
+
const FIELDS = new Set([
|
|
5
|
+
"schema_version", "result", "package_name", "package_version", "filename", "shasum", "integrity",
|
|
6
|
+
"promotion_content_sha256", "prepared_at",
|
|
7
|
+
]);
|
|
8
|
+
|
|
9
|
+
export function validateCandidateManifest(value, expected = {}) {
|
|
10
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("release candidate manifest must be an object");
|
|
11
|
+
const unknown = Object.keys(value).filter((key) => !FIELDS.has(key));
|
|
12
|
+
if (unknown.length) throw new Error(`release candidate manifest contains unsupported fields: ${unknown.join(", ")}`);
|
|
13
|
+
if (value.schema_version !== CANDIDATE_MANIFEST_SCHEMA_VERSION || value.result !== "pending") {
|
|
14
|
+
throw new Error("release candidate manifest is not pending current-schema state");
|
|
15
|
+
}
|
|
16
|
+
const version = parseReleaseVersion(value.package_version).raw;
|
|
17
|
+
const packageName = String(value.package_name || "");
|
|
18
|
+
if (packageName !== "machine-bridge-mcp") throw new Error("release candidate manifest package is invalid");
|
|
19
|
+
if (expected.packageName && packageName !== expected.packageName) throw new Error("release candidate manifest package does not match the current package");
|
|
20
|
+
if (expected.packageVersion && version !== expected.packageVersion) throw new Error("release candidate manifest version does not match the current package");
|
|
21
|
+
const filename = String(value.filename || "");
|
|
22
|
+
const expectedFilename = `${packageName}-${version}.tgz`;
|
|
23
|
+
if (filename !== expectedFilename) throw new Error(`release candidate manifest filename is invalid; expected ${expectedFilename}`);
|
|
24
|
+
if (!/^[0-9a-f]{40}$/.test(String(value.shasum || ""))) throw new Error("release candidate manifest SHA-1 is invalid");
|
|
25
|
+
if (!/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(String(value.integrity || ""))) throw new Error("release candidate manifest integrity is invalid");
|
|
26
|
+
if (!/^[0-9a-f]{64}$/.test(String(value.promotion_content_sha256 || ""))) throw new Error("release candidate promotion digest is invalid");
|
|
27
|
+
const preparedAt = Date.parse(String(value.prepared_at || ""));
|
|
28
|
+
if (!Number.isFinite(preparedAt)) throw new Error("release candidate preparation timestamp is invalid");
|
|
29
|
+
return Object.freeze({
|
|
30
|
+
schema_version: CANDIDATE_MANIFEST_SCHEMA_VERSION,
|
|
31
|
+
result: "pending",
|
|
32
|
+
package_name: packageName,
|
|
33
|
+
package_version: version,
|
|
34
|
+
filename,
|
|
35
|
+
shasum: String(value.shasum),
|
|
36
|
+
integrity: String(value.integrity),
|
|
37
|
+
promotion_content_sha256: String(value.promotion_content_sha256),
|
|
38
|
+
prepared_at: new Date(preparedAt).toISOString(),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
const CHANNEL_TAGS = Object.freeze({ dev: "dev", beta: "beta", rc: "next" });
|
|
2
|
+
const SOAK_SECONDS = Object.freeze({ major: 7 * 24 * 60 * 60, minor: 3 * 24 * 60 * 60, patch: 24 * 60 * 60 });
|
|
3
|
+
|
|
4
|
+
export const SOAK_POLICY_VERSION = "3.0.0";
|
|
5
|
+
export const SOAK_CONFIRMATION = "owner-reported-published-prerelease-soak-passed";
|
|
6
|
+
|
|
7
|
+
export function parseReleaseVersion(value) {
|
|
8
|
+
const raw = String(value || "").trim();
|
|
9
|
+
const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z]+)\.(\d+))?$/.exec(raw);
|
|
10
|
+
if (!match) throw new Error(`release version must use x.y.z or x.y.z-(dev|beta|rc).n: ${raw || "<empty>"}`);
|
|
11
|
+
const channel = match[4] || "";
|
|
12
|
+
if (channel && !Object.hasOwn(CHANNEL_TAGS, channel)) {
|
|
13
|
+
throw new Error(`unsupported prerelease channel ${channel}; use dev, beta, or rc`);
|
|
14
|
+
}
|
|
15
|
+
const sequence = channel ? Number(match[5]) : 0;
|
|
16
|
+
if (channel && (!Number.isSafeInteger(sequence) || sequence < 1)) {
|
|
17
|
+
throw new Error("prerelease sequence must be a positive integer");
|
|
18
|
+
}
|
|
19
|
+
const major = Number(match[1]);
|
|
20
|
+
const minor = Number(match[2]);
|
|
21
|
+
const patch = Number(match[3]);
|
|
22
|
+
for (const part of [major, minor, patch]) {
|
|
23
|
+
if (!Number.isSafeInteger(part) || part < 0) throw new Error("release version contains an invalid numeric component");
|
|
24
|
+
}
|
|
25
|
+
return Object.freeze({
|
|
26
|
+
raw,
|
|
27
|
+
major,
|
|
28
|
+
minor,
|
|
29
|
+
patch,
|
|
30
|
+
channel,
|
|
31
|
+
sequence,
|
|
32
|
+
prerelease: Boolean(channel),
|
|
33
|
+
baseVersion: `${major}.${minor}.${patch}`,
|
|
34
|
+
npmTag: channel ? CHANNEL_TAGS[channel] : "latest",
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function compareReleaseVersions(leftValue, rightValue) {
|
|
39
|
+
const left = typeof leftValue === "string" ? parseReleaseVersion(leftValue) : leftValue;
|
|
40
|
+
const right = typeof rightValue === "string" ? parseReleaseVersion(rightValue) : rightValue;
|
|
41
|
+
for (const key of ["major", "minor", "patch"]) {
|
|
42
|
+
if (left[key] !== right[key]) return left[key] - right[key];
|
|
43
|
+
}
|
|
44
|
+
if (!left.prerelease && !right.prerelease) return 0;
|
|
45
|
+
if (!left.prerelease) return 1;
|
|
46
|
+
if (!right.prerelease) return -1;
|
|
47
|
+
const channelOrder = { dev: 0, beta: 1, rc: 2 };
|
|
48
|
+
if (left.channel !== right.channel) return channelOrder[left.channel] - channelOrder[right.channel];
|
|
49
|
+
return left.sequence - right.sequence;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function requiresSoakForStable(version) {
|
|
53
|
+
const parsed = parseReleaseVersion(version);
|
|
54
|
+
return !parsed.prerelease && compareReleaseVersions(parsed, parseReleaseVersion(SOAK_POLICY_VERSION)) >= 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function assertPublishTag(version, requestedTag) {
|
|
58
|
+
const parsed = parseReleaseVersion(version);
|
|
59
|
+
const actual = String(requestedTag || (parsed.prerelease ? "latest" : "latest")).trim().toLowerCase();
|
|
60
|
+
if (actual !== parsed.npmTag) {
|
|
61
|
+
throw new Error(`${parsed.raw} must be published with npm dist-tag ${parsed.npmTag}, not ${actual || "<empty>"}`);
|
|
62
|
+
}
|
|
63
|
+
return parsed.npmTag;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function assertSoakEligiblePrerelease(version) {
|
|
67
|
+
const parsed = parseReleaseVersion(version);
|
|
68
|
+
if (!parsed.prerelease || !["beta", "rc"].includes(parsed.channel)) {
|
|
69
|
+
throw new Error("stable-release soak requires a published beta or rc version");
|
|
70
|
+
}
|
|
71
|
+
return parsed;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function assertStablePromotion(stableVersion, prereleaseVersion) {
|
|
75
|
+
const stable = parseReleaseVersion(stableVersion);
|
|
76
|
+
const prerelease = assertSoakEligiblePrerelease(prereleaseVersion);
|
|
77
|
+
if (stable.prerelease) throw new Error("stable promotion target must not contain a prerelease suffix");
|
|
78
|
+
if (stable.baseVersion !== prerelease.baseVersion) {
|
|
79
|
+
throw new Error(`prerelease ${prerelease.raw} cannot promote to unrelated stable version ${stable.raw}`);
|
|
80
|
+
}
|
|
81
|
+
return { stable, prerelease };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function minimumSoakSeconds(baseVersion, previousStableVersion = "") {
|
|
85
|
+
const target = parseReleaseVersion(baseVersion);
|
|
86
|
+
if (target.prerelease) throw new Error("minimum soak target must be a stable base version");
|
|
87
|
+
if (!previousStableVersion) {
|
|
88
|
+
if (target.minor === 0 && target.patch === 0) return SOAK_SECONDS.major;
|
|
89
|
+
if (target.patch === 0) return SOAK_SECONDS.minor;
|
|
90
|
+
return SOAK_SECONDS.patch;
|
|
91
|
+
}
|
|
92
|
+
const previous = parseReleaseVersion(previousStableVersion);
|
|
93
|
+
if (previous.prerelease) throw new Error("previous stable version must not be a prerelease");
|
|
94
|
+
if (compareReleaseVersions(target, previous) <= 0) throw new Error("target stable version must be newer than the previous stable version");
|
|
95
|
+
if (target.major !== previous.major) return SOAK_SECONDS.major;
|
|
96
|
+
if (target.minor !== previous.minor) return SOAK_SECONDS.minor;
|
|
97
|
+
return SOAK_SECONDS.patch;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function formatSoakDuration(seconds) {
|
|
101
|
+
const value = Number(seconds);
|
|
102
|
+
if (!Number.isSafeInteger(value) || value < 0) throw new Error("soak duration must be a non-negative integer");
|
|
103
|
+
if (value % (24 * 60 * 60) === 0) return `${value / (24 * 60 * 60)}d`;
|
|
104
|
+
if (value % (60 * 60) === 0) return `${value / (60 * 60)}h`;
|
|
105
|
+
return `${value}s`;
|
|
106
|
+
}
|
|
@@ -2,12 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
import { execFileSync } from "node:child_process";
|
|
4
4
|
import { readFileSync } from "node:fs";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { compareReleaseVersions, parseReleaseVersion } from "./release-channel.mjs";
|
|
7
|
+
import { createTrustedGitResolver } from "../src/local/trusted-git-executable.mjs";
|
|
5
8
|
|
|
6
9
|
const VERSION_TAG = /^v(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/;
|
|
7
10
|
|
|
8
|
-
const
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
+
const root = new URL("../", import.meta.url);
|
|
12
|
+
const pkg = JSON.parse(readFileSync(new URL("package.json", root), "utf8"));
|
|
13
|
+
const gitExecutable = createTrustedGitResolver({ workspace: fileURLToPath(root) });
|
|
14
|
+
let current;
|
|
15
|
+
try { current = parseReleaseVersion(String(pkg.version || "")); } catch { fail("package.json contains an invalid version"); }
|
|
11
16
|
|
|
12
17
|
const tags = git(["tag", "--merged", "HEAD", "--sort=-version:refname", "--list", "v[0-9]*"])
|
|
13
18
|
.split("\n")
|
|
@@ -19,7 +24,7 @@ if (!tags.length) {
|
|
|
19
24
|
}
|
|
20
25
|
|
|
21
26
|
const latestTag = tags[0];
|
|
22
|
-
const latest =
|
|
27
|
+
const latest = parseReleaseVersion(latestTag.slice(1));
|
|
23
28
|
const changed = new Set([
|
|
24
29
|
...lines(git(["diff", "--name-only", latestTag, "--"])),
|
|
25
30
|
...lines(git(["ls-files", "--others", "--exclude-standard"])),
|
|
@@ -35,11 +40,11 @@ if (!packageRelevant.length) {
|
|
|
35
40
|
process.stderr.write(`release impact check ok: ${relevant.length} repository-only file(s) changed since ${latestTag}; npm package content is unchanged\n`);
|
|
36
41
|
process.exit(0);
|
|
37
42
|
}
|
|
38
|
-
if (
|
|
43
|
+
if (compareReleaseVersions(current, latest) <= 0) {
|
|
39
44
|
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`);
|
|
40
45
|
}
|
|
41
46
|
|
|
42
|
-
const changelog = readFileSync(new URL("
|
|
47
|
+
const changelog = readFileSync(new URL("CHANGELOG.md", root), "utf8");
|
|
43
48
|
const heading = new RegExp(`^## ${escapeRegExp(pkg.version)}(?:\\s+-[^\\n]*)?$`, "m");
|
|
44
49
|
if (!heading.test(changelog)) fail(`CHANGELOG.md has no section for ${pkg.version}`);
|
|
45
50
|
|
|
@@ -58,7 +63,7 @@ export function isPackageRelevant(path, packageFiles = []) {
|
|
|
58
63
|
|
|
59
64
|
function git(args) {
|
|
60
65
|
try {
|
|
61
|
-
return execFileSync(
|
|
66
|
+
return execFileSync(gitExecutable(), args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
62
67
|
} catch (error) {
|
|
63
68
|
const detail = String(error?.stderr || error?.message || error).trim();
|
|
64
69
|
fail(`git ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
|
|
@@ -69,21 +74,6 @@ function lines(value) {
|
|
|
69
74
|
return value ? value.split("\n").map((item) => item.trim()).filter(Boolean) : [];
|
|
70
75
|
}
|
|
71
76
|
|
|
72
|
-
function parseVersion(value) {
|
|
73
|
-
const match = String(value).match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
|
|
74
|
-
return match ? { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]), prerelease: match[4] || "" } : null;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function compareVersions(left, right) {
|
|
78
|
-
for (const key of ["major", "minor", "patch"]) {
|
|
79
|
-
if (left[key] !== right[key]) return left[key] - right[key];
|
|
80
|
-
}
|
|
81
|
-
if (left.prerelease === right.prerelease) return 0;
|
|
82
|
-
if (!left.prerelease) return 1;
|
|
83
|
-
if (!right.prerelease) return -1;
|
|
84
|
-
return left.prerelease.localeCompare(right.prerelease);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
77
|
function escapeRegExp(value) {
|
|
88
78
|
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
89
79
|
}
|