@nexus-flow/mcp 0.13.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/README.md ADDED
@@ -0,0 +1,63 @@
1
+ # @nexus-flow/mcp
2
+
3
+ A tiny **runner shim** that lets an MCP host (Claude Desktop, Cursor, Windsurf, …) start the
4
+ [nexus-flow](https://nxf.nxsflow.com) MCP server **without `nxs` installed first**.
5
+
6
+ It fetches the signed `nxs` prebuilt for your platform, **verifies it (sha256 + minisign)**,
7
+ caches it, and execs `nxs mcp serve` — passing your arguments through.
8
+
9
+ ## Use it in a host config
10
+
11
+ ```json
12
+ {
13
+ "mcpServers": {
14
+ "nxs": {
15
+ "command": "npx",
16
+ "args": ["-y", "@nexus-flow/mcp", "--", "--workspace", "/absolute/path/to/your/project"]
17
+ }
18
+ }
19
+ }
20
+ ```
21
+
22
+ Everything after `--` is passed straight to `nxs mcp serve` (e.g. `--workspace`, `--actor`). If
23
+ you already have `nxs` on `PATH`, point the host at it directly instead — this shim exists for the
24
+ "not installed at all" case (`nxs mcp install` writes the direct entry; `nxs mcp install --runner
25
+ npx` writes the entry above).
26
+
27
+ ## Security
28
+
29
+ The shim **never execs an unverified binary**. Two gates, both mandatory, both fail-closed:
30
+
31
+ - **sha256** (integrity) — the `.sha256` sidecar must match the downloaded bytes.
32
+ - **minisign** (authenticity) — a prehashed Ed25519/BLAKE2b-512 signature is verified against the
33
+ public key **embedded in this package** (the same key `nxs` and `install.sh` use). A tampered
34
+ tarball, a wrong key, a legacy (un-prehashed) signature, or a missing signature all abort before
35
+ anything runs. There is **no** insecure-bootstrap escape hatch here.
36
+
37
+ Verification uses only Node's built-in `crypto` — this package has **zero runtime dependencies**.
38
+
39
+ Only bytes that passed **both** gates are written into the cache (verify-before-cache, via an atomic
40
+ temp→rename). A cached binary is therefore trusted and exec'd on later launches **without**
41
+ re-verification, so the integrity of the cache directory rests on filesystem permissions — the cache
42
+ lives under your per-user cache dir (or `NXS_MCP_CACHE_DIR`); keep it user-writable only. Downloads
43
+ are bounded (per-request idle timeout, a hard byte cap, and a decompression-size cap) so a stalled or
44
+ malicious origin fails fast instead of hanging or exhausting memory before the gate.
45
+
46
+ ## Configuration (env, matching `install.sh`)
47
+
48
+ | Variable | Default | Meaning |
49
+ | ------------------- | --------------------------- | -------------------------------------------- |
50
+ | `NXF_CHANNEL` | `stable` | Promotion ring (`stable` / `beta` / `alpha`) |
51
+ | `NXF_VERSION` | *(newest in channel)* | Pin an exact version |
52
+ | `NXF_BASE_URL` | `https://nxf.nxsflow.com` | Artifact origin |
53
+ | `NXF_PLATFORM` | *(auto from os/arch)* | Override host detection |
54
+ | `NXS_MCP_CACHE_DIR` | OS cache dir | Where the verified `nxs` is cached |
55
+
56
+ The first launch downloads and verifies (a few seconds); subsequent launches use the cache and
57
+ start instantly — and work offline.
58
+
59
+ ## Platforms
60
+
61
+ `{darwin,linux}-{aarch64,x86_64}`. Windows support is tracked upstream.
62
+
63
+ Requires Node ≥ 18.
package/bin/cli.js ADDED
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // @nexus-flow/mcp — the runner shim for hosts where `nxs` is not installed. A host MCP config
5
+ // launches `npx -y @nexus-flow/mcp -- <args>`; this fetches the signed `nxs` prebuilt for the
6
+ // platform, VERIFIES it (sha256 + minisign, fail-closed), caches it, and execs `nxs mcp serve`
7
+ // with the host args passed through. stdout is the MCP stdio channel — every diagnostic goes to
8
+ // stderr so it never corrupts the protocol stream.
9
+
10
+ const { spawn } = require('node:child_process');
11
+ const { ensureNxs } = require('../lib/index');
12
+
13
+ async function main() {
14
+ const passthrough = process.argv.slice(2); // e.g. ["--workspace", "/proj"]
15
+
16
+ let nxs;
17
+ try {
18
+ nxs = await ensureNxs({ env: process.env });
19
+ } catch (e) {
20
+ process.stderr.write(`nexus-flow: could not obtain a verified nxs binary: ${e.message}\n`);
21
+ process.exit(1);
22
+ }
23
+
24
+ // Hand off to the real server. `stdio: 'inherit'` wires the host's stdin/stdout/stderr straight
25
+ // through to nxs (MCP speaks over stdio). Async spawn (not spawnSync) keeps the event loop free so
26
+ // a SIGINT/SIGTERM sent to THIS process (a host that signals the node PID directly, not just via
27
+ // stdin-EOF) is forwarded to nxs instead of leaving it orphaned. Propagate the child's exit code,
28
+ // or re-raise the terminating signal so callers observe the real cause.
29
+ const child = spawn(nxs, ['mcp', 'serve', ...passthrough], { stdio: 'inherit' });
30
+
31
+ const forward = (sig) => {
32
+ if (!child.killed) child.kill(sig);
33
+ };
34
+ process.on('SIGINT', () => forward('SIGINT'));
35
+ process.on('SIGTERM', () => forward('SIGTERM'));
36
+ process.on('SIGHUP', () => forward('SIGHUP'));
37
+
38
+ child.on('error', (e) => {
39
+ process.stderr.write(`nexus-flow: failed to launch nxs: ${e.message}\n`);
40
+ process.exit(1);
41
+ });
42
+ child.on('exit', (code, signal) => {
43
+ if (signal) {
44
+ // Re-raise so the parent dies of the same signal (128+n exit), the conventional shell contract.
45
+ process.kill(process.pid, signal);
46
+ return;
47
+ }
48
+ process.exit(code === null ? 1 : code);
49
+ });
50
+ }
51
+
52
+ main();
package/lib/cache.js ADDED
@@ -0,0 +1,55 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+
7
+ // resolveCacheDir(env, platform, homedir): the per-user cache root for downloaded binaries. The
8
+ // NXS_MCP_CACHE_DIR override wins (testing / locked-down homes); otherwise the OS convention — macOS
9
+ // ~/Library/Caches, Linux $XDG_CACHE_HOME or ~/.cache.
10
+ function resolveCacheDir(env = process.env, platform = '', homedir = os.homedir()) {
11
+ if (env.NXS_MCP_CACHE_DIR) return env.NXS_MCP_CACHE_DIR;
12
+ const os_ = platform.split('-')[0];
13
+ if (os_ === 'darwin') return path.join(homedir, 'Library', 'Caches', 'nexus-flow', 'mcp');
14
+ const xdg = env.XDG_CACHE_HOME || path.join(homedir, '.cache');
15
+ return path.join(xdg, 'nexus-flow', 'mcp');
16
+ }
17
+
18
+ // The immutable, channel- + version- + platform-scoped path a verified `nxs` lands at. Versions are
19
+ // immutable, so a binary present here was already sha256+minisign-verified before it was moved into
20
+ // place. The `channel` segment keeps the offline fallback from ever serving a binary from a
21
+ // different promotion ring than the one requested (e.g. a cached `beta` for a `stable` run).
22
+ function binaryPath(cacheDir, channel, version, platform) {
23
+ return path.join(cacheDir, 'nxs', channel, version, platform, 'nxs');
24
+ }
25
+
26
+ // readCached: the binary path if a verified copy is already cached, else null.
27
+ function readCached(cacheDir, channel, version, platform) {
28
+ const p = binaryPath(cacheDir, channel, version, platform);
29
+ return fs.existsSync(p) ? p : null;
30
+ }
31
+
32
+ // install: write verified bytes to a temp sibling, chmod 0755, then atomically rename into the
33
+ // cache path — a concurrent reader never sees a half-written binary. Returns the final path.
34
+ // Caller MUST have verified the bytes first (only verified bytes are ever passed here).
35
+ function install(cacheDir, channel, version, platform, bytes) {
36
+ const dest = binaryPath(cacheDir, channel, version, platform);
37
+ const dir = path.dirname(dest);
38
+ fs.mkdirSync(dir, { recursive: true });
39
+ const tmp = path.join(dir, `.nxs.tmp.${process.pid}`);
40
+ try {
41
+ fs.writeFileSync(tmp, bytes, { mode: 0o755 });
42
+ fs.chmodSync(tmp, 0o755); // writeFileSync mode is subject to umask; force it
43
+ fs.renameSync(tmp, dest);
44
+ } catch (e) {
45
+ try {
46
+ fs.rmSync(tmp, { force: true });
47
+ } catch {
48
+ /* best-effort cleanup */
49
+ }
50
+ throw e;
51
+ }
52
+ return dest;
53
+ }
54
+
55
+ module.exports = { resolveCacheDir, binaryPath, readCached, install };
@@ -0,0 +1,18 @@
1
+ 'use strict';
2
+
3
+ // Embedded minisign PUBLIC key — safe to ship in a world-readable package (it verifies, never
4
+ // signs). MUST stay byte-identical to install.sh's EMBEDDED_MINISIGN_PUBKEY and the release
5
+ // pipeline's vars.NXF_MINISIGN_PUBKEY; the pubkey cross-check in .github/workflows/install-sh-ci.yml
6
+ // fails CI on drift. Rotation = update this one line (and install.sh's, and the repo var).
7
+ const EMBEDDED_MINISIGN_PUBKEY = 'RWRnRFdblE2/DEmkBWpMPqXYele5JoNtQubF+N1Ryp7msJgq7HKn2Ola';
8
+
9
+ // Public origin serving /latest, /download/*, and the sidecars (install.sh's NXF_BASE_URL default).
10
+ const DEFAULT_BASE_URL = 'https://nxf.nxsflow.com';
11
+
12
+ // Default promotion ring — the most-stable of release/channels (single source of truth). `cargo
13
+ // xtask channels check` asserts this equals the canonical most-stable channel. The set of shipped
14
+ // platform keys is not duplicated here: lib/platform.js maps the os/arch tokens componentwise (like
15
+ // install.sh's platform_for), and `cargo xtask platforms check` guards that mapping.
16
+ const DEFAULT_CHANNEL = 'stable';
17
+
18
+ module.exports = { EMBEDDED_MINISIGN_PUBKEY, DEFAULT_BASE_URL, DEFAULT_CHANNEL };
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+
3
+ const http = require('node:http');
4
+ const https = require('node:https');
5
+ const { sameOrigin } = require('./resolve');
6
+
7
+ function client(url) {
8
+ return url.startsWith('https:') ? https : http;
9
+ }
10
+
11
+ // Idle-socket timeout for a single GET: fires when no bytes arrive for this long (it RESETS on
12
+ // activity, so a large-but-flowing download never trips it — only a stall / hung TLS does). Bounds
13
+ // the slow-loris case where a CDN accepts then goes silent.
14
+ const DEFAULT_TIMEOUT_MS = 30_000;
15
+
16
+ // Hard cap on a single response body. The largest legitimate artifact is one tarball (nxs +
17
+ // nxf-relay + docs, tens of MB); this ceiling is comfortably above that but stops a malicious /
18
+ // compromised origin from OOM-ing startup by streaming unbounded bytes BEFORE verification.
19
+ const DEFAULT_MAX_BYTES = 256 * 1024 * 1024;
20
+
21
+ // One GET, no redirect following, resolving to { statusCode, location, body }. Rejects on a
22
+ // transport/TLS error, on an idle timeout, or when the body exceeds `maxBytes` — every real HTTP
23
+ // status is a resolved value the caller decides on.
24
+ function request(url, { timeoutMs = DEFAULT_TIMEOUT_MS, maxBytes = DEFAULT_MAX_BYTES } = {}) {
25
+ return new Promise((resolve, reject) => {
26
+ const req = client(url).get(url, (res) => {
27
+ const chunks = [];
28
+ let total = 0;
29
+ res.on('data', (c) => {
30
+ total += c.length;
31
+ if (total > maxBytes) {
32
+ // Refuse before buffering more: this runs pre-verification, so an unbounded body must
33
+ // never be allowed to exhaust memory.
34
+ req.destroy();
35
+ reject(new Error(`response from ${url} is too large (exceeds ${maxBytes} bytes) — refusing`));
36
+ return;
37
+ }
38
+ chunks.push(c);
39
+ });
40
+ res.on('end', () =>
41
+ resolve({
42
+ statusCode: res.statusCode,
43
+ location: res.headers.location,
44
+ body: Buffer.concat(chunks),
45
+ })
46
+ );
47
+ });
48
+ // Idle timeout on the underlying socket (covers connect-then-stall and never-respond alike).
49
+ req.setTimeout(timeoutMs, () => {
50
+ req.destroy();
51
+ reject(new Error(`request to ${url} timed out after ${timeoutMs}ms`));
52
+ });
53
+ req.on('error', reject);
54
+ });
55
+ }
56
+
57
+ // resolveRedirect(url): return the Location of a single 3xx WITHOUT following it (so the caller can
58
+ // same-origin-check the server-controlled target before fetching). null on a genuine 404 (nothing
59
+ // published); throw on any other status, an idle timeout, or a transport error — a dropped/blocked
60
+ // request must NOT read as "not found" and silently downgrade.
61
+ async function resolveRedirect(url, opts = {}) {
62
+ const { statusCode, location } = await request(url, opts);
63
+ if (statusCode >= 300 && statusCode < 400 && location) return location;
64
+ if (statusCode === 404) return null;
65
+ throw new Error(`unexpected HTTP ${statusCode} resolving ${url}`);
66
+ }
67
+
68
+ // download(url): GET the bytes, following redirects (bounded), rejecting on a non-2xx final status,
69
+ // an idle timeout, or an oversized body. Redirect hops are same-origin-guarded against the ORIGINAL
70
+ // url (rejecting a cross-host jump or an https→http downgrade): minisign is the real integrity
71
+ // backstop, but there is no reason to follow a server-directed fetch off our own origin.
72
+ async function download(url, { maxRedirects = 5, timeoutMs, maxBytes } = {}) {
73
+ const reqOpts = {};
74
+ if (timeoutMs !== undefined) reqOpts.timeoutMs = timeoutMs;
75
+ if (maxBytes !== undefined) reqOpts.maxBytes = maxBytes;
76
+ let current = url;
77
+ for (let i = 0; i <= maxRedirects; i++) {
78
+ const { statusCode, location, body } = await request(current, reqOpts);
79
+ if (statusCode >= 200 && statusCode < 300) return body;
80
+ if (statusCode >= 300 && statusCode < 400 && location) {
81
+ const next = new URL(location, current).toString();
82
+ if (!sameOrigin(next, url)) {
83
+ throw new Error(`refusing cross-origin/downgraded redirect from ${url} to ${next}`);
84
+ }
85
+ current = next;
86
+ continue;
87
+ }
88
+ throw new Error(`download failed: HTTP ${statusCode} for ${current}`);
89
+ }
90
+ throw new Error(`download failed: too many redirects for ${url}`);
91
+ }
92
+
93
+ module.exports = { resolveRedirect, download, DEFAULT_TIMEOUT_MS, DEFAULT_MAX_BYTES };
package/lib/extract.js ADDED
@@ -0,0 +1,52 @@
1
+ 'use strict';
2
+
3
+ const zlib = require('node:zlib');
4
+
5
+ const BLOCK = 512;
6
+
7
+ // Cap on the INFLATED tar size. Well above a real release (nxs + nxf-relay + docs, tens of MB) yet
8
+ // bounded, so a decompression bomb (a tiny gzip that inflates to gigabytes) throws instead of
9
+ // exhausting memory. This runs before verification only for the tarball path already byte-capped in
10
+ // download.js — defense in depth on the two DoS surfaces the review flagged.
11
+ const DEFAULT_MAX_OUTPUT = 512 * 1024 * 1024;
12
+
13
+ // Read the NUL-terminated name from a ustar header block (bytes 0..100). Release tarballs ship bare
14
+ // member names (`nxs`, `nxf-relay`, …), all well under 100 chars, so the `prefix` field is never
15
+ // needed here.
16
+ function headerName(block) {
17
+ const raw = block.subarray(0, 100);
18
+ const nul = raw.indexOf(0);
19
+ return raw.toString('latin1', 0, nul === -1 ? 100 : nul);
20
+ }
21
+
22
+ // Parse the octal size field (bytes 124..136).
23
+ function headerSize(block) {
24
+ const raw = block.subarray(124, 136).toString('latin1').replace(/[\0 ]+$/g, '').trim();
25
+ return raw ? parseInt(raw, 8) : 0;
26
+ }
27
+
28
+ // extractMember(tarGzBuffer, name): gunzip and walk the tar, returning the exact bytes of the
29
+ // regular-file member matching `name`. Extracting a single member by EXACT name (not a wildcard)
30
+ // also defeats any `../`/absolute-path entry a tampered tarball might carry — defense in depth
31
+ // behind the sha256 + minisign gates. Throws if the member is absent.
32
+ function extractMember(tarGzBuffer, name, { maxOutputLength = DEFAULT_MAX_OUTPUT } = {}) {
33
+ // `maxOutputLength` makes gunzip throw (RangeError) rather than inflate an unbounded bomb.
34
+ const tar = zlib.gunzipSync(tarGzBuffer, { maxOutputLength });
35
+ let pos = 0;
36
+ while (pos + BLOCK <= tar.length) {
37
+ const header = tar.subarray(pos, pos + BLOCK);
38
+ const entryName = headerName(header);
39
+ if (entryName === '') break; // end-of-archive marker (zero block)
40
+ const size = headerSize(header);
41
+ const typeflag = header[156];
42
+ const dataStart = pos + BLOCK;
43
+ // Regular file: typeflag '0' (0x30) or NUL (legacy).
44
+ if (entryName === name && (typeflag === 0x30 || typeflag === 0)) {
45
+ return Buffer.from(tar.subarray(dataStart, dataStart + size));
46
+ }
47
+ pos = dataStart + Math.ceil(size / BLOCK) * BLOCK;
48
+ }
49
+ throw new Error(`member '${name}' not found in the downloaded tarball`);
50
+ }
51
+
52
+ module.exports = { extractMember };
package/lib/index.js ADDED
@@ -0,0 +1,143 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ const { detectPlatform } = require('./platform');
7
+ const { resolveCacheDir, binaryPath, readCached, install } = require('./cache');
8
+ const { pinnedUrl, latestUrl, sidecarUrls, sameOrigin } = require('./resolve');
9
+ const { resolveRedirect, download } = require('./download');
10
+ const { verifySha256, verifyMinisign } = require('./verify');
11
+ const { extractMember } = require('./extract');
12
+ const { EMBEDDED_MINISIGN_PUBKEY, DEFAULT_BASE_URL, DEFAULT_CHANNEL } = require('./constants');
13
+
14
+ // Plain-SemVer shape a version must match (matches release/version's `X.Y.Z`, no suffixes).
15
+ const VERSION_RE = /^\d+\.\d+\.\d+$/;
16
+
17
+ // A security refusal that must NEVER be papered over by the offline cache fallback (an off-origin
18
+ // /latest redirect). A typed sentinel, so the catch below matches on the type — not on error text.
19
+ class OriginRefusedError extends Error {}
20
+
21
+ // Parse the version out of `nxf_<version>_<platform>.tar.gz`.
22
+ function versionFromTarballName(name, platform) {
23
+ const m = new RegExp(`^nxf_(.+)_${platform.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\.tar\\.gz$`).exec(name);
24
+ if (!m) throw new Error(`unexpected tarball name '${name}' (cannot read version)`);
25
+ return m[1];
26
+ }
27
+
28
+ // Compare two plain-SemVer strings (X.Y.Z) descending.
29
+ function semverDesc(a, b) {
30
+ const pa = a.split('.').map(Number);
31
+ const pb = b.split('.').map(Number);
32
+ for (let i = 0; i < 3; i++) {
33
+ if ((pb[i] || 0) !== (pa[i] || 0)) return (pb[i] || 0) - (pa[i] || 0);
34
+ }
35
+ return 0;
36
+ }
37
+
38
+ // The newest already-cached, already-verified binary for this channel + platform, or null. Used as
39
+ // an offline fallback when the CDN is unreachable. Scoped to `channel` so a `stable` run never falls
40
+ // back to a `beta` binary cached earlier.
41
+ function newestCached(cacheDir, channel, platform) {
42
+ const root = path.join(cacheDir, 'nxs', channel);
43
+ let versions;
44
+ try {
45
+ versions = fs.readdirSync(root);
46
+ } catch {
47
+ return null;
48
+ }
49
+ const usable = versions
50
+ .filter((v) => VERSION_RE.test(v) && fs.existsSync(binaryPath(cacheDir, channel, v, platform)))
51
+ .sort(semverDesc);
52
+ return usable.length
53
+ ? { version: usable[0], path: binaryPath(cacheDir, channel, usable[0], platform) }
54
+ : null;
55
+ }
56
+
57
+ // ensureNxs(opts): resolve → download → VERIFY (sha256 + minisign, fail-closed) → extract → cache
58
+ // the signed `nxs` binary, returning its path. All logging is on the caller-supplied `log` (the CLI
59
+ // routes it to stderr — stdout is the MCP channel). Options default to production values; tests
60
+ // inject a fixture base URL, platform, pubkey, and cache dir.
61
+ async function ensureNxs(opts = {}) {
62
+ const env = opts.env || process.env;
63
+ const log = opts.log || ((m) => process.stderr.write(`${m}\n`));
64
+ const baseUrl = opts.baseUrl || env.NXF_BASE_URL || DEFAULT_BASE_URL;
65
+ const channel = opts.channel || env.NXF_CHANNEL || DEFAULT_CHANNEL;
66
+ const platform = opts.platform || detectPlatform(env);
67
+ const pubkey = opts.pubkey || EMBEDDED_MINISIGN_PUBKEY;
68
+ const cacheDir = opts.cacheDir || resolveCacheDir(env, platform);
69
+ const pinnedVersion = opts.version || env.NXF_VERSION || null;
70
+
71
+ // Validate a pinned version up front: it is interpolated into the cache path and the download URL,
72
+ // so a malformed value (e.g. `../../..`) must be rejected outright rather than traversing the cache
73
+ // dir. Same plain-SemVer shape enforced for a version discovered from `/latest`.
74
+ if (pinnedVersion && !VERSION_RE.test(pinnedVersion)) {
75
+ throw new Error(`invalid pinned version '${pinnedVersion}' (expected plain SemVer X.Y.Z)`);
76
+ }
77
+
78
+ // A pinned + cached version needs no network at all.
79
+ if (pinnedVersion) {
80
+ const hit = readCached(cacheDir, channel, pinnedVersion, platform);
81
+ if (hit) {
82
+ log(`nexus-flow: using cached nxs ${pinnedVersion} (${channel}, ${platform})`);
83
+ return hit;
84
+ }
85
+ }
86
+
87
+ // Resolve the tarball URL + concrete version. On any resolution failure, fall back to the newest
88
+ // cached binary (offline resilience) before giving up.
89
+ let tarUrl;
90
+ let version;
91
+ try {
92
+ if (pinnedVersion) {
93
+ version = pinnedVersion;
94
+ tarUrl = pinnedUrl(baseUrl, channel, version, platform);
95
+ } else {
96
+ const resolved = await resolveRedirect(latestUrl(baseUrl, channel, platform));
97
+ if (!resolved) throw new Error(`no release published for ${platform} on channel '${channel}'`);
98
+ if (!sameOrigin(resolved, baseUrl)) {
99
+ throw new OriginRefusedError(`refusing /latest redirect to a different origin: ${resolved}`);
100
+ }
101
+ tarUrl = resolved;
102
+ version = versionFromTarballName(path.posix.basename(new URL(tarUrl).pathname), platform);
103
+ const hit = readCached(cacheDir, channel, version, platform);
104
+ if (hit) {
105
+ log(`nexus-flow: using cached nxs ${version} (${channel}, ${platform})`);
106
+ return hit;
107
+ }
108
+ }
109
+ } catch (e) {
110
+ // An off-origin redirect is a security refusal, never something to paper over with the cache.
111
+ if (e instanceof OriginRefusedError) throw e;
112
+ const fb = newestCached(cacheDir, channel, platform);
113
+ if (fb) {
114
+ log(
115
+ `nexus-flow: warning: could not reach ${baseUrl} (${e.message}); using cached nxs ${fb.version} (${channel})`
116
+ );
117
+ return fb.path;
118
+ }
119
+ throw e;
120
+ }
121
+
122
+ // Download the tarball + both sidecars. A missing sidecar (download throws on non-2xx) aborts —
123
+ // there is no downgrade to sha256-only and no unsigned path.
124
+ log(`nexus-flow: downloading ${tarUrl}`);
125
+ const { sha256: shaUrl, minisig: sigUrl } = sidecarUrls(tarUrl);
126
+ const [tarball, shaText, sigText] = await Promise.all([
127
+ download(tarUrl),
128
+ download(shaUrl),
129
+ download(sigUrl),
130
+ ]);
131
+
132
+ // Both gates are mandatory; either throws → nothing is extracted or cached.
133
+ verifySha256(tarball, shaText.toString('utf8'));
134
+ verifyMinisign(tarball, sigText.toString('utf8'), pubkey);
135
+ log('nexus-flow: signature verified');
136
+
137
+ const bin = extractMember(tarball, 'nxs');
138
+ const p = install(cacheDir, channel, version, platform, bin);
139
+ log(`nexus-flow: installed nxs ${version} → ${p}`);
140
+ return p;
141
+ }
142
+
143
+ module.exports = { ensureNxs, versionFromTarballName, newestCached, OriginRefusedError };
@@ -0,0 +1,74 @@
1
+ 'use strict';
2
+
3
+ // Host → canonical platform key. Mirrors install.sh's `platform_for` (and decide.ts's
4
+ // normalizeOs/normalizeArch): the shim is a componentwise consumer of release/platforms, the
5
+ // single source of truth for the four shipped targets. `cargo xtask platforms check` asserts the
6
+ // mapping below covers every canonical os/arch token, so a 5th target or a rename fails CI here.
7
+
8
+ // uname os token → canonical os.
9
+ function osFor(unameS) {
10
+ switch (unameS) {
11
+ case 'Darwin':
12
+ return 'darwin';
13
+ case 'Linux':
14
+ return 'linux';
15
+ default:
16
+ return null;
17
+ }
18
+ }
19
+
20
+ // uname machine token → canonical arch (forgiving on synonyms).
21
+ function archFor(unameM) {
22
+ switch (unameM) {
23
+ case 'arm64':
24
+ case 'aarch64':
25
+ return 'aarch64';
26
+ case 'x86_64':
27
+ case 'amd64':
28
+ return 'x86_64';
29
+ default:
30
+ return null;
31
+ }
32
+ }
33
+
34
+ // platformFor(unameS, unameM) → "<os>-<arch>" for a shipped target, else null.
35
+ function platformFor(unameS, unameM) {
36
+ const os = osFor(unameS);
37
+ const arch = archFor(unameM);
38
+ if (!os || !arch) return null;
39
+ return `${os}-${arch}`;
40
+ }
41
+
42
+ // Node's process.platform → the uname `-s` token platformFor understands.
43
+ function unameSFor(nodePlatform) {
44
+ switch (nodePlatform) {
45
+ case 'darwin':
46
+ return 'Darwin';
47
+ case 'linux':
48
+ return 'Linux';
49
+ default:
50
+ return nodePlatform; // unrecognised → platformFor returns null → detectPlatform throws
51
+ }
52
+ }
53
+
54
+ // Node's process.arch → a uname `-m` token platformFor understands ('x64' → 'x86_64'; 'arm64'
55
+ // passes through since archFor already accepts it).
56
+ function unameMFor(nodeArch) {
57
+ return nodeArch === 'x64' ? 'x86_64' : nodeArch;
58
+ }
59
+
60
+ // Resolve the platform key: the NXF_PLATFORM override (escape hatch / testing) wins; otherwise
61
+ // derive from the node os/arch tokens. Throws a clear message on an unsupported host rather than
62
+ // letting a 404 surface three steps later.
63
+ function detectPlatform(env, nodePlatform = process.platform, nodeArch = process.arch) {
64
+ if (env && env.NXF_PLATFORM) return env.NXF_PLATFORM;
65
+ const platform = platformFor(unameSFor(nodePlatform), unameMFor(nodeArch));
66
+ if (!platform) {
67
+ throw new Error(
68
+ `unsupported platform: ${nodePlatform}/${nodeArch}. Supported: {darwin,linux}-{aarch64,x86_64}.`
69
+ );
70
+ }
71
+ return platform;
72
+ }
73
+
74
+ module.exports = { platformFor, detectPlatform };
package/lib/resolve.js ADDED
@@ -0,0 +1,45 @@
1
+ 'use strict';
2
+
3
+ // Pure URL construction for the artifact CDN. The network round-trips (fetch, follow the /latest
4
+ // 302) live in download.js/index.js; this module stays side-effect-free and unit-testable.
5
+
6
+ function stripSlash(base) {
7
+ return base.replace(/\/+$/, '');
8
+ }
9
+
10
+ // `nxf_<version>_<platform>.tar.gz` — the release packaging name (release.yml).
11
+ function tarballName(version, platform) {
12
+ return `nxf_${version}_${platform}.tar.gz`;
13
+ }
14
+
15
+ // The immutable per-version artifact path (used when NXF_VERSION pins a version).
16
+ function pinnedUrl(baseUrl, channel, version, platform) {
17
+ return `${stripSlash(baseUrl)}/download/${channel}/${version}/${tarballName(version, platform)}`;
18
+ }
19
+
20
+ // /latest?channel=&target=&arch= → 302 to the newest channel tarball. `platform` is the "<os>-<arch>"
21
+ // key; target/arch are its two halves (os and arch each carry no '-').
22
+ function latestUrl(baseUrl, channel, platform) {
23
+ const [os, arch] = platform.split('-');
24
+ return `${stripSlash(baseUrl)}/latest?channel=${channel}&target=${os}&arch=${arch}`;
25
+ }
26
+
27
+ // The `.sha256` / `.minisig` sidecars sit next to the resolved tarball.
28
+ function sidecarUrls(tarUrl) {
29
+ return { sha256: `${tarUrl}.sha256`, minisig: `${tarUrl}.minisig` };
30
+ }
31
+
32
+ // sameOrigin(url, base): true iff `url` has the same scheme://authority as `base`. Guards the
33
+ // server-controlled /latest redirect target against an open-redirect off our origin or an http
34
+ // downgrade before we fetch it.
35
+ function sameOrigin(url, base) {
36
+ try {
37
+ const a = new URL(url);
38
+ const b = new URL(base);
39
+ return a.protocol === b.protocol && a.host === b.host;
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ module.exports = { tarballName, pinnedUrl, latestUrl, sidecarUrls, sameOrigin };
package/lib/verify.js ADDED
@@ -0,0 +1,101 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+
5
+ // Two-gate download verification, ported faithfully from install.sh (§5.3) — but fail-closed
6
+ // UNCONDITIONALLY: this is the binary-exec path, so there is no NXF_INSECURE escape hatch and no
7
+ // 404-signature downgrade. sha256 proves INTEGRITY; minisign proves AUTHENTICITY. Any failure in
8
+ // either throws, and the caller must never exec an unverified binary.
9
+
10
+ // The fixed 12-byte SPKI DER header that wraps a raw Ed25519 public key so `crypto.createPublicKey`
11
+ // can import it (identical bytes to install.sh's `\060\052\060\005\006\003\053\145\160\003\041\000`).
12
+ const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
13
+
14
+ // Strict base64 decode: reject anything outside the base64 alphabet BEFORE decoding (Node's decoder
15
+ // silently drops invalid chars, which would weaken the length checks below).
16
+ function strictBase64(s, what) {
17
+ const trimmed = String(s).trim();
18
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(trimmed)) {
19
+ throw new Error(`${what} is not valid base64 (refusing to verify)`);
20
+ }
21
+ return Buffer.from(trimmed, 'base64');
22
+ }
23
+
24
+ // verifySha256(buf, sidecarText): MANDATORY integrity gate. The sidecar is `<hex> <name>`
25
+ // (publish-release.sh); compare only the hex so a name mismatch cannot weaken it.
26
+ function verifySha256(buf, sidecarText) {
27
+ const expected = String(sidecarText).trim().split(/\s+/)[0];
28
+ if (!expected) throw new Error('empty sha256 sidecar (refusing to install)');
29
+ const actual = crypto.createHash('sha256').update(buf).digest('hex');
30
+ if (expected.toLowerCase() !== actual) {
31
+ throw new Error(`sha256 mismatch — expected ${expected}, got ${actual} (refusing to install)`);
32
+ }
33
+ }
34
+
35
+ // Take the base64 body of a minisign public key: accept a bare base64 line OR a full minisign.pub
36
+ // (comment + base64), exactly as install.sh's openssl path does — the last non-comment, non-blank
37
+ // line.
38
+ function pubkeyBody(pubkeyText) {
39
+ const lines = String(pubkeyText)
40
+ .split('\n')
41
+ .map((l) => l.trim())
42
+ .filter((l) => l && !l.startsWith('untrusted comment:'));
43
+ if (lines.length === 0) throw new Error('minisign public key is empty (refusing to verify)');
44
+ return lines[lines.length - 1];
45
+ }
46
+
47
+ // verifyMinisign(buf, minisigText, pubkeyText): MANDATORY authenticity gate. Verifies a prehashed
48
+ // (minisign `-H`) Ed25519/BLAKE2b-512 signature — the only form the release pipeline emits and the
49
+ // only form accepted here (a legacy un-prehashed "Ed" signature is refused, never verified on a
50
+ // different path). Throws on any failure.
51
+ function verifyMinisign(buf, minisigText, pubkeyText) {
52
+ // Public key: 2-byte algorithm + 8-byte key id + 32-byte Ed25519 key = 42 bytes.
53
+ const pub = strictBase64(pubkeyBody(pubkeyText), 'minisign public key');
54
+ if (pub.length !== 42) {
55
+ throw new Error('minisign public key has an unexpected length (refusing to verify)');
56
+ }
57
+ const pubKeyId = pub.subarray(2, 10);
58
+ const rawKey = pub.subarray(10, 42);
59
+
60
+ // Signature: line 2 of the .minisig — 2-byte algorithm + 8-byte key id + 64-byte signature.
61
+ const sigLine = String(minisigText).split('\n')[1];
62
+ if (!sigLine || !sigLine.trim()) {
63
+ throw new Error('minisign .minisig has no signature line (refusing to install)');
64
+ }
65
+ const sig = strictBase64(sigLine, 'minisign signature');
66
+ if (sig.length !== 74) {
67
+ throw new Error('minisign signature has an unexpected length (refusing to install)');
68
+ }
69
+ // Algorithm bytes: "ED" = prehashed (what release.yml produces and what this shim requires).
70
+ // Anything else (e.g. legacy "Ed") is refused here rather than verified on a different code path.
71
+ const alg = sig.subarray(0, 2).toString('latin1');
72
+ if (alg !== 'ED') {
73
+ throw new Error(
74
+ 'minisign signature is not the prehashed (-H) form this shim verifies (refusing to downgrade)'
75
+ );
76
+ }
77
+ // The signature's key id must match the configured public key's, exactly as minisign checks.
78
+ if (!crypto.timingSafeEqual(sig.subarray(2, 10), pubKeyId)) {
79
+ throw new Error('minisign signature key id does not match the configured public key (refusing to install)');
80
+ }
81
+ const signature = sig.subarray(10, 74);
82
+
83
+ // Message = BLAKE2b-512 of the file (the minisign `-H` prehash), verified with pure Ed25519.
84
+ const digest = crypto.createHash('blake2b512').update(buf).digest();
85
+ let ok;
86
+ try {
87
+ const key = crypto.createPublicKey({
88
+ key: Buffer.concat([ED25519_SPKI_PREFIX, rawKey]),
89
+ format: 'der',
90
+ type: 'spki',
91
+ });
92
+ ok = crypto.verify(null, digest, key, signature);
93
+ } catch {
94
+ ok = false;
95
+ }
96
+ if (!ok) {
97
+ throw new Error('minisign signature verification FAILED (refusing to install)');
98
+ }
99
+ }
100
+
101
+ module.exports = { verifySha256, verifyMinisign };
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@nexus-flow/mcp",
3
+ "version": "0.13.0",
4
+ "description": "Runner shim: fetch, verify (sha256 + minisign), cache, and exec the signed nxs binary as an MCP server for hosts where nxs is not installed.",
5
+ "keywords": [
6
+ "mcp",
7
+ "model-context-protocol",
8
+ "nexus-flow",
9
+ "nxs"
10
+ ],
11
+ "homepage": "https://nxf.nxsflow.com",
12
+ "bugs": "https://github.com/nxsflow/nexus-flow/issues",
13
+ "license": "Apache-2.0 OR MIT",
14
+ "type": "commonjs",
15
+ "bin": {
16
+ "nexus-flow-mcp": "bin/cli.js"
17
+ },
18
+ "files": [
19
+ "bin/",
20
+ "lib/",
21
+ "README.md"
22
+ ],
23
+ "scripts": {
24
+ "test": "node --test"
25
+ },
26
+ "engines": {
27
+ "node": ">=18"
28
+ }
29
+ }