machine-bridge-mcp 0.11.1 → 0.12.0
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 +26 -0
- package/CONTRIBUTING.md +2 -2
- package/README.md +5 -5
- package/SECURITY.md +15 -5
- package/browser-extension/manifest.json +2 -2
- package/docs/ARCHITECTURE.md +14 -8
- package/docs/AUDIT.md +110 -0
- package/docs/ENGINEERING.md +14 -4
- package/docs/LOCAL_AUTOMATION.md +3 -3
- package/docs/LOGGING.md +5 -3
- package/docs/MANAGED_JOBS.md +5 -1
- package/docs/OPERATIONS.md +12 -4
- package/docs/PRIVACY.md +5 -3
- package/docs/RELEASING.md +1 -1
- package/docs/TESTING.md +11 -8
- package/package.json +7 -8
- package/scripts/github-release.mjs +368 -0
- package/scripts/privacy-check.mjs +66 -5
- package/scripts/release-state.mjs +10 -0
- package/scripts/syntax-check.mjs +52 -0
- package/src/local/app-automation.mjs +3 -1
- package/src/local/browser-bridge.mjs +94 -53
- package/src/local/cli.mjs +165 -81
- package/src/local/daemon-process.mjs +3 -67
- package/src/local/exclusive-file.mjs +94 -0
- package/src/local/job-runner.mjs +33 -17
- package/src/local/log.mjs +16 -1
- package/src/local/managed-jobs.mjs +187 -56
- package/src/local/process-identity.mjs +143 -0
- package/src/local/resource-operations.mjs +2 -3
- package/src/local/runtime.mjs +4 -2
- package/src/local/service-lifecycle.mjs +56 -0
- package/src/local/service.mjs +53 -25
- package/src/local/shell.mjs +23 -4
- package/src/local/state.mjs +228 -66
- package/src/worker/index.ts +1 -1
package/docs/TESTING.md
CHANGED
|
@@ -15,10 +15,12 @@ The suite includes:
|
|
|
15
15
|
- release-impact enforcement requiring a new package version and CHANGELOG section for release-relevant changes;
|
|
16
16
|
- release-state diagnostics distinguishing missing local/remote version tags from tags that point to the wrong commit, including the required publication order;
|
|
17
17
|
- generated Cloudflare Worker types and strict TypeScript checking, including unused-local and unused-parameter rejection;
|
|
18
|
-
- syntax validation for every shipped
|
|
18
|
+
- recursive syntax validation for every JavaScript file under the shipped/runtime/test roots plus the shell wrapper;
|
|
19
19
|
- shared tool-catalog schema, annotation, and profile-inventory checks;
|
|
20
20
|
- default working-agreement injection without user files, bounded automatic project metadata, script-body non-disclosure, user-global opt-out, repository opt-out rejection, global `model_instructions_file` injection in stdio/remote initialization, hierarchical precedence, live project/skill rescanning and fingerprints, automatic task ranking/loading, command override/removal, direct argv handling, timeout ceilings, and execution-profile denial;
|
|
21
|
-
-
|
|
21
|
+
- concurrent complete-before-visible lock claims, atomic replacement under active readers, malformed-lock grace, snapshot/token-safe reclamation, absolute-age expiry, PID-reuse detection, and bounded startup-lock waiting;
|
|
22
|
+
- foreground takeover of active and orphaned background daemons, legacy lock identification from canonicalized process arguments, foreground-process protection, actual-PID exit waiting, non-forcing timeout guidance, daemon lock mode/version/process-start metadata, launchd service-target semantics, and silent idempotent duplicate service starts;
|
|
23
|
+
- fail-closed service lifecycle ordering for provider-stop, all-workspace daemon-stop, and definition removal, including platform/daemon/removal failure injection and normalized macOS/systemd/Windows results;
|
|
22
24
|
- machine-level browser-broker ownership/client proxying, authenticated extension origin/subprotocol, non-cacheable local pairing, pairing-token non-disclosure, resource-backed upload routing, and broker result redaction;
|
|
23
25
|
- canonical path and symbolic-link escape tests;
|
|
24
26
|
- relative-path privacy and error-path redaction tests;
|
|
@@ -29,16 +31,16 @@ The suite includes:
|
|
|
29
31
|
- nested Git repository detection and helper suppression;
|
|
30
32
|
- author-email privacy in `git_log`;
|
|
31
33
|
- isolated command HOME/temp/cache behavior;
|
|
32
|
-
- one-shot timeout, descendant termination, cancellation, and process-session interaction;
|
|
34
|
+
- one-shot timeout, descendant process-group/tree termination including descendants that ignore graceful shutdown, cancellation, and process-session interaction;
|
|
33
35
|
- layered fixed runtime diagnostics for filesystem, direct process, shell, managed-job storage, and resource availability;
|
|
34
36
|
- local resource CLI registration, permission checks, dynamic reload, state-path redaction, and content non-disclosure;
|
|
35
37
|
- real Ed25519 and RSA generation, idempotent reuse, public/private correspondence, mode enforcement, incomplete/mismatched/symlink rejection, and private-content non-disclosure;
|
|
36
38
|
- real-machine canonical-full sandbox acceptance for outside-workspace I/O, direct/shell execution, full environment inheritance, SSH prerequisites, temporary authorized-key writing, and detached cleanup without external state changes;
|
|
37
39
|
- deterministic injected atomic-replace failures and repeated Windows full-sandbox runs to catch transient file-sharing races;
|
|
38
40
|
- canonical named-profile repair and full-only tool exposure parity between local and Worker policy filters;
|
|
39
|
-
- managed-job staging/local approval/cancel-before-start, detachment, job-scoped temporary files, resource hash verification/redaction, discard capture, finally execution,
|
|
40
|
-
- daemon/startup locking and
|
|
41
|
-
- guarded state-root removal, schema migration, policy-origin persistence, and legacy implicit-default migration;
|
|
41
|
+
- managed-job staging/local approval/cancel-before-start, detachment, job-scoped temporary files, resource hash verification/redaction, discard capture, finally execution, descendant-tree escalation, token/snapshot-safe transition locks, runner process identity, plan scrubbing, PID-reuse-safe dead-runner recovery, and legacy PID-file compatibility;
|
|
42
|
+
- daemon/startup locking, successfully-read corrupt-JSON recovery, and explicit propagation/preservation of oversized, symbolic-link, permission, and I/O failures;
|
|
43
|
+
- guarded state-root removal, unsafe state-root/workspace overlap rejection before creation, all-profile lock/daemon scanning, schema migration, policy-origin persistence, and legacy implicit-default migration;
|
|
42
44
|
- no filename-based sensitive-file denial under unrestricted policy;
|
|
43
45
|
- log redaction, control-character handling, message/field bounds, suppression of both successful and failed per-tool events outside debug, service warning-level configuration, and idempotent bounded migration of legacy log formats;
|
|
44
46
|
- deterministic relay connection lifecycle coverage for transport construction/error/deadline, pre-handshake `welcome` validation, authenticated `hello_ack` readiness, identity/version mismatch, retryable Worker handshake errors, fatal protocol errors, autonomous outage-reminder backoff, handshake and heartbeat timeout, brief-outage suppression, sustained-outage escalation, recovery summaries, and supersession;
|
|
@@ -65,6 +67,7 @@ GitHub Actions executes the main suite on Linux, macOS, and Windows using the pi
|
|
|
65
67
|
## Test design rules
|
|
66
68
|
|
|
67
69
|
- Tests should exercise the public boundary rather than only helper functions when practical.
|
|
70
|
+
- Lock, service lifecycle, state deletion, detached process, and credential changes require behavior-level concurrent or fault-injection coverage; source-string assertions are supplementary only.
|
|
68
71
|
- Every permission-expanding feature needs a denial test. Browser/application features also require a token/value/content non-disclosure assertion.
|
|
69
72
|
- Every bounded resource needs an over-limit test.
|
|
70
73
|
- Every multi-stage mutation needs a no-partial-commit test.
|
|
@@ -80,10 +83,10 @@ Run `npm run privacy:check` before committing and before packaging. Developers s
|
|
|
80
83
|
|
|
81
84
|
## Package manifest
|
|
82
85
|
|
|
83
|
-
`npm run package:test` executes a real silent `npm pack --dry-run --json`, requires clean parseable JSON, rejects sensitive local artifacts and credential-like file classes, and verifies that privacy/engineering guidance,
|
|
86
|
+
`npm run package:test` executes a real silent `npm pack --dry-run --json`, requires clean parseable JSON, rejects sensitive local artifacts and credential-like file classes, validates every packaged mode as `0644` or `0755`, and verifies that privacy/engineering guidance, runtime/relay/secure-file/lock/service/browser/app modules, all package-script helpers, the packaged browser extension, contribution discipline, and privacy/release-impact checkers are present. `npm run install:test` packs the real tarball, installs it into an isolated global prefix with the documented npm 12 options, rejects blocked-script warnings, confirms optional `fsevents` is absent, and runs the installed CLI.
|
|
84
87
|
|
|
85
88
|
The stdio integration test also sends an oversized line, verifies bounded rejection, and confirms that the next valid request is still processed.
|
|
86
89
|
|
|
87
90
|
## Architecture and documentation regression checks
|
|
88
91
|
|
|
89
|
-
`npm run architecture:test` rejects local-module dependency cycles, missing relative imports, obsolete `LocalDaemon`/`daemon.mjs` naming, broken relative Markdown links, invisible ASCII control bytes in repository text, removal of the owner-required default-`full` engineering invariant, and accidental publication of `.project-local/` notes.
|
|
92
|
+
`npm run architecture:test` rejects local-module dependency cycles, missing static or dynamic relative imports, package scripts that reference missing files, drift from the recursive syntax scanner, incomplete executable package directories, inconsistent installation guidance, obsolete `LocalDaemon`/`daemon.mjs` naming, broken relative Markdown links, invisible ASCII control bytes in repository text, removal of the owner-required default-`full` engineering invariant, and accidental publication of `.project-local/` notes.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "machine-bridge-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Cross-client MCP bridge for local agent context, structured browser and application automation, files, Git, processes, resources, and durable jobs over stdio or OAuth relay.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -20,10 +20,7 @@
|
|
|
20
20
|
"src/local",
|
|
21
21
|
"src/shared",
|
|
22
22
|
"src/worker/index.ts",
|
|
23
|
-
"scripts
|
|
24
|
-
"scripts/privacy-check.mjs",
|
|
25
|
-
"scripts/release-impact-check.mjs",
|
|
26
|
-
"scripts/network-retry.mjs",
|
|
23
|
+
"scripts",
|
|
27
24
|
"wrangler.jsonc",
|
|
28
25
|
"tsconfig.json",
|
|
29
26
|
"README.md",
|
|
@@ -44,8 +41,8 @@
|
|
|
44
41
|
"prepublishOnly": "npm run check && npm run version:check && npm run release:check",
|
|
45
42
|
"worker:types": "wrangler types src/worker/worker-configuration.d.ts",
|
|
46
43
|
"typecheck": "npm run worker:types && tsc -p tsconfig.json --noEmit",
|
|
47
|
-
"syntax": "
|
|
48
|
-
"check": "npm run privacy:check && npm run privacy:test && npm run release-impact:check && npm run release-impact:test && npm run release-state:test && npm run network-retry:test && npm run relay:test && npm run secure-file:test && npm run architecture:test && npm run typecheck && npm run syntax && npm run catalog:test && npm run agent-context:test && npm run browser-bridge:test && npm run app-automation:test && npm run self-test && npm run atomic-fs:test && npm run package:test && npm run ssh-key:test && npm run full-access:test && npm run managed-jobs:test && npm run stdio:integration-test && npm run worker:integration-test",
|
|
44
|
+
"syntax": "node scripts/syntax-check.mjs",
|
|
45
|
+
"check": "npm run privacy:check && npm run privacy:test && npm run release-impact:check && npm run release-impact:test && npm run release-state:test && npm run network-retry:test && npm run relay:test && npm run secure-file:test && npm run architecture:test && npm run typecheck && npm run syntax && npm run process-lock:test && npm run service-lifecycle:test && npm run catalog:test && npm run agent-context:test && npm run browser-bridge:test && npm run app-automation:test && npm run self-test && npm run atomic-fs:test && npm run package:test && npm run ssh-key:test && npm run full-access:test && npm run managed-jobs:test && npm run stdio:integration-test && npm run worker:integration-test",
|
|
49
46
|
"worker:dry-run": "wrangler deploy --dry-run",
|
|
50
47
|
"worker:integration-test": "node tests/worker-integration-test.mjs",
|
|
51
48
|
"release:check": "node scripts/github-release.mjs --check",
|
|
@@ -70,7 +67,9 @@
|
|
|
70
67
|
"release-state:test": "node tests/release-state-test.mjs",
|
|
71
68
|
"agent-context:test": "node tests/agent-context-test.mjs",
|
|
72
69
|
"browser-bridge:test": "node tests/browser-bridge-test.mjs",
|
|
73
|
-
"app-automation:test": "node tests/app-automation-test.mjs"
|
|
70
|
+
"app-automation:test": "node tests/app-automation-test.mjs",
|
|
71
|
+
"process-lock:test": "node tests/process-lock-test.mjs",
|
|
72
|
+
"service-lifecycle:test": "node tests/service-lifecycle-test.mjs"
|
|
74
73
|
},
|
|
75
74
|
"dependencies": {
|
|
76
75
|
"wrangler": "4.110.0",
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
mkdtempSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
rmSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { dirname, join, resolve } from "node:path";
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
import { runNetworkCommand } from "./network-retry.mjs";
|
|
13
|
+
import { tagSyncError } from "./release-state.mjs";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
|
|
16
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
17
|
+
process.chdir(root);
|
|
18
|
+
|
|
19
|
+
function fail(message) {
|
|
20
|
+
console.error(`release error: ${message}`);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function run(command, args, options = {}) {
|
|
25
|
+
const result = spawnSync(command, args, {
|
|
26
|
+
cwd: root,
|
|
27
|
+
encoding: "utf8",
|
|
28
|
+
stdio: options.capture ? "pipe" : "inherit",
|
|
29
|
+
env: process.env,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
if (result.error) {
|
|
33
|
+
fail(`${command} could not be started: ${result.error.message}`);
|
|
34
|
+
}
|
|
35
|
+
if (result.status !== 0 && !options.allowFailure) {
|
|
36
|
+
const detail = options.capture
|
|
37
|
+
? `${result.stdout ?? ""}${result.stderr ?? ""}`.trim()
|
|
38
|
+
: "";
|
|
39
|
+
fail(`${command} ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
|
|
40
|
+
}
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
function runNetwork(command, args, options = {}) {
|
|
46
|
+
const result = runNetworkCommand(command, args, { cwd: root, env: process.env });
|
|
47
|
+
if (!options.capture) {
|
|
48
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
49
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
50
|
+
}
|
|
51
|
+
if (result.error && !options.allowFailure) {
|
|
52
|
+
fail(`${command} could not be started: ${result.error.message}`);
|
|
53
|
+
}
|
|
54
|
+
if (result.status !== 0 && !options.allowFailure) failCommandResult(command, args, result);
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function outputNetwork(command, args, options = {}) {
|
|
59
|
+
const result = runNetwork(command, args, { ...options, capture: true });
|
|
60
|
+
return (result.stdout ?? "").trim();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function failCommandResult(command, args, result) {
|
|
64
|
+
const detail = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
|
|
65
|
+
fail(`${command} ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function output(command, args, options = {}) {
|
|
69
|
+
const result = run(command, args, { ...options, capture: true });
|
|
70
|
+
return (result.stdout ?? "").trim();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function packageMetadata() {
|
|
74
|
+
const data = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
75
|
+
if (typeof data.name !== "string" || typeof data.version !== "string") {
|
|
76
|
+
fail("package.json must contain string name and version fields");
|
|
77
|
+
}
|
|
78
|
+
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(data.version)) {
|
|
79
|
+
fail(`unsupported package version: ${data.version}`);
|
|
80
|
+
}
|
|
81
|
+
return data;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function changelogBody(version) {
|
|
85
|
+
const changelog = readFileSync(join(root, "CHANGELOG.md"), "utf8");
|
|
86
|
+
const heading = new RegExp(`^## ${version.replaceAll(".", "\\.")}(?:\\s+-[^\\n]*)?$`, "m");
|
|
87
|
+
const match = heading.exec(changelog);
|
|
88
|
+
if (!match) return null;
|
|
89
|
+
const start = match.index + match[0].length;
|
|
90
|
+
const next = changelog.slice(start).search(/^## /m);
|
|
91
|
+
const body = changelog.slice(start, next < 0 ? undefined : start + next).trim();
|
|
92
|
+
return body || null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function ensureClean() {
|
|
96
|
+
const status = output("git", ["status", "--porcelain"]);
|
|
97
|
+
if (status) fail(`working tree is not clean:\n${status}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function fetchRemote() {
|
|
101
|
+
runNetwork("git", ["fetch", "origin", "main", "--tags", "--prune"]);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function localTagCommit(tag) {
|
|
105
|
+
const result = run("git", ["rev-list", "-n", "1", tag], {
|
|
106
|
+
capture: true,
|
|
107
|
+
allowFailure: true,
|
|
108
|
+
});
|
|
109
|
+
return result.status === 0 ? result.stdout.trim() : null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function remoteTagCommit(tag) {
|
|
113
|
+
const text = outputNetwork("git", [
|
|
114
|
+
"ls-remote",
|
|
115
|
+
"--tags",
|
|
116
|
+
"origin",
|
|
117
|
+
`refs/tags/${tag}`,
|
|
118
|
+
`refs/tags/${tag}^{}`,
|
|
119
|
+
]);
|
|
120
|
+
if (!text) return null;
|
|
121
|
+
const rows = text.split("\n").map((line) => line.trim().split(/\s+/));
|
|
122
|
+
const peeled = rows.find(([, ref]) => ref === `refs/tags/${tag}^{}`);
|
|
123
|
+
const direct = rows.find(([, ref]) => ref === `refs/tags/${tag}`);
|
|
124
|
+
return (peeled ?? direct)?.[0] ?? null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function releaseInfo(tag) {
|
|
128
|
+
const result = runNetwork(
|
|
129
|
+
"gh",
|
|
130
|
+
[
|
|
131
|
+
"release",
|
|
132
|
+
"view",
|
|
133
|
+
tag,
|
|
134
|
+
"--json",
|
|
135
|
+
"tagName,name,targetCommitish,isDraft,isPrerelease,assets,url",
|
|
136
|
+
],
|
|
137
|
+
{ capture: true, allowFailure: true },
|
|
138
|
+
);
|
|
139
|
+
if (result.status !== 0) return null;
|
|
140
|
+
return JSON.parse(result.stdout);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function assertCoreSync({ requireReleaseAsset }) {
|
|
144
|
+
const pkg = packageMetadata();
|
|
145
|
+
const tag = `v${pkg.version}`;
|
|
146
|
+
const head = output("git", ["rev-parse", "HEAD"]);
|
|
147
|
+
const originMain = output("git", ["rev-parse", "origin/main"]);
|
|
148
|
+
if (head !== originMain) {
|
|
149
|
+
fail(`HEAD ${head} does not match origin/main ${originMain}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const localCommit = localTagCommit(tag);
|
|
153
|
+
const localTagError = tagSyncError({ scope: "local", tag, head, commit: localCommit });
|
|
154
|
+
if (localTagError) fail(localTagError);
|
|
155
|
+
|
|
156
|
+
const remoteCommit = remoteTagCommit(tag);
|
|
157
|
+
const remoteTagError = tagSyncError({ scope: "remote", tag, head, commit: remoteCommit });
|
|
158
|
+
if (remoteTagError) fail(remoteTagError);
|
|
159
|
+
|
|
160
|
+
const release = releaseInfo(tag);
|
|
161
|
+
if (!release || release.isDraft || release.isPrerelease) {
|
|
162
|
+
fail(`published GitHub Release ${tag} is missing or not final`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (requireReleaseAsset) {
|
|
166
|
+
const expectedAsset = `${pkg.name}-${pkg.version}.tgz`;
|
|
167
|
+
const names = new Set((release.assets ?? []).map((asset) => asset.name));
|
|
168
|
+
if (!names.has(expectedAsset)) {
|
|
169
|
+
fail(`GitHub Release ${tag} is missing ${expectedAsset}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
console.log(`GitHub source, tag, release, and package asset are in sync: ${tag}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function writeNotesFile(directory, version) {
|
|
177
|
+
const body = changelogBody(version);
|
|
178
|
+
if (!body) return null;
|
|
179
|
+
const path = join(directory, `release-${version}.md`);
|
|
180
|
+
writeFileSync(path, body.endsWith("\n") ? body : `${body}\n`, "utf8");
|
|
181
|
+
return path;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function packReleaseAsset(directory, pkg) {
|
|
185
|
+
const result = run(
|
|
186
|
+
"npm",
|
|
187
|
+
["pack", "--silent", "--json", "--pack-destination", directory],
|
|
188
|
+
{ capture: true },
|
|
189
|
+
);
|
|
190
|
+
let records;
|
|
191
|
+
try {
|
|
192
|
+
records = JSON.parse(result.stdout);
|
|
193
|
+
} catch {
|
|
194
|
+
fail("npm pack did not return valid JSON");
|
|
195
|
+
}
|
|
196
|
+
const record = normalizePackRecord(records, pkg.name);
|
|
197
|
+
const filename = record?.filename;
|
|
198
|
+
if (typeof filename !== "string") fail("npm pack did not report a filename");
|
|
199
|
+
const path = join(directory, filename);
|
|
200
|
+
const expected = `${pkg.name.replaceAll("/", "-").replace(/^@/, "")}-${pkg.version}.tgz`;
|
|
201
|
+
if (filename !== expected) {
|
|
202
|
+
fail(`unexpected npm package filename ${filename}; expected ${expected}`);
|
|
203
|
+
}
|
|
204
|
+
return path;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function normalizePackRecord(value, packageName) {
|
|
208
|
+
if (Array.isArray(value)) return value[0] ?? null;
|
|
209
|
+
if (!value || typeof value !== "object") return null;
|
|
210
|
+
if (value[packageName] && typeof value[packageName] === "object") return value[packageName];
|
|
211
|
+
return Object.values(value).find((item) => item && typeof item === "object") ?? null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function ensureRelease(tag, version, assetPath, latest) {
|
|
215
|
+
const temp = dirname(assetPath);
|
|
216
|
+
const notes = writeNotesFile(temp, version);
|
|
217
|
+
const existing = releaseInfo(tag);
|
|
218
|
+
const title = `machine-bridge-mcp ${tag}`;
|
|
219
|
+
|
|
220
|
+
if (!existing) {
|
|
221
|
+
const args = [
|
|
222
|
+
"release",
|
|
223
|
+
"create",
|
|
224
|
+
tag,
|
|
225
|
+
assetPath,
|
|
226
|
+
"--verify-tag",
|
|
227
|
+
"--title",
|
|
228
|
+
title,
|
|
229
|
+
latest ? "--latest" : "--latest=false",
|
|
230
|
+
];
|
|
231
|
+
if (notes) args.push("--notes-file", notes);
|
|
232
|
+
else args.push("--generate-notes");
|
|
233
|
+
const created = runNetwork("gh", args, { allowFailure: true });
|
|
234
|
+
if (created.status !== 0 && !releaseInfo(tag)) failCommandResult("gh", args, created);
|
|
235
|
+
} else {
|
|
236
|
+
runNetwork("gh", [
|
|
237
|
+
"release",
|
|
238
|
+
"edit",
|
|
239
|
+
tag,
|
|
240
|
+
"--title",
|
|
241
|
+
title,
|
|
242
|
+
latest ? "--latest" : "--latest=false",
|
|
243
|
+
...(notes ? ["--notes-file", notes] : []),
|
|
244
|
+
]);
|
|
245
|
+
runNetwork("gh", ["release", "upload", tag, assetPath, "--clobber"]);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function publishCurrent() {
|
|
250
|
+
ensureClean();
|
|
251
|
+
fetchRemote();
|
|
252
|
+
|
|
253
|
+
const branch = output("git", ["branch", "--show-current"]);
|
|
254
|
+
if (branch !== "main") fail(`release must run from main, not ${branch || "detached HEAD"}`);
|
|
255
|
+
|
|
256
|
+
const pkg = packageMetadata();
|
|
257
|
+
const tag = `v${pkg.version}`;
|
|
258
|
+
if (!changelogBody(pkg.version)) {
|
|
259
|
+
fail(`CHANGELOG.md has no section for ${pkg.version}`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
run("npm", ["run", "check"]);
|
|
263
|
+
run("npm", ["run", "version:check"]);
|
|
264
|
+
ensureClean();
|
|
265
|
+
|
|
266
|
+
const head = output("git", ["rev-parse", "HEAD"]);
|
|
267
|
+
const originMain = output("git", ["rev-parse", "origin/main"]);
|
|
268
|
+
if (head !== originMain) {
|
|
269
|
+
const ancestor = run("git", ["merge-base", "--is-ancestor", "origin/main", "HEAD"], {
|
|
270
|
+
capture: true,
|
|
271
|
+
allowFailure: true,
|
|
272
|
+
});
|
|
273
|
+
if (ancestor.status !== 0) {
|
|
274
|
+
fail("origin/main is not an ancestor of HEAD; refusing a non-fast-forward release");
|
|
275
|
+
}
|
|
276
|
+
runNetwork("git", ["push", "origin", "HEAD:main"]);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const existingLocal = localTagCommit(tag);
|
|
280
|
+
if (existingLocal && existingLocal !== head) {
|
|
281
|
+
fail(`local ${tag} points to ${existingLocal}, not ${head}`);
|
|
282
|
+
}
|
|
283
|
+
if (!existingLocal) {
|
|
284
|
+
run("git", ["tag", "-a", tag, "-m", `Release ${pkg.version}`]);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const existingRemote = remoteTagCommit(tag);
|
|
288
|
+
if (existingRemote && existingRemote !== head) {
|
|
289
|
+
fail(`remote ${tag} points to ${existingRemote}, not ${head}`);
|
|
290
|
+
}
|
|
291
|
+
if (!existingRemote) {
|
|
292
|
+
runNetwork("git", ["push", "origin", tag]);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const temp = mkdtempSync(join(tmpdir(), "machine-bridge-mcp-release-"));
|
|
296
|
+
try {
|
|
297
|
+
const assetPath = packReleaseAsset(temp, pkg);
|
|
298
|
+
ensureRelease(tag, pkg.version, assetPath, true);
|
|
299
|
+
} finally {
|
|
300
|
+
rmSync(temp, { recursive: true, force: true });
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
fetchRemote();
|
|
304
|
+
assertCoreSync({ requireReleaseAsset: true });
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function backfillMissingReleases() {
|
|
308
|
+
ensureClean();
|
|
309
|
+
fetchRemote();
|
|
310
|
+
|
|
311
|
+
const tags = output("git", ["tag", "--sort=version:refname"])
|
|
312
|
+
.split("\n")
|
|
313
|
+
.filter((tag) => /^v\d+\.\d+\.\d+$/.test(tag));
|
|
314
|
+
const releases = JSON.parse(outputNetwork("gh", [
|
|
315
|
+
"release",
|
|
316
|
+
"list",
|
|
317
|
+
"--limit",
|
|
318
|
+
"1000",
|
|
319
|
+
"--json",
|
|
320
|
+
"tagName",
|
|
321
|
+
]));
|
|
322
|
+
const existing = new Set(releases.map((release) => release.tagName));
|
|
323
|
+
const missing = tags.filter((tag) => !existing.has(tag));
|
|
324
|
+
|
|
325
|
+
if (missing.length === 0) {
|
|
326
|
+
console.log("No GitHub Releases are missing.");
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const temp = mkdtempSync(join(tmpdir(), "machine-bridge-mcp-backfill-"));
|
|
331
|
+
try {
|
|
332
|
+
for (const tag of missing) {
|
|
333
|
+
if (!remoteTagCommit(tag)) fail(`remote tag ${tag} is missing`);
|
|
334
|
+
const version = tag.slice(1);
|
|
335
|
+
const notes = writeNotesFile(temp, version);
|
|
336
|
+
const args = [
|
|
337
|
+
"release",
|
|
338
|
+
"create",
|
|
339
|
+
tag,
|
|
340
|
+
"--verify-tag",
|
|
341
|
+
"--title",
|
|
342
|
+
`machine-bridge-mcp ${tag}`,
|
|
343
|
+
"--latest=false",
|
|
344
|
+
];
|
|
345
|
+
if (notes) args.push("--notes-file", notes);
|
|
346
|
+
else args.push("--generate-notes");
|
|
347
|
+
const created = runNetwork("gh", args, { allowFailure: true });
|
|
348
|
+
if (created.status !== 0 && !releaseInfo(tag)) failCommandResult("gh", args, created);
|
|
349
|
+
}
|
|
350
|
+
} finally {
|
|
351
|
+
rmSync(temp, { recursive: true, force: true });
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
console.log(`Backfilled ${missing.length} GitHub Release(s): ${missing.join(", ")}`);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const mode = process.argv[2] ?? "--check";
|
|
358
|
+
if (mode === "--check") {
|
|
359
|
+
ensureClean();
|
|
360
|
+
fetchRemote();
|
|
361
|
+
assertCoreSync({ requireReleaseAsset: true });
|
|
362
|
+
} else if (mode === "--publish") {
|
|
363
|
+
publishCurrent();
|
|
364
|
+
} else if (mode === "--backfill") {
|
|
365
|
+
backfillMissingReleases();
|
|
366
|
+
} else {
|
|
367
|
+
fail("usage: node scripts/github-release.mjs [--check|--publish|--backfill]");
|
|
368
|
+
}
|
|
@@ -12,16 +12,23 @@ const findings = [];
|
|
|
12
12
|
for (const relativePath of candidates) {
|
|
13
13
|
if (relativePath === ".privacy-denylist") continue;
|
|
14
14
|
scanDenylistPath(relativePath, denylist, findings);
|
|
15
|
+
scanSensitivePath(relativePath, findings);
|
|
15
16
|
const fullPath = path.join(root, relativePath);
|
|
16
17
|
let info;
|
|
17
|
-
try { info = lstatSync(fullPath); } catch {
|
|
18
|
+
try { info = lstatSync(fullPath); } catch {
|
|
19
|
+
findings.push({ path: relativePath, line: 1, rule: "publication file metadata could not be read" });
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
18
22
|
if (info.isSymbolicLink()) {
|
|
19
23
|
findings.push({ path: relativePath, line: 1, rule: "symbolic link in publication surface" });
|
|
20
24
|
continue;
|
|
21
25
|
}
|
|
22
26
|
if (!info.isFile()) continue;
|
|
23
27
|
let buffer;
|
|
24
|
-
try { buffer = readFileSync(fullPath); } catch {
|
|
28
|
+
try { buffer = readFileSync(fullPath); } catch {
|
|
29
|
+
findings.push({ path: relativePath, line: 1, rule: "publication file content could not be read" });
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
25
32
|
if (buffer.length > 5 * 1024 * 1024) {
|
|
26
33
|
findings.push({ path: relativePath, line: 1, rule: "file exceeds privacy scanner size limit and requires manual review" });
|
|
27
34
|
continue;
|
|
@@ -36,6 +43,7 @@ for (const relativePath of candidates) {
|
|
|
36
43
|
continue;
|
|
37
44
|
}
|
|
38
45
|
if (relativePath !== selfPath) scanBuiltIn(relativePath, text, findings);
|
|
46
|
+
if (path.basename(relativePath).toLowerCase() === ".npmrc") scanNpmrc(relativePath, text, findings);
|
|
39
47
|
scanDenylist(relativePath, text, denylist, findings);
|
|
40
48
|
}
|
|
41
49
|
|
|
@@ -65,7 +73,9 @@ function collectCandidateFiles(directory) {
|
|
|
65
73
|
const relative = stack.pop();
|
|
66
74
|
const absolute = path.join(directory, relative);
|
|
67
75
|
let entries;
|
|
68
|
-
try { entries = readdirSync(absolute, { withFileTypes: true }); } catch {
|
|
76
|
+
try { entries = readdirSync(absolute, { withFileTypes: true }); } catch {
|
|
77
|
+
throw new Error("privacy fallback traversal could not read a repository directory");
|
|
78
|
+
}
|
|
69
79
|
for (const entry of entries) {
|
|
70
80
|
if (excluded.has(entry.name) || entry.name === ".privacy-denylist") continue;
|
|
71
81
|
const child = relative ? path.join(relative, entry.name) : entry.name;
|
|
@@ -92,10 +102,17 @@ function escapeRegExp(value) {
|
|
|
92
102
|
|
|
93
103
|
function scanBuiltIn(relativePath, text, out) {
|
|
94
104
|
const rules = [
|
|
95
|
-
["private key material", /-----BEGIN\s+(?:OPENSSH|RSA|EC|DSA)\s+PRIVATE\s+KEY-----/g],
|
|
105
|
+
["private key material", /-----BEGIN\s+(?:(?:OPENSSH|RSA|EC|DSA)\s+|ENCRYPTED\s+)?PRIVATE\s+KEY-----/g],
|
|
96
106
|
["AWS access key", /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g],
|
|
97
107
|
["GitHub access token", /\bgh[pousr]_[A-Za-z0-9_]{30,}\b/g],
|
|
108
|
+
["GitLab access token", /\bglpat-[A-Za-z0-9_-]{20,}\b/g],
|
|
109
|
+
["npm access token", /\bnpm_[A-Za-z0-9]{30,}\b/g],
|
|
110
|
+
["Slack access token", /\bxox[aboprs]-[A-Za-z0-9-]{10,}\b/g],
|
|
111
|
+
["Google API key", /\bAIza[A-Za-z0-9_-]{30,}\b/g],
|
|
112
|
+
["live payment API key", /\b(?:sk|rk|pk)_live_[A-Za-z0-9]{16,}\b/g],
|
|
98
113
|
["API secret token", /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/g],
|
|
114
|
+
["JWT-like bearer token", /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g],
|
|
115
|
+
["URL with embedded credentials", /https?:\/\/[^\s/@:"'<>]+:[^\s/@"'<>]+@[^\s/"'<>]+/gi],
|
|
99
116
|
["absolute macOS/Linux home path", /(?:^|[\s"'=(:,])\/(?:Users|home)\/([^/\s"'<>]+)\//gm],
|
|
100
117
|
["absolute Windows home path", /(?:^|[\s"'=(:,])\b[A-Za-z]:\\Users\\([^\\\s"'<>]+)\\/gm],
|
|
101
118
|
];
|
|
@@ -105,6 +122,11 @@ function scanBuiltIn(relativePath, text, out) {
|
|
|
105
122
|
out.push({ path: relativePath, line: lineNumber(text, match.index || 0), rule });
|
|
106
123
|
}
|
|
107
124
|
}
|
|
125
|
+
const emailLike = /\b[A-Z0-9._%+-]+@([A-Z0-9.-]+\.[A-Z]{2,})\b/gi;
|
|
126
|
+
for (const match of text.matchAll(emailLike)) {
|
|
127
|
+
if (isReservedExampleHost(match[1]) || String(match[1]).toLowerCase().endsWith(".noreply.github.com")) continue;
|
|
128
|
+
out.push({ path: relativePath, line: lineNumber(text, match.index || 0), rule: "non-example email address" });
|
|
129
|
+
}
|
|
108
130
|
const sshLike = /\b[A-Za-z_][A-Za-z0-9._-]*@([A-Za-z0-9.-]+)\b/g;
|
|
109
131
|
for (const match of text.matchAll(sshLike)) {
|
|
110
132
|
if (isReservedExampleHost(match[1]) || !hasNearbySshContext(text, match.index || 0)) continue;
|
|
@@ -112,6 +134,40 @@ function scanBuiltIn(relativePath, text, out) {
|
|
|
112
134
|
}
|
|
113
135
|
}
|
|
114
136
|
|
|
137
|
+
|
|
138
|
+
function scanSensitivePath(relativePath, out) {
|
|
139
|
+
const normalized = String(relativePath).split(path.sep).join("/");
|
|
140
|
+
const base = path.posix.basename(normalized).toLowerCase();
|
|
141
|
+
const sensitive = base === ".dev.vars"
|
|
142
|
+
|| /^\.env(?:\.|$)/.test(base)
|
|
143
|
+
|| /\.(?:pem|p12|pfx|sqlite|sqlite3|db|log)$/i.test(base)
|
|
144
|
+
|| /^(?:id_rsa|id_dsa|id_ecdsa|id_ed25519)(?:\.|$)/i.test(base)
|
|
145
|
+
|| /^(?:credentials?|secrets?|cookies?|sessions?|tokens?)(?:\.|$)/i.test(base);
|
|
146
|
+
if (sensitive) out.push({ path: relativePath, line: 1, rule: "credential- or private-data-shaped publication filename" });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function scanNpmrc(relativePath, text, out) {
|
|
150
|
+
const forbiddenKey = /(?:^|:)(?:_auth|_authtoken|_password|username|email|certfile|keyfile|cafile)$/i;
|
|
151
|
+
const lines = text.split(/\r?\n/);
|
|
152
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
153
|
+
const line = lines[index].trim();
|
|
154
|
+
if (!line || line.startsWith("#") || line.startsWith(";")) continue;
|
|
155
|
+
const separator = line.indexOf("=");
|
|
156
|
+
if (separator < 1) {
|
|
157
|
+
out.push({ path: relativePath, line: index + 1, rule: "unparseable tracked .npmrc line requires manual review" });
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const key = line.slice(0, separator).trim().toLowerCase();
|
|
161
|
+
const value = line.slice(separator + 1).trim();
|
|
162
|
+
if (forbiddenKey.test(key) || /(?:^|[:/])(?:_auth|_authtoken|_password|username|email|certfile|keyfile|cafile)$/i.test(key)) {
|
|
163
|
+
out.push({ path: relativePath, line: index + 1, rule: "tracked .npmrc contains authentication or identity configuration" });
|
|
164
|
+
}
|
|
165
|
+
if (/\$\{[^}]+\}/.test(value) || /https?:\/\/[^/@\s]+:[^/@\s]+@/i.test(value)) {
|
|
166
|
+
out.push({ path: relativePath, line: index + 1, rule: "tracked .npmrc contains environment interpolation or embedded credentials" });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
115
171
|
function scanDenylistPath(relativePath, entries, out) {
|
|
116
172
|
const lower = relativePath.toLocaleLowerCase("en-US");
|
|
117
173
|
for (const entry of entries) {
|
|
@@ -133,7 +189,9 @@ function scanDenylist(relativePath, text, entries, out) {
|
|
|
133
189
|
function loadDenylist(file) {
|
|
134
190
|
if (!existsSync(file)) return [];
|
|
135
191
|
let text;
|
|
136
|
-
try { text = readFileSync(file, "utf8"); } catch {
|
|
192
|
+
try { text = readFileSync(file, "utf8"); } catch {
|
|
193
|
+
throw new Error("local privacy denylist exists but could not be read");
|
|
194
|
+
}
|
|
137
195
|
return [...new Set(text.split(/\r?\n/)
|
|
138
196
|
.map((line) => line.trim())
|
|
139
197
|
.filter((line) => line && !line.startsWith("#") && line.length >= 3)
|
|
@@ -163,6 +221,9 @@ function isReservedExampleHost(host) {
|
|
|
163
221
|
|| value === "127.0.0.1"
|
|
164
222
|
|| value === "::1"
|
|
165
223
|
|| value === "example"
|
|
224
|
+
|| value === "example.com"
|
|
225
|
+
|| value === "example.net"
|
|
226
|
+
|| value === "example.org"
|
|
166
227
|
|| value.endsWith(".example")
|
|
167
228
|
|| value.endsWith(".example.com")
|
|
168
229
|
|| value.endsWith(".example.net")
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function tagSyncError({ scope = "local", tag, head, commit }) {
|
|
2
|
+
const label = scope === "remote" ? "remote tag" : "local tag";
|
|
3
|
+
if (!commit) {
|
|
4
|
+
return `${label} ${tag} is missing; run npm run release:publish before npm publish`;
|
|
5
|
+
}
|
|
6
|
+
if (commit !== head) {
|
|
7
|
+
return `${label} ${tag} points to ${commit}, not HEAD ${head}`;
|
|
8
|
+
}
|
|
9
|
+
return "";
|
|
10
|
+
}
|