@darwinium/portal-mcp 0.0.1 → 0.0.2

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.
@@ -0,0 +1,250 @@
1
+ import { c as PAIRING_CODE_REGEX, f as VERSION_SUBPROTOCOL, l as PROTOCOL_VERSION, n as killProcessOnPort, p as WS_CLOSE_TOKEN_MISMATCH, r as wait, t as isPortInUse } from "./killProcessOnPort-Bt5MA5Dv.js";
2
+ import { a as info, n as box, o as ok, s as warn } from "./term-Bg7icwx7.js";
3
+ import * as fs from "node:fs";
4
+ import * as os from "node:os";
5
+ import * as path from "node:path";
6
+ import { WebSocketServer } from "ws";
7
+ import { randomInt } from "node:crypto";
8
+
9
+ //#region src/install/migrate.ts
10
+ /**
11
+ * Legacy token-path migration.
12
+ *
13
+ * Earlier builds unconditionally placed the token at
14
+ * `~/.config/darwinium-portal-mcp/token` on every platform. Current builds use
15
+ * `env-paths` for platform-conventional paths (macOS `~/Library/Application
16
+ * Support/...`, Windows `%APPDATA%/.../Data`, Linux `~/.local/share/...`).
17
+ * On macOS/Windows the path changes; on Linux we silently skip migration.
18
+ *
19
+ * The migration runs once per `install` / `rotate-token` invocation (idempotent
20
+ * — no-op if old path absent OR new path already populated).
21
+ *
22
+ * Output lines:
23
+ * - Success: `✓ migrated token from <old path> to <new path>`
24
+ * - Partial: `⚠ migration partial — old token at <old path> still present; please remove manually.`
25
+ */
26
+ /** Legacy token path — hand-coded XDG-style. */
27
+ const LEGACY_TOKEN_PATH = path.join(os.homedir(), ".config", "darwinium-portal-mcp", "token");
28
+ /**
29
+ * Move the legacy token file to `newTokenPath` if appropriate.
30
+ *
31
+ * Idempotent: no-op when the old path is absent. When BOTH paths exist, log a
32
+ * warn line so the customer can resolve the partial-migration state manually
33
+ * (we never silently overwrite the new file).
34
+ *
35
+ * Linux: silently no-op — env-paths default places the data dir at
36
+ * `~/.local/share/darwinium-portal-mcp/`, distinct from the legacy
37
+ * `~/.config/darwinium-portal-mcp/`. The legacy layout only ever shipped to
38
+ * dev machines (macOS/Windows), so Linux deployments cannot have a stale
39
+ * legacy token to migrate.
40
+ */
41
+ function maybeMigrate(newTokenPath) {
42
+ if (process.platform === "linux") return;
43
+ if (!fs.existsSync(LEGACY_TOKEN_PATH)) return;
44
+ if (fs.existsSync(newTokenPath)) {
45
+ warn(`migration partial — old token at ${LEGACY_TOKEN_PATH} still present; please remove manually.`);
46
+ return;
47
+ }
48
+ fs.mkdirSync(path.dirname(newTokenPath), {
49
+ recursive: true,
50
+ mode: 448
51
+ });
52
+ fs.renameSync(LEGACY_TOKEN_PATH, newTokenPath);
53
+ ok(`migrated token from ${LEGACY_TOKEN_PATH} to ${newTokenPath}`);
54
+ }
55
+
56
+ //#endregion
57
+ //#region src/install/oobPair.ts
58
+ /**
59
+ * Foreground OOB pairing-WS server.
60
+ *
61
+ * `install` and `rotate-token` invoke this module to deliver the freshly
62
+ * generated 64-hex token to the popup over an OOB-authenticated channel:
63
+ *
64
+ * 1. Generate a 6-digit code with crypto.randomInt (CSPRNG; never Math.random).
65
+ * 2. Bind a short-lived WebSocketServer on 127.0.0.1:9224 that accepts ONLY
66
+ * the `pair.<6-digit>` subprotocol (parallel to production `serve`'s
67
+ * `tok.<hex>` lifecycle — separate WSS instance).
68
+ * 3. Print the boxed pairing prompt on stderr.
69
+ * 4. Wait up to 60s for a popup to upgrade with the matching code.
70
+ * - On match: send {type:'paired', token, version}; close 1000; resolve.
71
+ * - On mismatch: 4401 close + attempt counter (3-attempt lockout).
72
+ * - On expiry / lockout: print re-arm prompt; wait for stdin Enter to
73
+ * regenerate code + re-arm. Loop indefinitely until paired or SIGINT.
74
+ * 5. SIGINT: print the Ctrl+C abort lines + exit 130.
75
+ *
76
+ * Security:
77
+ * - `handleProtocols` returns ONLY `VERSION_SUBPROTOCOL` — never echoes the
78
+ * offered `pair.<code>` slot.
79
+ * - 3-attempt lockout × 60s window × CSPRNG 6-digit space (≈3×10⁻⁶ success
80
+ * per window) bounds the brute-force attack surface.
81
+ * - Pre-pair handleProtocols rejection consumes no app-level resources
82
+ * (HTTP 401 at upgrade boundary). Post-upgrade attempts are bounded by
83
+ * the counter.
84
+ *
85
+ * Pair handling lives here only — production `src/ws/server.ts` rejects
86
+ * `pair.*` shapes. The popup-side mirror lives in the portal-extension package.
87
+ */
88
+ const DEFAULT_PORT = 9224;
89
+ const DEFAULT_TTL_MS = 6e4;
90
+ const DEFAULT_MAX_ATTEMPTS = 3;
91
+ const PORT_HELD_MESSAGE = "Another MCP client is using the Darwinium bridge on port 9224. Close other Claude Desktop / Claude Code instances and retry, or run `lsof -i:9224` to find the holding process.";
92
+ /** 6-digit zero-padded random pairing code via CSPRNG. */
93
+ function generateCode() {
94
+ return String(randomInt(0, 1e6)).padStart(6, "0");
95
+ }
96
+ /** Print the boxed pairing prompt + waiting line. */
97
+ function printPairingPrompt(code, mode) {
98
+ if (mode === "install") {
99
+ info("pairing armed. Open the Darwinium Portal MCP extension popup");
100
+ console.error(" and enter this 6-digit code within 60 seconds.");
101
+ } else {
102
+ info("pairing armed. Open the Darwinium Portal MCP extension popup,");
103
+ console.error(" click \"Re-pair\", and enter this 6-digit code within 60 seconds.");
104
+ }
105
+ console.error("");
106
+ box([
107
+ "",
108
+ ` Pairing code: ${code}`,
109
+ ` Expires: 60s`,
110
+ ""
111
+ ]);
112
+ console.error("");
113
+ info("waiting for popup... (Ctrl+C to abort)");
114
+ }
115
+ /** Print the re-arm prompt after code expiry or lockout. */
116
+ function printRearmPrompt() {
117
+ info("code expired.");
118
+ info("press Enter to generate a new one, or Ctrl+C to abort.");
119
+ }
120
+ /** Wait for the user to press Enter (returns Promise<void>). */
121
+ function waitForEnter() {
122
+ return new Promise((resolve) => {
123
+ process.stdin.resume();
124
+ process.stdin.once("data", () => {
125
+ process.stdin.pause();
126
+ resolve();
127
+ });
128
+ });
129
+ }
130
+ /** Parse the offered subprotocol list from an upgrade request header. */
131
+ function offeredProtocols(req) {
132
+ const header = req.headers["sec-websocket-protocol"];
133
+ if (!header) return [];
134
+ return (Array.isArray(header) ? header.join(",") : header).split(",").map((s) => s.trim()).filter((s) => s.length > 0);
135
+ }
136
+ /** Reclaim port 9224 from any stale holder, then bail if still bound. */
137
+ async function reclaimPort(port) {
138
+ killProcessOnPort(port);
139
+ for (let i = 0; i < 50; i++) {
140
+ if (!await isPortInUse(port)) return;
141
+ await wait(100);
142
+ }
143
+ console.error(PORT_HELD_MESSAGE);
144
+ process.exit(1);
145
+ }
146
+ /**
147
+ * Run the foreground OOB pairing flow until a successful pair, expiry-without-
148
+ * Enter (treated as Ctrl+C), or SIGINT.
149
+ *
150
+ * Resolves with `{paired: true}` on success. Process-exits 130 on SIGINT.
151
+ * Process-exits 1 on unrecoverable port-bind failure.
152
+ */
153
+ async function run(opts) {
154
+ const port = opts.port ?? DEFAULT_PORT;
155
+ const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
156
+ const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
157
+ await reclaimPort(port);
158
+ const sigintHandler = () => {
159
+ console.error("");
160
+ info("aborted by user. No token was paired.");
161
+ info("re-run `npx @darwinium/portal-mcp install` to try again.");
162
+ process.exit(130);
163
+ };
164
+ process.on("SIGINT", sigintHandler);
165
+ let armedCode = null;
166
+ let attempts = 0;
167
+ return new Promise((resolve, reject) => {
168
+ const wss = new WebSocketServer({
169
+ port,
170
+ host: "127.0.0.1",
171
+ handleProtocols: (offered, _req) => {
172
+ const protocols = [...offered];
173
+ const hasVersion = protocols.includes(VERSION_SUBPROTOCOL);
174
+ const hasPairShape = protocols.some((p) => PAIRING_CODE_REGEX.test(p));
175
+ if (!hasVersion || !hasPairShape) return false;
176
+ return VERSION_SUBPROTOCOL;
177
+ }
178
+ });
179
+ let expiryTimer = null;
180
+ let expiryInProgress = false;
181
+ const armCode = () => {
182
+ armedCode = generateCode();
183
+ attempts = 0;
184
+ printPairingPrompt(armedCode, opts.mode);
185
+ if (expiryTimer) clearTimeout(expiryTimer);
186
+ expiryTimer = setTimeout(() => {
187
+ handleExpiry();
188
+ }, ttlMs);
189
+ };
190
+ const handleExpiry = async () => {
191
+ if (expiryInProgress) return;
192
+ expiryInProgress = true;
193
+ armedCode = null;
194
+ printRearmPrompt();
195
+ try {
196
+ await waitForEnter();
197
+ } catch (err) {
198
+ expiryInProgress = false;
199
+ reject(err);
200
+ return;
201
+ }
202
+ expiryInProgress = false;
203
+ armCode();
204
+ };
205
+ wss.on("connection", (ws, req) => {
206
+ const offeredPair = offeredProtocols(req).find((p) => PAIRING_CODE_REGEX.test(p));
207
+ if (armedCode === null) {
208
+ ws.close(WS_CLOSE_TOKEN_MISMATCH, "pairing not armed");
209
+ return;
210
+ }
211
+ if (offeredPair !== `pair.${armedCode}`) {
212
+ attempts += 1;
213
+ if (attempts >= maxAttempts) {
214
+ ws.close(WS_CLOSE_TOKEN_MISMATCH, "too many attempts");
215
+ warn(`wrong code (attempt ${attempts} of ${maxAttempts}).`);
216
+ if (expiryTimer) clearTimeout(expiryTimer);
217
+ armedCode = null;
218
+ handleExpiry();
219
+ return;
220
+ }
221
+ ws.close(WS_CLOSE_TOKEN_MISMATCH, "wrong code");
222
+ warn(`wrong code (attempt ${attempts} of ${maxAttempts}).`);
223
+ return;
224
+ }
225
+ const frame = JSON.stringify({
226
+ type: "paired",
227
+ token: opts.expectedToken,
228
+ version: PROTOCOL_VERSION
229
+ });
230
+ ws.send(frame);
231
+ ws.close(1e3, "paired");
232
+ if (expiryTimer) clearTimeout(expiryTimer);
233
+ armedCode = null;
234
+ process.removeListener("SIGINT", sigintHandler);
235
+ wss.close(() => {
236
+ resolve({ paired: true });
237
+ });
238
+ });
239
+ wss.on("error", (err) => {
240
+ reject(err);
241
+ });
242
+ wss.on("listening", () => {
243
+ armCode();
244
+ });
245
+ });
246
+ }
247
+
248
+ //#endregion
249
+ export { maybeMigrate as n, run as t };
250
+ //# sourceMappingURL=oobPair-ybkkqrSh.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"oobPair-ybkkqrSh.js","names":[],"sources":["../../src/install/migrate.ts","../../src/install/oobPair.ts"],"sourcesContent":["/**\n * Legacy token-path migration.\n *\n * Earlier builds unconditionally placed the token at\n * `~/.config/darwinium-portal-mcp/token` on every platform. Current builds use\n * `env-paths` for platform-conventional paths (macOS `~/Library/Application\n * Support/...`, Windows `%APPDATA%/.../Data`, Linux `~/.local/share/...`).\n * On macOS/Windows the path changes; on Linux we silently skip migration.\n *\n * The migration runs once per `install` / `rotate-token` invocation (idempotent\n * — no-op if old path absent OR new path already populated).\n *\n * Output lines:\n * - Success: `✓ migrated token from <old path> to <new path>`\n * - Partial: `⚠ migration partial — old token at <old path> still present; please remove manually.`\n */\nimport * as fs from 'node:fs';\nimport * as os from 'node:os';\nimport * as path from 'node:path';\nimport * as term from './term.js';\n\n/** Legacy token path — hand-coded XDG-style. */\nexport const LEGACY_TOKEN_PATH = path.join(os.homedir(), '.config', 'darwinium-portal-mcp', 'token');\n\n/**\n * Move the legacy token file to `newTokenPath` if appropriate.\n *\n * Idempotent: no-op when the old path is absent. When BOTH paths exist, log a\n * warn line so the customer can resolve the partial-migration state manually\n * (we never silently overwrite the new file).\n *\n * Linux: silently no-op — env-paths default places the data dir at\n * `~/.local/share/darwinium-portal-mcp/`, distinct from the legacy\n * `~/.config/darwinium-portal-mcp/`. The legacy layout only ever shipped to\n * dev machines (macOS/Windows), so Linux deployments cannot have a stale\n * legacy token to migrate.\n */\nexport function maybeMigrate(newTokenPath: string): void {\n if (process.platform === 'linux') return;\n if (!fs.existsSync(LEGACY_TOKEN_PATH)) return;\n if (fs.existsSync(newTokenPath)) {\n term.warn(`migration partial — old token at ${LEGACY_TOKEN_PATH} still present; please remove manually.`);\n return;\n }\n // Ensure parent dir exists with secure mode before the rename. mkdirSync is\n // idempotent on a present dir; mode is applied on creation only.\n fs.mkdirSync(path.dirname(newTokenPath), { recursive: true, mode: 0o700 });\n fs.renameSync(LEGACY_TOKEN_PATH, newTokenPath);\n term.ok(`migrated token from ${LEGACY_TOKEN_PATH} to ${newTokenPath}`);\n}\n","/**\n * Foreground OOB pairing-WS server.\n *\n * `install` and `rotate-token` invoke this module to deliver the freshly\n * generated 64-hex token to the popup over an OOB-authenticated channel:\n *\n * 1. Generate a 6-digit code with crypto.randomInt (CSPRNG; never Math.random).\n * 2. Bind a short-lived WebSocketServer on 127.0.0.1:9224 that accepts ONLY\n * the `pair.<6-digit>` subprotocol (parallel to production `serve`'s\n * `tok.<hex>` lifecycle — separate WSS instance).\n * 3. Print the boxed pairing prompt on stderr.\n * 4. Wait up to 60s for a popup to upgrade with the matching code.\n * - On match: send {type:'paired', token, version}; close 1000; resolve.\n * - On mismatch: 4401 close + attempt counter (3-attempt lockout).\n * - On expiry / lockout: print re-arm prompt; wait for stdin Enter to\n * regenerate code + re-arm. Loop indefinitely until paired or SIGINT.\n * 5. SIGINT: print the Ctrl+C abort lines + exit 130.\n *\n * Security:\n * - `handleProtocols` returns ONLY `VERSION_SUBPROTOCOL` — never echoes the\n * offered `pair.<code>` slot.\n * - 3-attempt lockout × 60s window × CSPRNG 6-digit space (≈3×10⁻⁶ success\n * per window) bounds the brute-force attack surface.\n * - Pre-pair handleProtocols rejection consumes no app-level resources\n * (HTTP 401 at upgrade boundary). Post-upgrade attempts are bounded by\n * the counter.\n *\n * Pair handling lives here only — production `src/ws/server.ts` rejects\n * `pair.*` shapes. The popup-side mirror lives in the portal-extension package.\n */\nimport { WebSocketServer, WebSocket } from 'ws';\nimport { randomInt } from 'node:crypto';\nimport { IncomingMessage } from 'node:http';\nimport { killProcessOnPort, isPortInUse, wait } from '../ws/killProcessOnPort.js';\nimport {\n PROTOCOL_VERSION,\n VERSION_SUBPROTOCOL,\n PAIRING_CODE_REGEX,\n WS_CLOSE_TOKEN_MISMATCH,\n} from '../bridge/wireProtocol.js';\nimport * as term from './term.js';\n\nconst DEFAULT_PORT = 9224;\nconst DEFAULT_TTL_MS = 60_000; // 60-second pairing window\nconst DEFAULT_MAX_ATTEMPTS = 3; // 3-attempt lockout\n\n// Same message as src/ws/server.ts. Duplicated here because we deliver\n// the message in the foreground install flow (PORT_HELD on `serve`'s server is\n// async; here it's synchronous to a customer staring at their terminal).\nconst PORT_HELD_MESSAGE =\n 'Another MCP client is using the Darwinium bridge on port 9224. ' +\n 'Close other Claude Desktop / Claude Code instances and retry, ' +\n 'or run `lsof -i:9224` to find the holding process.';\n\n/** Public API — caller picks the prompt-copy variant via `mode`. */\nexport interface OobPairOptions {\n /** Picks the prompt-verb copy: install (\"Open ... popup\") vs rotate (\"click Re-pair\"). */\n mode: 'install' | 'rotate';\n /** 64-hex token to deliver in the {type:'paired'} frame on successful pair. */\n expectedToken: string;\n /** Override the 9224 default — primarily for tests. */\n port?: number;\n /** Override the 60_000 default — primarily for tests. */\n ttlMs?: number;\n /** Override the 3 default — primarily for tests. */\n maxAttempts?: number;\n}\n\n/** Result returned on a successful pair (caller's promise resolves with this). */\nexport interface OobPairResult {\n paired: true;\n}\n\n/** 6-digit zero-padded random pairing code via CSPRNG. */\nfunction generateCode(): string {\n return String(randomInt(0, 1_000_000)).padStart(6, '0');\n}\n\n/** Print the boxed pairing prompt + waiting line. */\nfunction printPairingPrompt(code: string, mode: 'install' | 'rotate'): void {\n if (mode === 'install') {\n term.info('pairing armed. Open the Darwinium Portal MCP extension popup');\n console.error(' and enter this 6-digit code within 60 seconds.');\n } else {\n term.info('pairing armed. Open the Darwinium Portal MCP extension popup,');\n console.error(' click \"Re-pair\", and enter this 6-digit code within 60 seconds.');\n }\n console.error('');\n // Box content — inner padding column 19.\n // \" Pairing code: \" is 3 + 14 + 2 = 19 chars; code follows; row right-padded.\n // \" Expires: 60s\" — same column-19 left edge for vertical rhythm.\n term.box(['', ` Pairing code: ${code}`, ` Expires: 60s`, '']);\n console.error('');\n term.info('waiting for popup... (Ctrl+C to abort)');\n}\n\n/** Print the re-arm prompt after code expiry or lockout. */\nfunction printRearmPrompt(): void {\n // run() resolves on the first successful pair, so this prompt only fires\n // when no pair has happened in the current 60s window — a counter would\n // always print \"0 successful pairings\" and read like a bug to a customer.\n term.info('code expired.');\n term.info('press Enter to generate a new one, or Ctrl+C to abort.');\n}\n\n/** Wait for the user to press Enter (returns Promise<void>). */\nfunction waitForEnter(): Promise<void> {\n return new Promise((resolve) => {\n process.stdin.resume();\n // `once` instead of `on` so a duplicate invocation only attaches a\n // one-shot listener — the re-entrancy guard in handleExpiry already\n // prevents the duplicate, but defense-in-depth here means a future\n // caller cannot accidentally leak a stdin 'data' listener.\n process.stdin.once('data', () => {\n process.stdin.pause();\n resolve();\n });\n });\n}\n\n/** Parse the offered subprotocol list from an upgrade request header. */\nfunction offeredProtocols(req: IncomingMessage): string[] {\n const header = req.headers['sec-websocket-protocol'];\n if (!header) return [];\n // Header may be `string` (comma-joined) or `string[]` per Node ws semantics.\n const raw = Array.isArray(header) ? header.join(',') : header;\n return raw\n .split(',')\n .map((s) => s.trim())\n .filter((s) => s.length > 0);\n}\n\n/** Reclaim port 9224 from any stale holder, then bail if still bound. */\nasync function reclaimPort(port: number): Promise<void> {\n killProcessOnPort(port);\n for (let i = 0; i < 50; i++) {\n if (!(await isPortInUse(port))) return;\n await wait(100);\n }\n console.error(PORT_HELD_MESSAGE);\n process.exit(1);\n}\n\n/**\n * Run the foreground OOB pairing flow until a successful pair, expiry-without-\n * Enter (treated as Ctrl+C), or SIGINT.\n *\n * Resolves with `{paired: true}` on success. Process-exits 130 on SIGINT.\n * Process-exits 1 on unrecoverable port-bind failure.\n */\nexport async function run(opts: OobPairOptions): Promise<OobPairResult> {\n const port = opts.port ?? DEFAULT_PORT;\n const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;\n const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;\n\n await reclaimPort(port);\n\n // SIGINT — abort cleanly with a clear \"nothing was paired\" message.\n const sigintHandler = () => {\n console.error('');\n term.info('aborted by user. No token was paired.');\n term.info('re-run `npx @darwinium/portal-mcp install` to try again.');\n process.exit(130);\n };\n process.on('SIGINT', sigintHandler);\n\n let armedCode: string | null = null;\n let attempts = 0;\n\n return new Promise<OobPairResult>((resolve, reject) => {\n const wss = new WebSocketServer({\n port,\n host: '127.0.0.1',\n handleProtocols: (offered: Set<string>, _req) => {\n const protocols = [...offered];\n const hasVersion = protocols.includes(VERSION_SUBPROTOCOL);\n const hasPairShape = protocols.some((p) => PAIRING_CODE_REGEX.test(p));\n if (!hasVersion || !hasPairShape) return false; // → HTTP 401\n // Return ONLY the version subprotocol; NEVER echo the code.\n return VERSION_SUBPROTOCOL;\n },\n });\n\n let expiryTimer: NodeJS.Timeout | null = null;\n // Re-entrancy guard. clearTimeout cannot un-queue an already-fired\n // setTimeout callback, so the lockout-driven handleExpiry can race\n // the timer-driven handleExpiry. Without this flag, both invocations\n // would call waitForEnter(), register two stdin listeners, and leak\n // the one whose promise never resolves.\n let expiryInProgress = false;\n\n const armCode = (): void => {\n armedCode = generateCode();\n attempts = 0;\n printPairingPrompt(armedCode, opts.mode);\n if (expiryTimer) clearTimeout(expiryTimer);\n expiryTimer = setTimeout(() => {\n void handleExpiry();\n }, ttlMs);\n };\n\n const handleExpiry = async (): Promise<void> => {\n if (expiryInProgress) return;\n expiryInProgress = true;\n armedCode = null;\n printRearmPrompt();\n try {\n await waitForEnter();\n } catch (err) {\n expiryInProgress = false;\n reject(err as Error);\n return;\n }\n expiryInProgress = false;\n armCode();\n };\n\n wss.on('connection', (ws: WebSocket, req: IncomingMessage) => {\n const protocols = offeredProtocols(req);\n const offeredPair = protocols.find((p) => PAIRING_CODE_REGEX.test(p));\n if (armedCode === null) {\n ws.close(WS_CLOSE_TOKEN_MISMATCH, 'pairing not armed');\n return;\n }\n if (offeredPair !== `pair.${armedCode}`) {\n attempts += 1;\n if (attempts >= maxAttempts) {\n ws.close(WS_CLOSE_TOKEN_MISMATCH, 'too many attempts');\n term.warn(`wrong code (attempt ${attempts} of ${maxAttempts}).`);\n // Trigger expiry path immediately — re-arm gates on Enter.\n if (expiryTimer) clearTimeout(expiryTimer);\n armedCode = null;\n void handleExpiry();\n return;\n }\n ws.close(WS_CLOSE_TOKEN_MISMATCH, 'wrong code');\n term.warn(`wrong code (attempt ${attempts} of ${maxAttempts}).`);\n return;\n }\n // Match — deliver token and close cleanly.\n const frame = JSON.stringify({\n type: 'paired',\n token: opts.expectedToken,\n version: PROTOCOL_VERSION,\n });\n ws.send(frame);\n ws.close(1000, 'paired');\n if (expiryTimer) clearTimeout(expiryTimer);\n armedCode = null;\n process.removeListener('SIGINT', sigintHandler);\n // Close the server BEFORE resolving so port 9224 is released for any\n // subsequent `serve` start that the customer may run.\n wss.close(() => {\n resolve({ paired: true });\n });\n });\n\n wss.on('error', (err: Error) => {\n reject(err);\n });\n\n wss.on('listening', () => {\n armCode();\n });\n });\n}\n"],"mappings":"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"}
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+
4
+ //#region src/version.ts
5
+ const VERSION = "0.0.2";
6
+
7
+ //#endregion
8
+ //#region src/bin/portal-mcp.ts
9
+ console.log = console.error;
10
+ console.info = console.error;
11
+ console.debug = console.error;
12
+ console.warn = console.error;
13
+ const program = new Command();
14
+ program.name("portal-mcp").version(VERSION);
15
+ program.command("serve", { isDefault: true }).description("Run as MCP stdio server (default subcommand). Binds WS server on 127.0.0.1:9224 for the extension bridge.").action(async () => {
16
+ const { runServer } = await import("./server-CuNr9ZtJ.js");
17
+ await runServer();
18
+ });
19
+ program.command("install").description("Install Darwinium Portal MCP — writes binary, token, config, and OOB-pairs the extension").action(async () => {
20
+ const { runInstall } = await import("./install-BhzwhhI-.js");
21
+ await runInstall({});
22
+ });
23
+ program.command("doctor").description("Self-diagnostic: pass/fail across binary, token, config, extension, port").option("--json", "Emit machine-readable JSON for support tickets").action(async (opts) => {
24
+ const { runDoctor } = await import("./doctor-D7BbIXN2.js");
25
+ await runDoctor(opts);
26
+ });
27
+ program.command("rotate-token").description("Generate a new token + fresh OOB pairing code; old token rejects with WS 4401").action(async () => {
28
+ const { runRotateToken } = await import("./rotate-Dzf8pwua.js");
29
+ await runRotateToken();
30
+ });
31
+ program.parseAsync(process.argv).catch((err) => {
32
+ console.error(`portal-mcp fatal: ${err.message}`);
33
+ process.exit(1);
34
+ });
35
+
36
+ //#endregion
37
+ export { VERSION as t };
38
+ //# sourceMappingURL=portal-mcp.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"portal-mcp.js","names":[],"sources":["../../src/version.ts","../../src/bin/portal-mcp.ts"],"sourcesContent":["// AUTO-GENERATED by scripts/gen-version.mjs — do not edit by hand.\n// Regenerate with `yarn gen-version`; `yarn build` does it for you.\n//\n// This is the RELEASE version. For the bridge frame-format version see\n// PROTOCOL_VERSION in src/bridge/wireProtocol.ts — they are independent.\nexport const VERSION = '0.0.2';\n","#!/usr/bin/env node\n\n// STDOUT DISCIPLINE — MUST be the FIRST executable statements before any imports\n// that may log on load. Any console.log / .info / .debug / .warn is re-routed to\n// stderr to keep stdout pure JSON-RPC for MCP host parsing.\nconsole.log = console.error;\nconsole.info = console.error;\nconsole.debug = console.error;\nconsole.warn = console.error;\n\nimport { Command } from 'commander';\n\nimport { VERSION } from '../version.js';\n\nconst program = new Command();\nprogram.name('portal-mcp').version(VERSION);\n\nprogram\n .command('serve', { isDefault: true })\n .description(\n 'Run as MCP stdio server (default subcommand). Binds WS server on 127.0.0.1:9224 for the extension bridge.',\n )\n .action(async () => {\n const { runServer } = await import('../server/index.js');\n await runServer();\n });\n\nprogram\n .command('install')\n .description('Install Darwinium Portal MCP — writes binary, token, config, and OOB-pairs the extension')\n .action(async () => {\n const { runInstall } = await import('../install/install.js');\n await runInstall({});\n });\n\nprogram\n .command('doctor')\n .description('Self-diagnostic: pass/fail across binary, token, config, extension, port')\n .option('--json', 'Emit machine-readable JSON for support tickets')\n .action(async (opts: { json?: boolean }) => {\n const { runDoctor } = await import('../install/doctor.js');\n await runDoctor(opts);\n });\n\nprogram\n .command('rotate-token')\n .description('Generate a new token + fresh OOB pairing code; old token rejects with WS 4401')\n .action(async () => {\n const { runRotateToken } = await import('../install/rotate.js');\n await runRotateToken();\n });\n\nprogram.parseAsync(process.argv).catch((err) => {\n // Unhandled errors must go to stderr so stdout stays clean.\n console.error(`portal-mcp fatal: ${(err as Error).message}`);\n process.exit(1);\n});\n"],"mappings":";;;;AAKA,MAAa,UAAU;;;;ACAvB,QAAQ,MAAM,QAAQ;AACtB,QAAQ,OAAO,QAAQ;AACvB,QAAQ,QAAQ,QAAQ;AACxB,QAAQ,OAAO,QAAQ;AAMvB,MAAM,UAAU,IAAI,SAAS;AAC7B,QAAQ,KAAK,aAAa,CAAC,QAAQ,QAAQ;AAE3C,QACG,QAAQ,SAAS,EAAE,WAAW,MAAM,CAAC,CACrC,YACC,4GACD,CACA,OAAO,YAAY;CAClB,MAAM,EAAE,cAAc,MAAM,OAAO;AACnC,OAAM,WAAW;EACjB;AAEJ,QACG,QAAQ,UAAU,CAClB,YAAY,2FAA2F,CACvG,OAAO,YAAY;CAClB,MAAM,EAAE,eAAe,MAAM,OAAO;AACpC,OAAM,WAAW,EAAE,CAAC;EACpB;AAEJ,QACG,QAAQ,SAAS,CACjB,YAAY,2EAA2E,CACvF,OAAO,UAAU,iDAAiD,CAClE,OAAO,OAAO,SAA6B;CAC1C,MAAM,EAAE,cAAc,MAAM,OAAO;AACnC,OAAM,UAAU,KAAK;EACrB;AAEJ,QACG,QAAQ,eAAe,CACvB,YAAY,gFAAgF,CAC5F,OAAO,YAAY;CAClB,MAAM,EAAE,mBAAmB,MAAM,OAAO;AACxC,OAAM,gBAAgB;EACtB;AAEJ,QAAQ,WAAW,QAAQ,KAAK,CAAC,OAAO,QAAQ;AAE9C,SAAQ,MAAM,qBAAsB,IAAc,UAAU;AAC5D,SAAQ,KAAK,EAAE;EACf"}
@@ -0,0 +1,64 @@
1
+ import { i as EXTENSION_DIR, o as TOKEN_PATH } from "./killProcessOnPort-Bt5MA5Dv.js";
2
+ import { a as info, c as lockAcl, o as ok, s as warn } from "./term-Bg7icwx7.js";
3
+ import { r as regenerate } from "./store-B3e4WCLw.js";
4
+ import { n as maybeMigrate, t as run } from "./oobPair-ybkkqrSh.js";
5
+ import * as fs from "node:fs";
6
+ import * as path from "node:path";
7
+
8
+ //#region src/install/rotate.ts
9
+ /**
10
+ * `npx @darwinium/portal-mcp rotate-token` — token rotation.
11
+ *
12
+ * Sequence:
13
+ * 1. migrate.maybeMigrate() — in case the customer is rotating from a
14
+ * stale legacy install they never ran `install` against.
15
+ * 2. If TOKEN_PATH is absent: warn + delegate to runInstall.
16
+ * 3. token.regenerate({silent:true}) — fresh 64-hex via TOCTOU-safe wx+0o600.
17
+ * 4. winAcl.lockAcl(TOKEN_PATH) — re-lock the new file on Windows.
18
+ * 5. oobPair.run({mode:'rotate', expectedToken: newToken}) — same flow as
19
+ * install but with the "click Re-pair" prompt-copy variant.
20
+ * 6. Success summary + process.exit(0).
21
+ *
22
+ * Security:
23
+ * - Never echoes the new token (silent mode).
24
+ * - The audit-line "generated new token at <path>" fires inside
25
+ * generateAndWrite() so log-aggregators see the rotation.
26
+ *
27
+ * Old-token rejection is enforced by the next `serve` start — the WS
28
+ * hello-frame check fails for the old token because TOKEN_PATH now holds
29
+ * the new one. The smoke test verifies this.
30
+ */
31
+ async function runRotateToken() {
32
+ info("rotate-token starting...");
33
+ maybeMigrate(TOKEN_PATH);
34
+ if (!fs.existsSync(TOKEN_PATH)) {
35
+ warn("no existing token to rotate. Running install instead.");
36
+ const { runInstall } = await import("./install-BhzwhhI-.js");
37
+ return runInstall({});
38
+ }
39
+ const token = regenerate({ silent: true });
40
+ const aclSuffix = process.platform === "win32" ? ", ACL locked" : "";
41
+ ok(`token (regenerated at ${TOKEN_PATH}, mode 0600${aclSuffix})`);
42
+ if (process.platform === "win32") {
43
+ const r = lockAcl(TOKEN_PATH);
44
+ if (!r.ok) warn(`Could not restrict ACLs: ${r.error}. Run \`${r.cmd}\` manually.`);
45
+ }
46
+ console.error("");
47
+ await run({
48
+ mode: "rotate",
49
+ expectedToken: token
50
+ });
51
+ let extVersion = "";
52
+ const manifestPath = path.join(EXTENSION_DIR, "manifest.json");
53
+ if (fs.existsSync(manifestPath)) try {
54
+ extVersion = JSON.parse(fs.readFileSync(manifestPath, "utf8")).version;
55
+ } catch {}
56
+ if (extVersion) ok(`paired with extension (extension version ${extVersion})`);
57
+ else ok("paired with extension");
58
+ info("rotate complete. The old token is no longer accepted.");
59
+ process.exit(0);
60
+ }
61
+
62
+ //#endregion
63
+ export { runRotateToken };
64
+ //# sourceMappingURL=rotate-Dzf8pwua.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rotate-Dzf8pwua.js","names":["winAcl.lockAcl","oobPair.run"],"sources":["../../src/install/rotate.ts"],"sourcesContent":["/**\n * `npx @darwinium/portal-mcp rotate-token` — token rotation.\n *\n * Sequence:\n * 1. migrate.maybeMigrate() — in case the customer is rotating from a\n * stale legacy install they never ran `install` against.\n * 2. If TOKEN_PATH is absent: warn + delegate to runInstall.\n * 3. token.regenerate({silent:true}) — fresh 64-hex via TOCTOU-safe wx+0o600.\n * 4. winAcl.lockAcl(TOKEN_PATH) — re-lock the new file on Windows.\n * 5. oobPair.run({mode:'rotate', expectedToken: newToken}) — same flow as\n * install but with the \"click Re-pair\" prompt-copy variant.\n * 6. Success summary + process.exit(0).\n *\n * Security:\n * - Never echoes the new token (silent mode).\n * - The audit-line \"generated new token at <path>\" fires inside\n * generateAndWrite() so log-aggregators see the rotation.\n *\n * Old-token rejection is enforced by the next `serve` start — the WS\n * hello-frame check fails for the old token because TOKEN_PATH now holds\n * the new one. The smoke test verifies this.\n */\nimport * as fs from 'node:fs';\nimport * as path from 'node:path';\nimport { TOKEN_PATH, EXTENSION_DIR } from '../token/paths.js';\nimport { regenerate } from '../token/store.js';\nimport * as term from './term.js';\nimport * as migrate from './migrate.js';\nimport * as winAcl from './winAcl.js';\nimport * as oobPair from './oobPair.js';\n\nexport async function runRotateToken(): Promise<void> {\n term.info('rotate-token starting...');\n\n // 1. Idempotent legacy token-path migration (in case install was never run).\n migrate.maybeMigrate(TOKEN_PATH);\n\n // 2. No-existing-token early branch — delegate to install.\n if (!fs.existsSync(TOKEN_PATH)) {\n term.warn('no existing token to rotate. Running install instead.');\n const { runInstall } = await import('./install.js');\n return runInstall({});\n }\n\n // 3. Regenerate (silent — token is delivered via OOB pairing).\n const token = regenerate({ silent: true });\n const aclSuffix = process.platform === 'win32' ? ', ACL locked' : '';\n term.ok(`token (regenerated at ${TOKEN_PATH}, mode 0600${aclSuffix})`);\n\n // 4. Re-apply Windows ACL on the freshly created file. unlink + re-create\n // resets ACLs to inherited; we must re-lock.\n if (process.platform === 'win32') {\n const r = winAcl.lockAcl(TOKEN_PATH);\n if (!r.ok) {\n term.warn(`Could not restrict ACLs: ${r.error}. Run \\`${r.cmd}\\` manually.`);\n }\n }\n\n console.error('');\n\n // 5. OOB pair — `mode:'rotate'` picks the \"click Re-pair\" prompt copy.\n await oobPair.run({ mode: 'rotate', expectedToken: token });\n\n // 6. Success summary — include extension version when extension was previously extracted.\n let extVersion = '';\n const manifestPath = path.join(EXTENSION_DIR, 'manifest.json');\n if (fs.existsSync(manifestPath)) {\n try {\n extVersion = JSON.parse(fs.readFileSync(manifestPath, 'utf8')).version as string;\n } catch {\n /* versionless success line is fine */\n }\n }\n if (extVersion) {\n term.ok(`paired with extension (extension version ${extVersion})`);\n } else {\n term.ok('paired with extension');\n }\n term.info('rotate complete. The old token is no longer accepted.');\n process.exit(0);\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+BA,eAAsB,iBAAgC;AACpD,MAAU,2BAA2B;AAGrC,cAAqB,WAAW;AAGhC,KAAI,CAAC,GAAG,WAAW,WAAW,EAAE;AAC9B,OAAU,wDAAwD;EAClE,MAAM,EAAE,eAAe,MAAM,OAAO;AACpC,SAAO,WAAW,EAAE,CAAC;;CAIvB,MAAM,QAAQ,WAAW,EAAE,QAAQ,MAAM,CAAC;CAC1C,MAAM,YAAY,QAAQ,aAAa,UAAU,iBAAiB;AAClE,IAAQ,yBAAyB,WAAW,aAAa,UAAU,GAAG;AAItE,KAAI,QAAQ,aAAa,SAAS;EAChC,MAAM,IAAIA,QAAe,WAAW;AACpC,MAAI,CAAC,EAAE,GACL,MAAU,4BAA4B,EAAE,MAAM,UAAU,EAAE,IAAI,cAAc;;AAIhF,SAAQ,MAAM,GAAG;AAGjB,OAAMC,IAAY;EAAE,MAAM;EAAU,eAAe;EAAO,CAAC;CAG3D,IAAI,aAAa;CACjB,MAAM,eAAe,KAAK,KAAK,eAAe,gBAAgB;AAC9D,KAAI,GAAG,WAAW,aAAa,CAC7B,KAAI;AACF,eAAa,KAAK,MAAM,GAAG,aAAa,cAAc,OAAO,CAAC,CAAC;SACzD;AAIV,KAAI,WACF,IAAQ,4CAA4C,WAAW,GAAG;KAElE,IAAQ,wBAAwB;AAElC,MAAU,wDAAwD;AAClE,SAAQ,KAAK,EAAE"}
@@ -0,0 +1,224 @@
1
+ import { d as TOKEN_SUBPROTOCOL_REGEX, f as VERSION_SUBPROTOCOL, n as killProcessOnPort, r as wait, s as HELLO_TIMEOUT_MS, t as isPortInUse, u as TOKEN_SUBPROTOCOL_PREFIX } from "./killProcessOnPort-Bt5MA5Dv.js";
2
+ import * as net from "node:net";
3
+ import { WebSocketServer } from "ws";
4
+ import { randomBytes } from "node:crypto";
5
+
6
+ //#region src/ws/server.ts
7
+ const WS_PORT = 9224;
8
+ const WS_HOST = "127.0.0.1";
9
+ /** How long the peer probe waits for a WS upgrade before calling the holder foreign. */
10
+ const PEER_PROBE_TIMEOUT_MS = 1500;
11
+ /** Interval between takeover attempts while another instance owns the port. */
12
+ const TAKEOVER_POLL_MS = 1e3;
13
+ /** Pending takeover timer, so shutdown can cancel it and let the process exit. */
14
+ let takeoverTimer;
15
+ /** Cancel any pending takeover poll. Called on MCP transport close. */
16
+ function stopTakeoverPolling() {
17
+ if (takeoverTimer) {
18
+ clearTimeout(takeoverTimer);
19
+ takeoverTimer = void 0;
20
+ }
21
+ }
22
+ /**
23
+ * Is the process holding WS_PORT a live portal-mcp that would accept our token?
24
+ *
25
+ * Distinguishes "a peer instance is legitimately serving the bridge" from "a
26
+ * zombie or unrelated process is squatting on the port". Only the latter may be
27
+ * killed — see createWsServer. A false negative here is destructive: it routes a
28
+ * healthy peer into the kill branch.
29
+ *
30
+ * Speaks the upgrade by hand over a raw TCP socket rather than using the `ws`
31
+ * client. Under `bun build --compile` the `ws` package's CLIENT is replaced by
32
+ * Bun's native WebSocket, which cannot connect to our own server (immediate
33
+ * 1006) — so a `ws`-based probe reports "not a peer" for every peer in the
34
+ * shipped binary, while passing under plain node and JIT bun. `node:net` plus a
35
+ * literal handshake behaves identically across all three. The `ws`
36
+ * WebSocketServer used below is unaffected; only outbound clients are.
37
+ *
38
+ * Deliberately does NOT send the `hello` frame: hello is what makes the peer
39
+ * call bridge.attach(), which would displace the browser extension's socket and
40
+ * break the very session we are trying not to disturb. A 101 is the signal; the
41
+ * peer drops the silent socket a second later, which is harmless.
42
+ */
43
+ function probeBridgeHandshake(token) {
44
+ return new Promise((resolve) => {
45
+ let settled = false;
46
+ const socket = net.createConnection({
47
+ host: WS_HOST,
48
+ port: WS_PORT
49
+ });
50
+ const done = (isPeer) => {
51
+ if (settled) return;
52
+ settled = true;
53
+ clearTimeout(timer);
54
+ socket.destroy();
55
+ resolve(isPeer);
56
+ };
57
+ const timer = setTimeout(() => done(false), PEER_PROBE_TIMEOUT_MS);
58
+ socket.on("connect", () => {
59
+ socket.write(`GET / HTTP/1.1\r
60
+ Host: ${WS_HOST}:${WS_PORT}\r\nUpgrade: websocket\r
61
+ Connection: Upgrade\r
62
+ Sec-WebSocket-Key: ${randomBytes(16).toString("base64")}\r\nSec-WebSocket-Version: 13\r
63
+ Sec-WebSocket-Protocol: ${VERSION_SUBPROTOCOL}, ${TOKEN_SUBPROTOCOL_PREFIX}${token}\r\n\r
64
+ `);
65
+ });
66
+ let head = "";
67
+ socket.on("data", (chunk) => {
68
+ head += chunk.toString("latin1");
69
+ const end = head.indexOf("\r\n\r\n");
70
+ if (end === -1) {
71
+ if (head.length > 8192) done(false);
72
+ return;
73
+ }
74
+ const status = head.slice(0, end).toLowerCase();
75
+ done(status.startsWith("http/1.1 101") && status.includes("darwinium.v1".toLowerCase()));
76
+ });
77
+ socket.on("error", () => done(false));
78
+ socket.on("close", () => done(false));
79
+ });
80
+ }
81
+ /**
82
+ * Bind the WS server on 127.0.0.1:9224, or degrade gracefully if we cannot.
83
+ *
84
+ * Port ownership is a race between instances, so the outcome is one of three:
85
+ * - port free → bind, this process owns the bridge.
86
+ * - held by a live peer → do NOT kill it. Serve MCP without the bridge and
87
+ * poll to take over when the peer exits.
88
+ * - held by a zombie /
89
+ * foreign process → killProcessOnPort + poll, then bind.
90
+ *
91
+ * Returns null in the degraded case; the process keeps serving MCP over stdio
92
+ * either way, and never exits on a port conflict.
93
+ *
94
+ * Two-phase token-gated handshake:
95
+ * 1. `handleProtocols` SHAPE GATE — accepts only offered subprotocol arrays that
96
+ * include `darwinium.v1` AND a `tok.*` entry; returns ONLY `'darwinium.v1'` in
97
+ * the response Sec-WebSocket-Protocol header (NEVER echoes the token —
98
+ * subprotocol headers are logged by browsers/proxies).
99
+ * Mismatched shape → HTTP 401 on upgrade (distinct from post-upgrade WS 4401).
100
+ * 2. POST-UPGRADE TOKEN CHECK — `wss.on('connection')` starts a 1s timer for the
101
+ * `{type:'hello', token, version}` first frame. Mismatch / no-hello / malformed
102
+ * JSON → `ws.close(4401, ...)` produces a real WS close event.
103
+ *
104
+ * The bridge takes ownership of `ws` only after a successful hello check.
105
+ */
106
+ async function createWsServer(bridge, expectedToken) {
107
+ if (await isPortInUse(9224)) {
108
+ if (await probeBridgeHandshake(expectedToken)) {
109
+ console.error("portal-mcp: another portal-mcp instance owns ws://127.0.0.1:9224 — serving MCP without the browser bridge; will take over if it exits.");
110
+ bridge.setOwnsPort(false);
111
+ scheduleTakeover(bridge, expectedToken);
112
+ return null;
113
+ }
114
+ killProcessOnPort(WS_PORT);
115
+ for (let i = 0; i < 50; i++) {
116
+ if (!await isPortInUse(9224)) break;
117
+ await wait(100);
118
+ }
119
+ }
120
+ const wss = await bindWsServer(bridge, expectedToken);
121
+ if (!wss) {
122
+ console.error("portal-mcp: could not bind ws://127.0.0.1:9224 — serving MCP without the browser bridge; will retry.");
123
+ bridge.setOwnsPort(false);
124
+ scheduleTakeover(bridge, expectedToken);
125
+ return null;
126
+ }
127
+ bridge.setOwnsPort(true);
128
+ return wss;
129
+ }
130
+ /**
131
+ * Retry the bind every TAKEOVER_POLL_MS until it succeeds, then mark this
132
+ * process the owner.
133
+ *
134
+ * Deliberately NOT unref'd. In the degraded path there is no WebSocketServer
135
+ * holding the event loop open, so an unref'd timer lets the process fall out of
136
+ * the loop the moment it finishes answering `initialize` — the MCP host sees
137
+ * "Connection closed" a second after launch. Lifecycle is instead bound to the
138
+ * MCP transport: runServer cancels this on close so the process still exits
139
+ * promptly when the host goes away.
140
+ */
141
+ function scheduleTakeover(bridge, expectedToken) {
142
+ takeoverTimer = setTimeout(() => {
143
+ (async () => {
144
+ if (await isPortInUse(9224)) {
145
+ scheduleTakeover(bridge, expectedToken);
146
+ return;
147
+ }
148
+ if (await bindWsServer(bridge, expectedToken)) {
149
+ takeoverTimer = void 0;
150
+ bridge.setOwnsPort(true);
151
+ console.error("portal-mcp: took over ws://127.0.0.1:9224 from the previous instance.");
152
+ return;
153
+ }
154
+ scheduleTakeover(bridge, expectedToken);
155
+ })();
156
+ }, TAKEOVER_POLL_MS);
157
+ }
158
+ /**
159
+ * Bind the listener and wire the handshake handlers. Resolves null on a bind
160
+ * failure (EADDRINUSE) so callers can degrade instead of crashing.
161
+ */
162
+ function bindWsServer(bridge, expectedToken) {
163
+ return new Promise((resolve) => {
164
+ let settled = false;
165
+ const wss = new WebSocketServer({
166
+ port: WS_PORT,
167
+ host: WS_HOST,
168
+ handleProtocols: (offered, _req) => {
169
+ const protocols = [...offered];
170
+ const hasVersion = protocols.includes(VERSION_SUBPROTOCOL);
171
+ const hasTokenShape = protocols.some((p) => TOKEN_SUBPROTOCOL_REGEX.test(p));
172
+ if (!hasVersion || !hasTokenShape) return false;
173
+ return VERSION_SUBPROTOCOL;
174
+ }
175
+ });
176
+ wss.on("listening", () => {
177
+ console.error(`portal-mcp: WS server listening on ws://${WS_HOST}:${WS_PORT}`);
178
+ if (!settled) {
179
+ settled = true;
180
+ resolve(wss);
181
+ }
182
+ });
183
+ wss.on("connection", (ws, _req) => {
184
+ let helloOk = false;
185
+ const helloTimer = setTimeout(() => {
186
+ if (!helloOk) ws.close(4401, "no hello frame");
187
+ }, HELLO_TIMEOUT_MS);
188
+ ws.once("message", (raw) => {
189
+ try {
190
+ const msg = JSON.parse(raw.toString());
191
+ if (msg.type !== "hello" || msg.token !== expectedToken) {
192
+ clearTimeout(helloTimer);
193
+ ws.close(4401, "invalid token");
194
+ console.error("portal-mcp: WS rejected — hello mismatch");
195
+ return;
196
+ }
197
+ helloOk = true;
198
+ clearTimeout(helloTimer);
199
+ console.error("portal-mcp: WS client authenticated");
200
+ bridge.attach(ws);
201
+ } catch {
202
+ clearTimeout(helloTimer);
203
+ ws.close(4401, "malformed hello");
204
+ }
205
+ });
206
+ ws.on("close", (code) => console.error(`portal-mcp: WS client disconnected (code=${code})`));
207
+ ws.on("error", (err) => console.error(`portal-mcp: WS error: ${err.message}`));
208
+ });
209
+ wss.on("error", (err) => {
210
+ console.error(`portal-mcp: WS server error: ${err.message}`);
211
+ if (!settled) {
212
+ settled = true;
213
+ try {
214
+ wss.close();
215
+ } catch {}
216
+ resolve(null);
217
+ }
218
+ });
219
+ });
220
+ }
221
+
222
+ //#endregion
223
+ export { probeBridgeHandshake as n, stopTakeoverPolling as r, createWsServer as t };
224
+ //# sourceMappingURL=server-Beyo9MT8.js.map