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

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 +101 -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 +80 -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,167 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawnSync } from "node:child_process";
4
+ import { createHash } from "node:crypto";
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { dirname, join, resolve } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { defaultStateRoot, expandHome } from "../src/local/state.mjs";
9
+ import { writePrereleaseActivation } from "./prerelease-activation.mjs";
10
+ import { computePromotionContentDigest } from "./promotion-digest.mjs";
11
+ import { readPublishedNpmPrerelease } from "./published-release.mjs";
12
+ import { verifyCurrentReleaseAcceptance } from "./release-acceptance.mjs";
13
+ import { assertSoakEligiblePrerelease } from "./release-channel.mjs";
14
+
15
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
16
+ const npmCli = process.env.npm_execpath;
17
+ let previousInstallation = null;
18
+ let installedPrerelease = "";
19
+ if (!npmCli) fail("published prerelease installation must run through npm");
20
+
21
+ try {
22
+ if (!process.argv.includes("--allow-worker-deploy")) {
23
+ throw new Error("published prerelease activation updates the global package, same-name Worker, and login daemon; rerun with --allow-worker-deploy");
24
+ }
25
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
26
+ const requested = argumentValue("--version") || pkg.version;
27
+ const prerelease = assertSoakEligiblePrerelease(requested);
28
+ if (pkg.version !== prerelease.raw) {
29
+ throw new Error(`source checkout version ${pkg.version} does not match requested prerelease ${prerelease.raw}`);
30
+ }
31
+ const acceptance = verifyCurrentReleaseAcceptance(root);
32
+ if (!acceptance.required || acceptance.metadata.package_version !== prerelease.raw) {
33
+ throw new Error("published prerelease installation requires the locally accepted exact prerelease source");
34
+ }
35
+ const promotionDigest = computePromotionContentDigest(root);
36
+ if (acceptance.record.promotion_content_sha256 !== promotionDigest) {
37
+ throw new Error("current source promotion digest does not match local candidate acceptance");
38
+ }
39
+ const published = readPublishedNpmPrerelease(pkg.name, prerelease.raw, prerelease.npmTag);
40
+ if (published.integrity !== acceptance.metadata.integrity || published.shasum !== acceptance.metadata.shasum) {
41
+ throw new Error("npm prerelease bytes do not match the locally accepted candidate");
42
+ }
43
+
44
+ previousInstallation = currentGlobalInstallation(pkg.name);
45
+ runNpm([
46
+ "install", "--global", "--omit=optional",
47
+ "--allow-scripts=esbuild,workerd,sharp,fsevents",
48
+ `${pkg.name}@${prerelease.raw}`,
49
+ ]);
50
+ const installed = currentGlobalInstallation(pkg.name);
51
+ if (!installed || installed.version !== prerelease.raw) {
52
+ throw new Error(`global prerelease installation did not converge on ${prerelease.raw}`);
53
+ }
54
+ installedPrerelease = prerelease.raw;
55
+
56
+ const stateRoot = resolve(expandHome(argumentValue("--state-dir") || defaultStateRoot()));
57
+ const forwarded = forwardedActivationArgs();
58
+ const activation = runActivation(installed.entry, ["activate", ...forwarded, "--state-dir", stateRoot, "--json"]);
59
+ if (activation.version !== prerelease.raw || activation.daemon?.version !== prerelease.raw || activation.worker?.health?.version !== prerelease.raw) {
60
+ throw new Error("published prerelease activation did not converge on the exact registry version");
61
+ }
62
+ const recordPath = writePrereleaseActivation({
63
+ schema_version: 1,
64
+ package_name: pkg.name,
65
+ package_version: prerelease.raw,
66
+ source: "npm-prerelease",
67
+ shasum: published.shasum,
68
+ integrity: published.integrity,
69
+ promotion_content_sha256: promotionDigest,
70
+ activated_at: new Date().toISOString(),
71
+ published_at: published.publishedAt,
72
+ npm_dist_tag: prerelease.npmTag,
73
+ workspace_hash: workspaceHash(activation.workspace),
74
+ runtime_entry: installed.entry,
75
+ ...(previousInstallation ? { previous: previousInstallation } : {}),
76
+ }, stateRoot);
77
+
78
+ console.log(`Published prerelease activated: ${prerelease.raw}`);
79
+ console.log(`Worker and login daemon version: ${activation.version}`);
80
+ console.log(`Soak activation record: ${recordPath}`);
81
+ console.log("Use this prerelease normally. Any blocking issue requires a new beta/rc version and restarts the soak clock.");
82
+ } catch (error) {
83
+ const message = boundedDiagnostic(error?.message || error);
84
+ if (installedPrerelease) {
85
+ const previousVersion = previousInstallation?.version || "unknown";
86
+ fail(`${message}. The global package is now ${installedPrerelease}, but Worker/service activation may not have converged. Previous global version: ${previousVersion}. Preserve state and logs; fix forward with the exact prerelease or restore package, Worker, service definition, browser extension, and state as one verified unit.`);
87
+ }
88
+ fail(message);
89
+ }
90
+
91
+ function currentGlobalInstallation(packageName) {
92
+ try {
93
+ const globalRoot = runNpm(["root", "--global"]).stdout.trim();
94
+ const packageRoot = join(globalRoot, packageName);
95
+ const packagePath = join(packageRoot, "package.json");
96
+ const entry = join(packageRoot, "bin", "machine-mcp.mjs");
97
+ if (!existsSync(packagePath) || !existsSync(entry)) return null;
98
+ const value = JSON.parse(readFileSync(packagePath, "utf8"));
99
+ return { version: String(value.version || ""), entry };
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ function runActivation(entry, args) {
106
+ const result = spawnSync(process.execPath, [entry, ...args], {
107
+ cwd: root,
108
+ env: process.env,
109
+ encoding: "utf8",
110
+ timeout: 300_000,
111
+ windowsHide: true,
112
+ });
113
+ if (result.error) throw result.error;
114
+ if (result.status !== 0) throw new Error(`prerelease runtime activation failed: ${boundedDiagnostic(result.stderr || result.stdout)}`);
115
+ try { return JSON.parse(result.stdout); } catch { throw new Error("prerelease runtime activation did not return valid JSON"); }
116
+ }
117
+
118
+ function runNpm(args) {
119
+ const result = spawnSync(process.execPath, [npmCli, ...args], {
120
+ cwd: root,
121
+ env: process.env,
122
+ encoding: "utf8",
123
+ timeout: 300_000,
124
+ windowsHide: true,
125
+ });
126
+ if (result.error) throw result.error;
127
+ if (result.status !== 0) throw new Error(`npm ${args[0]} failed: ${boundedDiagnostic(result.stderr || result.stdout)}`);
128
+ return result;
129
+ }
130
+
131
+ function forwardedActivationArgs() {
132
+ const out = [];
133
+ const skipWithValue = new Set(["--version", "--state-dir"]);
134
+ for (let index = 2; index < process.argv.length; index += 1) {
135
+ const value = process.argv[index];
136
+ if (value === "--allow-worker-deploy") continue;
137
+ if (skipWithValue.has(value)) { index += 1; continue; }
138
+ if (["--version=", "--state-dir="].some((prefix) => value.startsWith(prefix))) continue;
139
+ if (value === "--json" || value.startsWith("--json=")) continue;
140
+ out.push(value);
141
+ }
142
+ return out;
143
+ }
144
+
145
+ function argumentValue(name) {
146
+ const exact = process.argv.find((value) => value.startsWith(`${name}=`));
147
+ if (exact) return exact.slice(name.length + 1);
148
+ const index = process.argv.indexOf(name);
149
+ return index >= 0 ? process.argv[index + 1] : "";
150
+ }
151
+
152
+ function workspaceHash(workspace) {
153
+ const value = process.platform === "win32" ? String(workspace).toLowerCase() : String(workspace);
154
+ return createHash("sha256").update(value).digest("hex").slice(0, 24);
155
+ }
156
+
157
+ function boundedDiagnostic(value) {
158
+ const home = String(process.env.HOME || process.env.USERPROFILE || "");
159
+ let text = String(value || "unknown error").replace(/[\r\n\t]+/g, " ").trim();
160
+ if (home) text = text.split(home).join("<home>");
161
+ return text.slice(0, 1600);
162
+ }
163
+
164
+ function fail(message) {
165
+ console.error(`published prerelease installation failed: ${message}`);
166
+ process.exit(1);
167
+ }
@@ -5,14 +5,13 @@ import {
5
5
  mkdtempSync,
6
6
  readFileSync,
7
7
  rmSync,
8
- writeFileSync,
9
8
  } from "node:fs";
10
9
  import { spawnSync } from "node:child_process";
11
10
  import { tmpdir } from "node:os";
12
11
  import { dirname, join, resolve } from "node:path";
13
12
  import { fileURLToPath } from "node:url";
14
13
  import {
15
- ACCEPTANCE_CONFIRMATION,
14
+ acceptanceConfirmationForVersion,
16
15
  ACCEPTANCE_SCHEMA_VERSION,
17
16
  acceptancePath,
18
17
  packProject,
@@ -21,6 +20,10 @@ import {
21
20
  verifyCurrentReleaseAcceptance,
22
21
  verifyTarball,
23
22
  } from "./release-acceptance.mjs";
23
+ import { computePromotionContentDigest } from "./promotion-digest.mjs";
24
+ import { parseReleaseVersion } from "./release-channel.mjs";
25
+ import { validateCandidateManifest } from "./release-candidate-manifest.mjs";
26
+ import { replaceFileAtomicallySync } from "../src/local/exclusive-file.mjs";
24
27
 
25
28
  const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
26
29
  const candidateDirectory = join(root, ".release-candidate");
@@ -48,14 +51,16 @@ function prepareCandidate() {
48
51
  schema_version: ACCEPTANCE_SCHEMA_VERSION,
49
52
  result: "pending",
50
53
  ...metadata,
54
+ promotion_content_sha256: computePromotionContentDigest(root),
51
55
  prepared_at: new Date().toISOString(),
52
56
  };
53
- writeFileSync(candidateManifestPath, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
57
+ replaceFileAtomicallySync(candidateManifestPath, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
54
58
  const phrase = confirmationPhrase(pkg.name, pkg.version);
55
59
  console.log(`Release candidate created: ${join(candidateDirectory, metadata.filename)}`);
56
- console.log("After the repository owner explicitly authorizes this exact candidate in the active conversation, the coding agent starts it through Machine Bridge with:");
57
- console.log("npm run release:candidate:start -- --allow-worker-deploy");
58
- console.log("The owner does not need to run a terminal authorization command. The coding agent keeps the candidate running while verifying connection readiness and representative functionality through Machine Bridge.");
60
+ console.log("The repository owner activates this exact candidate with one persistent owner-side command:");
61
+ parseReleaseVersion(pkg.version);
62
+ console.log("npm run release:candidate:activate -- --allow-worker-deploy");
63
+ console.log("The owner runs this one command. It installs the exact candidate in the private state root, updates the same-name Worker, verifies candidate relay readiness, replaces the login daemon, verifies the background handoff, and exits while the service remains active.");
59
64
  console.log("After that observed live verification succeeds, the coding agent records acceptance with:");
60
65
  console.log(`npm run release:accept -- --confirm \"${phrase}\"`);
61
66
  console.log("Automated tests alone do not authorize acceptance or the first GitHub push.");
@@ -68,10 +73,10 @@ function recordAcceptance() {
68
73
  if (supplied !== expected) {
69
74
  throw new Error(`interactive candidate verification confirmation must exactly match: ${expected}`);
70
75
  }
71
- const pending = readJson(candidateManifestPath, "release candidate manifest");
72
- if (pending.result !== "pending" || pending.package_name !== pkg.name || pending.package_version !== pkg.version) {
73
- throw new Error("release candidate manifest does not match the current package");
74
- }
76
+ const pending = validateCandidateManifest(readJson(candidateManifestPath, "release candidate manifest"), {
77
+ packageName: pkg.name,
78
+ packageVersion: pkg.version,
79
+ });
75
80
  verifyTarball(join(candidateDirectory, pending.filename), pending);
76
81
 
77
82
  const verificationDirectory = join(candidateDirectory, "verification");
@@ -84,10 +89,15 @@ function recordAcceptance() {
84
89
  }
85
90
  }
86
91
 
92
+ const promotionDigest = computePromotionContentDigest(root);
93
+ if (pending.promotion_content_sha256 !== promotionDigest) {
94
+ throw new Error("source changed after candidate preparation: promotion content digest no longer matches");
95
+ }
96
+
87
97
  const record = {
88
98
  schema_version: ACCEPTANCE_SCHEMA_VERSION,
89
99
  result: "passed",
90
- confirmation: ACCEPTANCE_CONFIRMATION,
100
+ confirmation: acceptanceConfirmationForVersion(pkg.version),
91
101
  package_name: current.package_name,
92
102
  package_version: current.package_version,
93
103
  filename: current.filename,
@@ -95,11 +105,12 @@ function recordAcceptance() {
95
105
  integrity: current.integrity,
96
106
  accepted_at: new Date().toISOString(),
97
107
  package_content_sha256: computePortablePackageDigest(),
108
+ promotion_content_sha256: promotionDigest,
98
109
  };
99
110
  verifyAcceptanceRecord(record, current);
100
111
  const path = acceptancePath(root, pkg.version);
101
112
  mkdirSync(dirname(path), { recursive: true });
102
- writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`, "utf8");
113
+ replaceFileAtomicallySync(path, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o644 });
103
114
  console.log(`Interactive local candidate acceptance recorded: ${path}`);
104
115
  console.log("Commit this record with the candidate. Any packaged-file change invalidates it.");
105
116
  }
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFileSync } from "node:fs";
4
+ import { resolve } from "node:path";
5
+ import { assertPublishTag, parseReleaseVersion } from "./release-channel.mjs";
6
+
7
+ try {
8
+ const pkg = JSON.parse(readFileSync(resolve("package.json"), "utf8"));
9
+ const parsed = parseReleaseVersion(pkg.version);
10
+ const configured = String(process.env.npm_config_tag || "latest").trim().toLowerCase() || "latest";
11
+ assertPublishTag(parsed.raw, configured);
12
+ console.log(`npm publication channel verified: ${parsed.raw} -> ${parsed.npmTag}`);
13
+ } catch (error) {
14
+ console.error(`npm publication policy failed: ${error?.message || error}`);
15
+ process.exit(1);
16
+ }
@@ -0,0 +1,79 @@
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, resolve } from "node:path";
3
+ import { defaultStateRoot, expandHome } from "../src/local/state.mjs";
4
+ import { replaceFileAtomicallySync } from "../src/local/exclusive-file.mjs";
5
+ import { ensureOwnerOnlyDirectorySync, readBoundedRegularFileSync } from "../src/local/secure-file.mjs";
6
+ import { assertSoakEligiblePrerelease } from "./release-channel.mjs";
7
+
8
+ export const ACTIVATION_SCHEMA_VERSION = 1;
9
+ const MAX_ACTIVATION_BYTES = 64 * 1024;
10
+ const SOURCES = new Set(["local-candidate", "npm-prerelease"]);
11
+
12
+ export function prereleaseActivationPath(version, stateRoot = defaultStateRoot()) {
13
+ const parsed = assertSoakEligiblePrerelease(version);
14
+ return join(resolve(expandHome(stateRoot)), "release-channels", "activations", `v${parsed.raw}.json`);
15
+ }
16
+
17
+ export function writePrereleaseActivation(record, stateRoot = defaultStateRoot()) {
18
+ const normalized = validatePrereleaseActivation(record);
19
+ const file = prereleaseActivationPath(normalized.package_version, stateRoot);
20
+ ensureOwnerOnlyDirectorySync(dirname(file));
21
+ replaceFileAtomicallySync(file, `${JSON.stringify(normalized, null, 2)}\n`, { mode: 0o600 });
22
+ return file;
23
+ }
24
+
25
+ export function readPrereleaseActivation(version, stateRoot = defaultStateRoot()) {
26
+ const file = prereleaseActivationPath(version, stateRoot);
27
+ if (!existsSync(file)) throw new Error(`prerelease activation record is missing: ${file}`);
28
+ let value;
29
+ try { value = JSON.parse(readBoundedRegularFileSync(file, MAX_ACTIVATION_BYTES, "prerelease activation record", { verifyPathIdentity: true }).toString("utf8")); }
30
+ catch (error) { throw new Error(`prerelease activation record is unavailable or invalid: ${error.message}`); }
31
+ return validatePrereleaseActivation(value);
32
+ }
33
+
34
+ export function validatePrereleaseActivation(value) {
35
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("prerelease activation record must be an object");
36
+ if (value.schema_version !== ACTIVATION_SCHEMA_VERSION) throw new Error("unsupported prerelease activation schema");
37
+ const parsed = assertSoakEligiblePrerelease(value.package_version);
38
+ if (value.package_name !== "machine-bridge-mcp") throw new Error("prerelease activation package name is invalid");
39
+ if (!SOURCES.has(value.source)) throw new Error("prerelease activation source is invalid");
40
+ if (!/^[0-9a-f]{40}$/.test(String(value.shasum || ""))) throw new Error("prerelease activation SHA-1 is invalid");
41
+ if (!/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(String(value.integrity || ""))) throw new Error("prerelease activation integrity is invalid");
42
+ if (!/^[0-9a-f]{64}$/.test(String(value.promotion_content_sha256 || ""))) throw new Error("prerelease activation promotion digest is invalid");
43
+ const activatedAt = Date.parse(String(value.activated_at || ""));
44
+ if (!Number.isFinite(activatedAt)) throw new Error("prerelease activation timestamp is invalid");
45
+ if (value.source === "npm-prerelease") {
46
+ if (value.npm_dist_tag !== parsed.npmTag) throw new Error("prerelease activation npm dist-tag is invalid");
47
+ const publishedAt = Date.parse(String(value.published_at || ""));
48
+ if (!Number.isFinite(publishedAt) || publishedAt > activatedAt + 5 * 60 * 1000) throw new Error("prerelease activation publication timestamp is invalid");
49
+ }
50
+ const workspaceHash = String(value.workspace_hash || "");
51
+ if (workspaceHash && !/^[0-9a-f]{24}$/.test(workspaceHash)) throw new Error("prerelease activation workspace hash is invalid");
52
+ const runtimeEntry = String(value.runtime_entry || "");
53
+ if (runtimeEntry && !isAbsolute(runtimeEntry)) throw new Error("prerelease activation runtime entry must be absolute");
54
+ let previous;
55
+ if (value.previous !== undefined) {
56
+ if (!value.previous || typeof value.previous !== "object" || Array.isArray(value.previous)) throw new Error("prerelease activation previous runtime is invalid");
57
+ const previousVersion = String(value.previous.version || "");
58
+ const previousEntry = String(value.previous.entry || "");
59
+ if (!previousVersion || !previousEntry || !isAbsolute(previousEntry)) throw new Error("prerelease activation previous runtime is invalid");
60
+ previous = { version: previousVersion, entry: previousEntry };
61
+ }
62
+ return Object.freeze({
63
+ schema_version: ACTIVATION_SCHEMA_VERSION,
64
+ package_name: value.package_name,
65
+ package_version: parsed.raw,
66
+ source: value.source,
67
+ shasum: value.shasum,
68
+ integrity: value.integrity,
69
+ promotion_content_sha256: value.promotion_content_sha256,
70
+ activated_at: new Date(activatedAt).toISOString(),
71
+ ...(value.source === "npm-prerelease" ? {
72
+ npm_dist_tag: value.npm_dist_tag,
73
+ published_at: new Date(Date.parse(value.published_at)).toISOString(),
74
+ } : {}),
75
+ ...(workspaceHash ? { workspace_hash: workspaceHash } : {}),
76
+ ...(runtimeEntry ? { runtime_entry: runtimeEntry } : {}),
77
+ ...(previous ? { previous } : {}),
78
+ });
79
+ }
@@ -3,9 +3,11 @@ import { existsSync, readFileSync, readdirSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { readBoundedRegularFileSync } from "../src/local/secure-file.mjs";
6
+ import { createTrustedGitResolver } from "../src/local/trusted-git-executable.mjs";
6
7
 
7
8
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
8
9
  const selfPath = "scripts/privacy-check.mjs";
10
+ const gitExecutable = createTrustedGitResolver({ workspace: root });
9
11
  const requestedOptions = new Set(process.argv.slice(2));
10
12
  for (const option of requestedOptions) {
11
13
  if (option !== "--history") throw new Error(`unknown privacy-check option: ${option}`);
@@ -68,7 +70,7 @@ process.stderr.write(`privacy check ok (${candidates.length} tracked/unignored f
68
70
 
69
71
  function collectCandidateFiles(directory) {
70
72
  try {
71
- return execFileSync("git", ["-C", directory, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
73
+ return execFileSync(gitExecutable(), ["-C", directory, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
72
74
  encoding: "buffer",
73
75
  maxBuffer: 32 * 1024 * 1024,
74
76
  stdio: ["ignore", "pipe", "ignore"],
@@ -98,7 +100,7 @@ function collectCandidateFiles(directory) {
98
100
  function scanReachableHistory(directory, entries, out) {
99
101
  let listing;
100
102
  try {
101
- listing = execFileSync("git", ["-C", directory, "rev-list", "--objects", "--all", "-z"], {
103
+ listing = execFileSync(gitExecutable(), ["-C", directory, "rev-list", "--objects", "--all", "-z"], {
102
104
  encoding: "buffer",
103
105
  maxBuffer: 64 * 1024 * 1024,
104
106
  stdio: ["ignore", "pipe", "pipe"],
@@ -130,7 +132,7 @@ function scanReachableHistory(directory, entries, out) {
130
132
  const hashes = [...objectPaths.keys()];
131
133
  let metadata;
132
134
  try {
133
- metadata = execFileSync("git", ["-C", directory, "cat-file", "--batch-check=%(objectname) %(objecttype) %(objectsize)"], {
135
+ metadata = execFileSync(gitExecutable(), ["-C", directory, "cat-file", "--batch-check=%(objectname) %(objecttype) %(objectsize)"], {
134
136
  input: `${hashes.join("\n")}\n`,
135
137
  encoding: "utf8",
136
138
  maxBuffer: 64 * 1024 * 1024,
@@ -162,7 +164,7 @@ function scanReachableHistory(directory, entries, out) {
162
164
  }
163
165
  let buffer;
164
166
  try {
165
- buffer = execFileSync("git", ["-C", directory, "cat-file", "blob", hash], {
167
+ buffer = execFileSync(gitExecutable(), ["-C", directory, "cat-file", "blob", hash], {
166
168
  encoding: "buffer",
167
169
  maxBuffer: 6 * 1024 * 1024,
168
170
  stdio: ["ignore", "pipe", "pipe"],
@@ -194,7 +196,7 @@ function scanReachableHistory(directory, entries, out) {
194
196
  function scanReachableCommitMessages(directory, entries, out) {
195
197
  let output;
196
198
  try {
197
- output = execFileSync("git", ["-C", directory, "log", "--all", "--format=%H%x00%B%x00"], {
199
+ output = execFileSync(gitExecutable(), ["-C", directory, "log", "--all", "--format=%H%x00%B%x00"], {
198
200
  encoding: "buffer",
199
201
  maxBuffer: 64 * 1024 * 1024,
200
202
  stdio: ["ignore", "pipe", "pipe"],
@@ -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
+ }