machine-bridge-mcp 2.0.0 → 3.0.0-beta.10

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.
Files changed (143) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/CONTRIBUTING.md +20 -25
  3. package/README.md +21 -14
  4. package/SECURITY.md +147 -123
  5. package/browser-extension/manifest.json +3 -2
  6. package/browser-extension/service-worker.js +31 -12
  7. package/docs/ARCHITECTURE.md +27 -13
  8. package/docs/AUDIT.md +85 -2
  9. package/docs/CLIENTS.md +2 -2
  10. package/docs/ENGINEERING.md +9 -3
  11. package/docs/GETTING_STARTED.md +10 -9
  12. package/docs/LOCAL_AUTHORIZATION.md +115 -72
  13. package/docs/LOGGING.md +12 -3
  14. package/docs/MANAGED_JOBS.md +24 -29
  15. package/docs/MULTI_ACCOUNT.md +98 -37
  16. package/docs/OPERATIONS.md +33 -22
  17. package/docs/OVERVIEW.md +7 -7
  18. package/docs/PROJECT_STANDARDS.md +9 -5
  19. package/docs/RELEASING.md +171 -64
  20. package/docs/TESTING.md +19 -7
  21. package/docs/THREAT_MODEL.md +138 -67
  22. package/docs/TOOL_REFERENCE.md +2 -2
  23. package/docs/UPGRADING.md +102 -18
  24. package/native/macos/MachineBridgeTrustBroker.swift +233 -0
  25. package/package.json +36 -5
  26. package/scripts/candidate-runtime-store.mjs +38 -0
  27. package/scripts/check-plan.mjs +19 -0
  28. package/scripts/check-runner.mjs +4 -1
  29. package/scripts/commit-message-check.mjs +5 -2
  30. package/scripts/coverage-check.mjs +30 -2
  31. package/scripts/github-push.mjs +11 -0
  32. package/scripts/github-release.mjs +38 -7
  33. package/scripts/install-published-prerelease.mjs +167 -0
  34. package/scripts/local-release-acceptance.mjs +23 -12
  35. package/scripts/npm-publication-policy.mjs +16 -0
  36. package/scripts/prerelease-activation.mjs +79 -0
  37. package/scripts/privacy-check.mjs +7 -5
  38. package/scripts/promotion-digest.mjs +121 -0
  39. package/scripts/publish-npm.mjs +51 -0
  40. package/scripts/published-release.mjs +87 -0
  41. package/scripts/release-acceptance.mjs +30 -1
  42. package/scripts/release-candidate-manifest.mjs +40 -0
  43. package/scripts/release-channel.mjs +106 -0
  44. package/scripts/release-impact-check.mjs +12 -22
  45. package/scripts/release-soak.mjs +237 -0
  46. package/scripts/start-release-candidate.mjs +113 -7
  47. package/src/local/account-access.mjs +6 -0
  48. package/src/local/account-admin.mjs +34 -16
  49. package/src/local/agent-context.mjs +45 -42
  50. package/src/local/app-automation.mjs +9 -4
  51. package/src/local/authority-context.mjs +106 -0
  52. package/src/local/browser-bridge-http.mjs +4 -1
  53. package/src/local/browser-bridge.mjs +14 -9
  54. package/src/local/browser-broker-routes.mjs +4 -0
  55. package/src/local/browser-broker-server.mjs +5 -4
  56. package/src/local/browser-extension-identity.mjs +50 -0
  57. package/src/local/browser-extension-protocol.mjs +9 -4
  58. package/src/local/browser-operation-service.mjs +7 -0
  59. package/src/local/browser-pairing-http.mjs +36 -0
  60. package/src/local/browser-pairing-store.mjs +56 -47
  61. package/src/local/cli-account-admin.mjs +41 -7
  62. package/src/local/cli-activate.mjs +90 -0
  63. package/src/local/cli-approval.mjs +14 -58
  64. package/src/local/cli-local-admin.mjs +5 -15
  65. package/src/local/cli-options.mjs +11 -7
  66. package/src/local/cli-service.mjs +35 -5
  67. package/src/local/cli.mjs +112 -58
  68. package/src/local/daemon-process.mjs +6 -0
  69. package/src/local/delegated-process-sandbox.mjs +147 -0
  70. package/src/local/device-identity.mjs +157 -48
  71. package/src/local/device-root-provider.mjs +87 -0
  72. package/src/local/errors.mjs +25 -3
  73. package/src/local/git-service.mjs +16 -15
  74. package/src/local/job-runner.mjs +35 -27
  75. package/src/local/log.mjs +14 -1
  76. package/src/local/macos-trust-broker.mjs +320 -0
  77. package/src/local/managed-job-lock.mjs +18 -0
  78. package/src/local/managed-job-projection.mjs +9 -1
  79. package/src/local/managed-job-runner.mjs +13 -1
  80. package/src/local/managed-job-storage.mjs +2 -1
  81. package/src/local/managed-job-terminal.mjs +138 -0
  82. package/src/local/managed-jobs.mjs +88 -32
  83. package/src/local/operation-authorization.mjs +75 -239
  84. package/src/local/operation-risk.mjs +28 -0
  85. package/src/local/operation-state-lock.mjs +7 -86
  86. package/src/local/owner-state-lock.mjs +104 -0
  87. package/src/local/policy.mjs +19 -0
  88. package/src/local/process-execution.mjs +21 -9
  89. package/src/local/process-sessions.mjs +27 -17
  90. package/src/local/process-tracker.mjs +30 -6
  91. package/src/local/process-tree-ownership.mjs +59 -0
  92. package/src/local/process-tree.mjs +22 -18
  93. package/src/local/relay-connection.mjs +47 -35
  94. package/src/local/relay-diagnostics.mjs +65 -0
  95. package/src/local/remote-configuration.mjs +48 -0
  96. package/src/local/runtime-activation.mjs +141 -0
  97. package/src/local/runtime-diagnostics.mjs +21 -0
  98. package/src/local/runtime-relay.mjs +9 -8
  99. package/src/local/runtime-reporting.mjs +8 -3
  100. package/src/local/runtime-tool-handlers.mjs +8 -8
  101. package/src/local/runtime.mjs +73 -39
  102. package/src/local/secure-file.mjs +5 -1
  103. package/src/local/security-audit-log.mjs +204 -0
  104. package/src/local/service-convergence.mjs +17 -1
  105. package/src/local/service-ownership.mjs +17 -0
  106. package/src/local/service-restart-handoff.mjs +50 -0
  107. package/src/local/service-restart-scheduler.mjs +49 -0
  108. package/src/local/service-status.mjs +47 -0
  109. package/src/local/service.mjs +142 -43
  110. package/src/local/state-inventory.mjs +21 -3
  111. package/src/local/state.mjs +155 -17
  112. package/src/local/stdio.mjs +5 -0
  113. package/src/local/tool-executor.mjs +39 -6
  114. package/src/local/tool-result-boundary.mjs +51 -0
  115. package/src/local/tools.mjs +1 -0
  116. package/src/local/trusted-executable.mjs +53 -0
  117. package/src/local/trusted-git-executable.mjs +34 -0
  118. package/src/local/trusted-github-cli.mjs +24 -0
  119. package/src/local/windows-service.mjs +20 -0
  120. package/src/local/worker-deployment.mjs +3 -4
  121. package/src/local/worker-secret-file.mjs +2 -2
  122. package/src/local/workspace-file-service.mjs +34 -31
  123. package/src/shared/admin-auth.d.mts +2 -1
  124. package/src/shared/admin-auth.mjs +3 -3
  125. package/src/shared/device-session-auth.d.mts +20 -0
  126. package/src/shared/device-session-auth.mjs +51 -0
  127. package/src/shared/server-metadata.json +1 -1
  128. package/src/shared/tool-catalog.json +2 -2
  129. package/src/worker/account-admin.ts +23 -22
  130. package/src/worker/authority.ts +9 -2
  131. package/src/worker/daemon-auth.ts +45 -67
  132. package/src/worker/daemon-sockets.ts +12 -2
  133. package/src/worker/device-session-verifier.ts +129 -0
  134. package/src/worker/dpop.ts +151 -0
  135. package/src/worker/http.ts +47 -16
  136. package/src/worker/index.ts +53 -16
  137. package/src/worker/nonce-store.ts +1 -5
  138. package/src/worker/oauth-client-admin.ts +45 -0
  139. package/src/worker/oauth-controller.ts +55 -6
  140. package/src/worker/oauth-refresh-families.ts +21 -7
  141. package/src/worker/oauth-state.ts +6 -0
  142. package/src/worker/oauth-tokens.ts +28 -8
  143. package/src/worker/websocket-protocol.ts +8 -2
@@ -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 record;
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 pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
9
- const current = parseVersion(String(pkg.version || ""));
10
- if (!current) fail("package.json contains an invalid version");
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 = parseVersion(latestTag.slice(1));
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 (compareVersions(current, latest) <= 0) {
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("../CHANGELOG.md", import.meta.url), "utf8");
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("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
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
  }
@@ -0,0 +1,237 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { spawnSync } from "node:child_process";
6
+ import { fileURLToPath } from "node:url";
7
+ import { readBoundedRegularFileSync } from "../src/local/secure-file.mjs";
8
+ import { replaceFileAtomicallySync } from "../src/local/exclusive-file.mjs";
9
+ import { readPrereleaseActivation } from "./prerelease-activation.mjs";
10
+ import { computePromotionContentDigest } from "./promotion-digest.mjs";
11
+ import {
12
+ SOAK_CONFIRMATION,
13
+ assertSoakEligiblePrerelease,
14
+ assertStablePromotion,
15
+ compareReleaseVersions,
16
+ formatSoakDuration,
17
+ minimumSoakSeconds,
18
+ parseReleaseVersion,
19
+ requiresSoakForStable,
20
+ } from "./release-channel.mjs";
21
+ import { verifyCurrentReleaseAcceptance } from "./release-acceptance.mjs";
22
+ import { readGithubPrerelease, readPublishedNpmPrerelease } from "./published-release.mjs";
23
+
24
+ export const SOAK_SCHEMA_VERSION = 1;
25
+ const MAX_SOAK_RECORD_BYTES = 64 * 1024;
26
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
27
+
28
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
29
+ const mode = process.argv[2] || "--verify";
30
+ try {
31
+ if (mode === "--record") recordCurrentPrereleaseSoak(root, { confirmation: argumentValue("--confirm"), stateRoot: argumentValue("--state-dir") || undefined });
32
+ else if (mode === "--verify") verifyCurrentStableSoak(root);
33
+ else if (mode === "--status") printStatus(root);
34
+ else throw new Error("usage: node scripts/release-soak.mjs [--record --confirm TEXT [--state-dir DIR]|--verify|--status]");
35
+ } catch (error) {
36
+ console.error(`release soak failed: ${error?.message || error}`);
37
+ process.exit(1);
38
+ }
39
+ }
40
+
41
+ export function soakRecordPath(repositoryRoot, stableVersion) {
42
+ const stable = parseReleaseVersion(stableVersion);
43
+ if (stable.prerelease) throw new Error("soak record path requires a stable version");
44
+ return join(repositoryRoot, "release-soak", `v${stable.raw}.json`);
45
+ }
46
+
47
+ export function recordCurrentPrereleaseSoak(repositoryRoot, options = {}) {
48
+ const pkg = readPackage(repositoryRoot);
49
+ const prerelease = assertSoakEligiblePrerelease(pkg.version);
50
+ const acceptance = verifyCurrentReleaseAcceptance(repositoryRoot);
51
+ if (!acceptance.required) throw new Error("prerelease package is missing local candidate acceptance policy");
52
+ const activation = readPrereleaseActivation(prerelease.raw, options.stateRoot);
53
+ if (activation.source !== "npm-prerelease") {
54
+ throw new Error("formal soak starts only after installing the published npm beta/rc; a local candidate activation is insufficient");
55
+ }
56
+ if (activation.integrity !== acceptance.metadata.integrity || activation.shasum !== acceptance.metadata.shasum) {
57
+ throw new Error("activated prerelease bytes do not match the locally accepted prerelease package");
58
+ }
59
+ const promotionDigest = computePromotionContentDigest(repositoryRoot);
60
+ if (activation.promotion_content_sha256 !== promotionDigest) {
61
+ throw new Error("activated prerelease promotion digest does not match the current source tree");
62
+ }
63
+ const previousStable = options.previousStableVersion || latestStableTag(repositoryRoot, prerelease.baseVersion);
64
+ const requiredSeconds = minimumSoakSeconds(prerelease.baseVersion, previousStable);
65
+ const activatedAt = Date.parse(activation.activated_at);
66
+ const acceptedAt = Number.isFinite(options.now) ? Number(options.now) : Date.now();
67
+ const observedSeconds = Math.floor((acceptedAt - activatedAt) / 1000);
68
+ if (observedSeconds < requiredSeconds) {
69
+ throw new Error(`prerelease soak is incomplete: observed ${formatSoakDuration(Math.max(0, observedSeconds))}, required ${formatSoakDuration(requiredSeconds)}`);
70
+ }
71
+ const expected = soakConfirmationPhrase(pkg.name, prerelease.raw, requiredSeconds);
72
+ if (options.confirmation !== expected) throw new Error(`soak confirmation must exactly match: ${expected}`);
73
+
74
+ const published = options.published || readPublishedNpmPrerelease(pkg.name, prerelease.raw, prerelease.npmTag);
75
+ if (
76
+ published.version !== prerelease.raw
77
+ || published.integrity !== acceptance.metadata.integrity
78
+ || published.shasum !== acceptance.metadata.shasum
79
+ || published.distTag !== prerelease.npmTag
80
+ || published.publishedAt !== activation.published_at
81
+ ) {
82
+ throw new Error("published npm prerelease metadata does not match the accepted and activated package");
83
+ }
84
+ const github = options.github || readGithubPrerelease(prerelease.raw);
85
+ if (github.tag !== `v${prerelease.raw}` || github.isPrerelease !== true) {
86
+ throw new Error("GitHub prerelease is missing or not marked as a prerelease");
87
+ }
88
+ const record = validateSoakRecord({
89
+ schema_version: SOAK_SCHEMA_VERSION,
90
+ result: "passed",
91
+ confirmation: SOAK_CONFIRMATION,
92
+ package_name: pkg.name,
93
+ stable_version: prerelease.baseVersion,
94
+ prerelease_version: prerelease.raw,
95
+ prerelease_channel: prerelease.channel,
96
+ prerelease_shasum: acceptance.metadata.shasum,
97
+ prerelease_integrity: acceptance.metadata.integrity,
98
+ promotion_content_sha256: promotionDigest,
99
+ activated_at: activation.activated_at,
100
+ published_at: published.publishedAt,
101
+ accepted_at: new Date(acceptedAt).toISOString(),
102
+ minimum_soak_seconds: requiredSeconds,
103
+ observed_soak_seconds: observedSeconds,
104
+ npm_dist_tag: prerelease.npmTag,
105
+ github_prerelease_tag: github.tag,
106
+ previous_stable_version: previousStable,
107
+ known_blocking_issues: 0,
108
+ });
109
+ const file = soakRecordPath(repositoryRoot, prerelease.baseVersion);
110
+ mkdirSync(dirname(file), { recursive: true });
111
+ replaceFileAtomicallySync(file, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o644 });
112
+ console.log(`Published prerelease soak accepted: ${file}`);
113
+ console.log("Stable promotion may change only synchronized version metadata; any functional package change requires a new prerelease and a new soak window.");
114
+ return record;
115
+ }
116
+
117
+ export function verifyCurrentStableSoak(repositoryRoot, options = {}) {
118
+ const pkg = readPackage(repositoryRoot);
119
+ if (!requiresSoakForStable(pkg.version)) return { required: false, version: pkg.version };
120
+ const file = soakRecordPath(repositoryRoot, pkg.version);
121
+ if (!existsSync(file)) throw new Error(`stable release soak record is missing: ${file}`);
122
+ let parsed;
123
+ try { parsed = JSON.parse(readBoundedRegularFileSync(file, MAX_SOAK_RECORD_BYTES, "release soak record", { verifyPathIdentity: true }).toString("utf8")); }
124
+ catch (error) { throw new Error(`release soak record is unavailable or invalid: ${error.message}`); }
125
+ const promotionDigest = computePromotionContentDigest(repositoryRoot, options);
126
+ const record = validateSoakRecord(parsed, { stableVersion: pkg.version, promotionDigest });
127
+ return { required: true, version: pkg.version, record, promotionDigest };
128
+ }
129
+
130
+ export function validateSoakRecord(value, options = {}) {
131
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("release soak record must be an object");
132
+ const allowed = new Set([
133
+ "schema_version", "result", "confirmation", "package_name", "stable_version", "prerelease_version",
134
+ "prerelease_channel", "prerelease_shasum", "prerelease_integrity", "promotion_content_sha256",
135
+ "activated_at", "published_at", "accepted_at", "minimum_soak_seconds", "observed_soak_seconds",
136
+ "npm_dist_tag", "github_prerelease_tag", "previous_stable_version", "known_blocking_issues",
137
+ ]);
138
+ const unknown = Object.keys(value).filter((key) => !allowed.has(key));
139
+ if (unknown.length) throw new Error(`release soak record contains unsupported fields: ${unknown.join(", ")}`);
140
+ if (value.schema_version !== SOAK_SCHEMA_VERSION) throw new Error("unsupported release soak schema");
141
+ if (value.result !== "passed" || value.confirmation !== SOAK_CONFIRMATION) throw new Error("release soak is not accepted");
142
+ if (value.package_name !== "machine-bridge-mcp") throw new Error("release soak package name is invalid");
143
+ const { stable, prerelease } = assertStablePromotion(value.stable_version, value.prerelease_version);
144
+ if (value.prerelease_channel !== prerelease.channel) throw new Error("release soak prerelease channel is inconsistent");
145
+ if (value.npm_dist_tag !== prerelease.npmTag) throw new Error("release soak npm dist-tag is inconsistent");
146
+ if (value.github_prerelease_tag !== `v${prerelease.raw}`) throw new Error("release soak GitHub prerelease tag is inconsistent");
147
+ if (!/^[0-9a-f]{40}$/.test(String(value.prerelease_shasum || ""))) throw new Error("release soak prerelease SHA-1 is invalid");
148
+ if (!/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(String(value.prerelease_integrity || ""))) throw new Error("release soak prerelease integrity is invalid");
149
+ if (!/^[0-9a-f]{64}$/.test(String(value.promotion_content_sha256 || ""))) throw new Error("release soak promotion digest is invalid");
150
+ const activatedAt = Date.parse(String(value.activated_at || ""));
151
+ const publishedAt = Date.parse(String(value.published_at || ""));
152
+ const acceptedAt = Date.parse(String(value.accepted_at || ""));
153
+ if (![activatedAt, publishedAt, acceptedAt].every(Number.isFinite)) throw new Error("release soak timestamps are invalid");
154
+ if (publishedAt > activatedAt + 5 * 60 * 1000 || activatedAt > acceptedAt) throw new Error("release soak timestamp ordering is invalid");
155
+ if (!Number.isSafeInteger(value.minimum_soak_seconds) || value.minimum_soak_seconds < 60) throw new Error("release soak minimum duration is invalid");
156
+ if (!Number.isSafeInteger(value.observed_soak_seconds) || value.observed_soak_seconds < value.minimum_soak_seconds) throw new Error("release soak observed duration is insufficient");
157
+ const elapsed = Math.floor((acceptedAt - activatedAt) / 1000);
158
+ if (value.observed_soak_seconds > elapsed + 5 || value.observed_soak_seconds < elapsed - 5) throw new Error("release soak observed duration does not match its timestamps");
159
+ if (value.known_blocking_issues !== 0) throw new Error("stable release is blocked by known prerelease issues");
160
+ const previous = parseReleaseVersion(value.previous_stable_version);
161
+ if (previous.prerelease || compareReleaseVersions(stable, previous) <= 0) throw new Error("release soak previous stable version is invalid");
162
+ const expectedMinimum = minimumSoakSeconds(stable.raw, previous.raw);
163
+ if (value.minimum_soak_seconds < expectedMinimum) throw new Error("release soak duration is below the active policy minimum");
164
+ if (options.stableVersion && stable.raw !== parseReleaseVersion(options.stableVersion).raw) throw new Error("release soak record targets another stable version");
165
+ if (options.promotionDigest && value.promotion_content_sha256 !== options.promotionDigest) {
166
+ throw new Error("stable package content differs from the accepted prerelease; publish a new prerelease and restart soak");
167
+ }
168
+ return Object.freeze({
169
+ schema_version: SOAK_SCHEMA_VERSION,
170
+ result: "passed",
171
+ confirmation: SOAK_CONFIRMATION,
172
+ package_name: "machine-bridge-mcp",
173
+ stable_version: stable.raw,
174
+ prerelease_version: prerelease.raw,
175
+ prerelease_channel: prerelease.channel,
176
+ prerelease_shasum: String(value.prerelease_shasum),
177
+ prerelease_integrity: String(value.prerelease_integrity),
178
+ promotion_content_sha256: String(value.promotion_content_sha256),
179
+ activated_at: new Date(activatedAt).toISOString(),
180
+ published_at: new Date(publishedAt).toISOString(),
181
+ accepted_at: new Date(acceptedAt).toISOString(),
182
+ minimum_soak_seconds: Number(value.minimum_soak_seconds),
183
+ observed_soak_seconds: Number(value.observed_soak_seconds),
184
+ npm_dist_tag: prerelease.npmTag,
185
+ github_prerelease_tag: `v${prerelease.raw}`,
186
+ previous_stable_version: previous.raw,
187
+ known_blocking_issues: 0,
188
+ });
189
+ }
190
+
191
+ export function soakConfirmationPhrase(name, prereleaseVersion, requiredSeconds) {
192
+ return `I SOAK-TESTED ${name} ${prereleaseVersion} FOR AT LEAST ${formatSoakDuration(requiredSeconds)} WITH NO BLOCKING ISSUES`;
193
+ }
194
+
195
+ function latestStableTag(repositoryRoot, targetBaseVersion) {
196
+ const target = parseReleaseVersion(targetBaseVersion);
197
+ const result = run("git", ["tag", "--list", "v*"], { cwd: repositoryRoot });
198
+ const versions = result.stdout.split(/\r?\n/).map((tag) => tag.trim().replace(/^v/, "")).filter(Boolean)
199
+ .map((value) => { try { return parseReleaseVersion(value); } catch { return null; } })
200
+ .filter((value) => value && !value.prerelease && compareReleaseVersions(value, target) < 0)
201
+ .sort(compareReleaseVersions);
202
+ const previous = versions.at(-1);
203
+ if (!previous) throw new Error(`could not determine the previous stable version before ${target.raw}`);
204
+ return previous.raw;
205
+ }
206
+
207
+ function printStatus(repositoryRoot) {
208
+ const pkg = readPackage(repositoryRoot);
209
+ const parsed = parseReleaseVersion(pkg.version);
210
+ if (parsed.prerelease) {
211
+ console.log(JSON.stringify({ version: parsed.raw, channel: parsed.channel, stable_target: parsed.baseVersion, soak_record: soakRecordPath(repositoryRoot, parsed.baseVersion) }, null, 2));
212
+ return;
213
+ }
214
+ const result = verifyCurrentStableSoak(repositoryRoot);
215
+ console.log(JSON.stringify(result, null, 2));
216
+ }
217
+
218
+ function readPackage(repositoryRoot) {
219
+ const value = JSON.parse(readFileSync(join(repositoryRoot, "package.json"), "utf8"));
220
+ if (value.name !== "machine-bridge-mcp") throw new Error("unexpected package name");
221
+ parseReleaseVersion(value.version);
222
+ return value;
223
+ }
224
+
225
+ function run(file, args, options = {}) {
226
+ const result = spawnSync(file, args, { cwd: options.cwd || root, encoding: "utf8", env: process.env, windowsHide: true });
227
+ if (result.error) throw result.error;
228
+ if (result.status !== 0) throw new Error(`${file} ${args[0] || ""} failed: ${String(result.stderr || result.stdout).trim()}`);
229
+ return result;
230
+ }
231
+
232
+ function argumentValue(name) {
233
+ const exact = process.argv.find((value) => value.startsWith(`${name}=`));
234
+ if (exact) return exact.slice(name.length + 1);
235
+ const index = process.argv.indexOf(name);
236
+ return index >= 0 ? process.argv[index + 1] : "";
237
+ }