@darwinium/portal-mcp 0.0.2 → 0.0.3

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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Connect Claude Desktop or Claude Code to your active `*.darwinium.com` portal tab. Three tools, zero backend connections, OOB-paired to your existing portal session.
4
4
 
5
- For the full guide, visit **https://darwinium.com/portal-mcp**.
5
+ For the full guide, visit **https://www.darwinium.com**. (A dedicated docs site at `darwinium.com/portal-mcp` is coming; until then this README is the reference.)
6
6
 
7
7
  ---
8
8
 
@@ -30,8 +30,7 @@ The install is two short steps: register the MCP server with your LLM client, th
30
30
 
31
31
  Pick the path your environment allows:
32
32
 
33
- - **Chrome Web Store** (recommended for unmanaged Chrome): listing pending review — see
34
- https://darwinium.com/portal-mcp/chrome-extension for the current link
33
+ - **Chrome Web Store** (recommended for unmanaged Chrome): listing pending review
35
34
  - **Unpacked** (locked corporate Chrome): run the installer in Step 2 first; it extracts a versioned copy of the extension to your platform's app-data directory and prints the exact path. Then in Chrome:
36
35
  1. Visit `chrome://extensions`
37
36
  2. Enable **Developer Mode** (top right)
@@ -145,7 +144,7 @@ The MCP server returns four user-actionable error responses:
145
144
 
146
145
  ## Privacy Policy
147
146
 
148
- Full policy: **https://darwinium.com/portal-mcp/privacy**. Summary below.
147
+ Full policy: **https://www.darwinium.com/privacy-policy**. Summary below.
149
148
 
150
149
  ### What this software collects
151
150
 
@@ -203,7 +202,7 @@ Privacy questions: **privacy@darwinium.com**. Issues:
203
202
  - **"`claude_desktop_config.json` is not valid JSON"** — fix the syntax (or delete the file) and re-run `install`. Your previous config is at `claude_desktop_config.json.bak`.
204
203
  - **"`port 9224` is already in use"** (`port.9224.bindable` doctor check fails) — another `portal-mcp serve` instance is running, or another tool has bound the port. Quit the other instance, or `lsof -i :9224` (macOS / Linux) / `netstat -ano | findstr :9224` (Windows) to find the offender.
205
204
 
206
- For more, see **https://darwinium.com/portal-mcp/troubleshooting**.
205
+ For more, contact us at **https://www.darwinium.com/contact-us**.
207
206
 
208
207
  ## Building the macOS hand-over bundle (internal)
209
208
 
@@ -220,9 +219,10 @@ Requires [bun](https://bun.sh) on PATH. Output lands in `build/` (gitignored):
220
219
 
221
220
  - `DarwiniumPortalMCP-<version>-macos-<arch>.zip` — the file you hand over
222
221
  - `DarwiniumPortalMCP/1 - Darwinium Portal MCP.mcpb` — double-click installs the MCP server
223
- into Claude Desktop. It carries a `bun build --compile` standalone binary, because Claude
224
- Desktop does not ship a Node runtime for mcpb extensions it searches PATH for one, which
225
- finds nothing on a machine without developer tooling.
222
+ into Claude Desktop. The hand-over bundle carries a `bun build --compile` standalone binary
223
+ so it is entirely self-contained for a non-technical recipient. The bundle published to the
224
+ extension directory (`yarn package:mcpb`) uses `server.type: "node"` instead: Claude Desktop
225
+ ships its own Node runtime, so one ~320KB file installs on macOS, Windows and Linux.
226
226
  - `DarwiniumPortalMCP/2 - chrome-extension/` — the Load Unpacked target, at a space-free path
227
227
  - `DarwiniumPortalMCP/START-HERE.html` — the end-user guide
228
228
  - `Check-Setup.command` / `Fix-Permissions.command` — support escape hatches
@@ -1 +1 @@
1
- {"version":3,"file":"install-BhzwhhI-.js","names":["winAcl.lockAcl","extract.extractBundledExtension","oobPair.run"],"sources":["../../src/install/extract.ts","../../src/install/configMerge.ts","../../src/install/install.ts"],"sourcesContent":["/**\n * Bundled-extension extractor.\n *\n * The npm-published binary ships with a copy of the WXT-built portal-extension\n * zip at `extension-bundle/extension.zip` (placed there by\n * `scripts/bundle-extension.ts` during prebuild). At install time we extract it\n * to `<env-paths data dir>/extension/` so customers can load it via Chrome's\n * \"Load Unpacked\" path on locked corporate machines that can't use the Web\n * Store.\n *\n * Idempotency: if the target directory already contains a manifest.json\n * with the same version as the bundled zip, we skip extraction so re-running\n * `install` doesn't churn the extracted dir. Drift (different version) is\n * handled by overwriting (with `extractAllTo(dir, true, false)`).\n *\n * Security: adm-zip's `extractAllTo(target, true, false)` does NOT preserve\n * original permissions and resolves entry paths relative to the extraction\n * target — no traversal beyond `targetDir`. A symlink pre-planted at the\n * target is risk-accepted.\n */\nimport AdmZip from 'adm-zip';\nimport * as fs from 'node:fs';\nimport * as path from 'node:path';\n\n/** Outcome of `extractBundledExtension(zip, target)`. */\nexport interface ExtractResult {\n /** True if the target dir was actually written (false = idempotent skip). */\n extracted: boolean;\n /** Manifest version of the bundled extension (always populated). */\n version: string;\n}\n\n// Validate that `json` parses to an object whose `version` field is a non-empty\n// string. Without this, an `as string` cast lets `version: undefined` flow into\n// the idempotency check, where `undefined === undefined` is true and every\n// re-install silently skips extraction.\nfunction readManifestVersion(json: string, source: string): string {\n let parsed: unknown;\n try {\n parsed = JSON.parse(json);\n } catch {\n throw new Error(`${source}: manifest.json is not valid JSON`);\n }\n if (!parsed || typeof parsed !== 'object') {\n throw new Error(`${source}: manifest.json is not a JSON object`);\n }\n const v = (parsed as { version?: unknown }).version;\n if (typeof v !== 'string' || v.length === 0) {\n throw new Error(`${source}: manifest.json missing version string`);\n }\n return v;\n}\n\n/**\n * Extract `zipPath` into `targetDir`, skipping when the existing manifest\n * version matches the incoming version.\n *\n * Throws if the bundled zip is missing `manifest.json` or its version field\n * is missing/non-string (catastrophic build-time mistake — caller maps to\n * the `extract failed: ...` line and exits 1).\n */\nexport function extractBundledExtension(zipPath: string, targetDir: string): ExtractResult {\n // Read the incoming zip's version BEFORE deciding to extract, so we can\n // implement the idempotency check without writing any bytes.\n const zip = new AdmZip(zipPath);\n const manifestEntry = zip.getEntry('manifest.json');\n if (!manifestEntry) {\n throw new Error(`bundled zip ${zipPath} missing manifest.json`);\n }\n const incomingVersion = readManifestVersion(manifestEntry.getData().toString('utf8'), `bundled zip ${zipPath}`);\n\n const existingManifest = path.join(targetDir, 'manifest.json');\n if (fs.existsSync(existingManifest)) {\n try {\n const existingVersion = readManifestVersion(\n fs.readFileSync(existingManifest, 'utf8'),\n `existing ${existingManifest}`,\n );\n if (existingVersion === incomingVersion) {\n return { extracted: false, version: incomingVersion };\n }\n } catch {\n // Existing manifest is malformed or missing version — fall through and overwrite.\n }\n }\n\n fs.mkdirSync(targetDir, { recursive: true });\n zip.extractAllTo(targetDir, /* overwrite */ true, /* keepOriginalPermission */ false);\n return { extracted: true, version: incomingVersion };\n}\n","/**\n * Pure additive merge for `claude_desktop_config.json`.\n *\n * Customers may already have other MCP servers configured in their\n * `claude_desktop_config.json`. We must add or update ONLY our own\n * `mcpServers[\"darwinium-portal-mcp\"]` entry, preserving everything else.\n *\n * To guard against config corruption, the caller (`install.ts`) writes a\n * `.bak` before any modification and uses temp+rename for atomicity. This\n * module is pure — no fs reads/writes, no side effects — so it's trivially\n * testable and cannot leak partial state.\n *\n * Pre-existing malformed JSON triggers a hard refusal: if we can't parse it,\n * we don't touch it. The caller maps the thrown error to the user-facing\n * fail line.\n */\n\n/** Shape of the per-server entry written under `mcpServers[<name>]`. */\nexport interface McpServerEntry {\n command: string;\n args: string[];\n env?: Record<string, string>;\n}\n\n/** Result of a merge: the new full JSON object plus diff metadata. */\nexport interface MergeResult {\n /** Full top-level JSON object after merge. Caller serializes + writes. */\n merged: object;\n /** True if the merged JSON differs from `current` (warrants a write). */\n changed: boolean;\n /** True if `current` already had a `mcpServers[serverName]` entry. */\n existed: boolean;\n}\n\n/**\n * Returns a merged JSON object with `mcpServers[serverName]` set to `entry`,\n * preserving all other top-level keys and other `mcpServers.*` siblings.\n *\n * `current` semantics:\n * - `null` → treated as empty starting point (file did not exist on disk).\n * - non-null non-object (e.g. array, string, number) → throws.\n *\n * Idempotency: if the existing entry is byte-equal (via JSON.stringify of\n * `args` and `env`) to the proposed entry, returns `{changed: false}` so the\n * caller can skip the `.bak` write and the temp-rename.\n */\nexport function mergeMcpServersEntry(current: unknown, serverName: string, entry: McpServerEntry): MergeResult {\n // Treat null (file absent) as empty starting point. Any other non-object is\n // a malformed file and we refuse to write.\n if (current !== null && (typeof current !== 'object' || Array.isArray(current))) {\n throw new Error('claude_desktop_config.json is not a JSON object');\n }\n const base: Record<string, unknown> = (current ?? {}) as Record<string, unknown>;\n\n // `mcpServers` must be a plain object or absent. Anything else (a string, an\n // array) is malformed, and spreading it would scatter its indices across the\n // rewritten config — so it takes the same hard refusal as malformed JSON\n // rather than being silently coerced.\n const rawServers = base.mcpServers;\n if (rawServers !== undefined && (typeof rawServers !== 'object' || rawServers === null || Array.isArray(rawServers))) {\n throw new Error('claude_desktop_config.json has a malformed \"mcpServers\" value');\n }\n const mcpServers: Record<string, unknown> = (rawServers ?? {}) as Record<string, unknown>;\n\n const existing = mcpServers[serverName];\n const existed = existing !== undefined;\n\n // Narrow before reading fields: a non-object entry simply fails the match and\n // gets overwritten, which is what the old `any` did implicitly.\n const existingEntry =\n typeof existing === 'object' && existing !== null ? (existing as Partial<McpServerEntry>) : undefined;\n\n // Drift detection: compare command, args, env via stable JSON encoding. We\n // intentionally use JSON.stringify(args) instead of array equality so order\n // and nested structures are compared deeply.\n const isMatch =\n existingEntry !== undefined &&\n existingEntry.command === entry.command &&\n JSON.stringify(existingEntry.args) === JSON.stringify(entry.args) &&\n JSON.stringify(existingEntry.env ?? {}) === JSON.stringify(entry.env ?? {});\n\n if (isMatch) {\n return { merged: base, changed: false, existed: true };\n }\n\n return {\n merged: { ...base, mcpServers: { ...mcpServers, [serverName]: entry } },\n changed: true,\n existed,\n };\n}\n","/**\n * `npx @darwinium/portal-mcp install` — orchestrator.\n *\n * Sequence:\n * 1. migrate.maybeMigrate() — legacy token-path move\n * 2. token: skip if exists, else generateAndWrite({silent:true})\n * 3. winAcl.lockAcl(TOKEN_PATH) AND winAcl.lockAcl(TOKEN_DIR)\n * 4. extract.extractBundledExtension(zip, EXTENSION_DIR)\n * 5. configMerge → write `.bak` then atomic temp+rename\n * 6. Print Claude Code marketplace + Chrome extension instructions\n * 7. oobPair.run({mode:'install', expectedToken: token})\n * 8. Success summary + process.exit(0)\n *\n * Security:\n * - Token never echoed — generateAndWrite({silent:true}).\n * - configMerge entry contains only `npx @darwinium/portal-mcp serve` —\n * no token, no env.\n * - `.bak` written via copyFileSync BEFORE temp-write; atomic fs.renameSync\n * replaces target. Malformed pre-existing JSON → refusal.\n */\nimport * as fs from 'node:fs';\nimport * as os from 'node:os';\nimport * as path from 'node:path';\nimport { TOKEN_DIR, TOKEN_PATH, EXTENSION_DIR } from '../token/paths.js';\nimport { generateAndWrite } from '../token/store.js';\nimport * as term from './term.js';\nimport * as migrate from './migrate.js';\nimport * as winAcl from './winAcl.js';\nimport * as extract from './extract.js';\nimport { bundledExtensionZipPath } from './bundlePaths.js';\nimport { mergeMcpServersEntry, type McpServerEntry } from './configMerge.js';\nimport * as oobPair from './oobPair.js';\n\n/** Config entry — never includes the token; only the cli invocation. */\nconst MCP_ENTRY: McpServerEntry = {\n command: 'npx',\n args: ['-y', '@darwinium/portal-mcp', 'serve'],\n};\n\nconst SERVER_NAME = 'darwinium-portal-mcp';\n\n/** Resolve the platform-correct claude_desktop_config.json path. */\nfunction resolveClaudeDesktopConfigPath(): string {\n if (process.platform === 'darwin') {\n return path.join(os.homedir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');\n }\n if (process.platform === 'win32') {\n const appData = process.env.APPDATA ?? path.join(os.homedir(), 'AppData', 'Roaming');\n return path.join(appData, 'Claude', 'claude_desktop_config.json');\n }\n // Linux fallback — Anthropic's documented location matches XDG_CONFIG_HOME default.\n return path.join(os.homedir(), '.config', 'Claude', 'claude_desktop_config.json');\n}\n\n/** Read + parse claude_desktop_config.json. Returns null on ENOENT. */\nfunction readConfigJson(configPath: string): unknown {\n if (!fs.existsSync(configPath)) return null;\n const raw = fs.readFileSync(configPath, 'utf8');\n try {\n return JSON.parse(raw);\n } catch (err) {\n // Re-throw as a tagged error the caller can map to the user-facing fail line.\n throw new Error(`claude_desktop_config.json is not valid JSON: ${(err as Error).message}`);\n }\n}\n\n/** Atomic write: `.bak` (if pre-existing) → temp → rename. */\nfunction writeConfigJsonAtomic(configPath: string, merged: object, existed: boolean): void {\n if (existed) {\n fs.copyFileSync(configPath, `${configPath}.bak`);\n }\n const tmpPath = `${configPath}.tmp.${process.pid}`;\n fs.writeFileSync(tmpPath, JSON.stringify(merged, null, 2), { mode: 0o644 });\n fs.renameSync(tmpPath, configPath);\n}\n\n/**\n * Apply the configMerge to claude_desktop_config.json. Emits exactly one\n * summary-line variant for the config row.\n */\nfunction applyClaudeDesktopMerge(): void {\n const configPath = resolveClaudeDesktopConfigPath();\n const configDir = path.dirname(configPath);\n\n if (!fs.existsSync(configDir)) {\n // Claude Desktop not installed — warn and continue.\n term.warn(\n `config (Claude Desktop config dir not found at ${configDir} — install Claude Desktop or use the Claude Code marketplace path).`,\n );\n return;\n }\n\n let current: unknown;\n try {\n current = readConfigJson(configPath);\n } catch {\n // Refuse to overwrite invalid JSON.\n term.fail(`config (refusing to write — ${configPath} is not valid JSON; please fix or delete and re-run)`);\n process.exit(1);\n }\n\n let result: ReturnType<typeof mergeMcpServersEntry>;\n try {\n result = mergeMcpServersEntry(current, SERVER_NAME, MCP_ENTRY);\n } catch (err) {\n // mergeMcpServersEntry throws on non-object top-level — same fail line.\n term.fail(`config (refusing to write — ${configPath} is not a JSON object: ${(err as Error).message})`);\n process.exit(1);\n }\n\n const fileExisted = current !== null;\n if (!result.changed) {\n term.ok('config (existing match)');\n return;\n }\n\n writeConfigJsonAtomic(configPath, result.merged, fileExisted);\n if (result.existed) {\n term.ok(`config (updated darwinium-portal-mcp entry; .bak written)`);\n } else if (fileExisted) {\n term.ok(`config (added darwinium-portal-mcp entry to ${configPath}; .bak written)`);\n } else {\n // First-ever config file — there's no pre-existing content to back up.\n term.ok(`config (added darwinium-portal-mcp entry to ${configPath}; new file)`);\n }\n}\n\n/** Print the Claude Code + Chrome extension instructions block. */\nfunction printPostInstallInstructions(): void {\n console.error('');\n term.info('Claude Code: run `/plugin marketplace add darwinium-com/portal-mcp-marketplace`');\n console.error(' then `/plugin install portal-mcp` from inside Claude Code.');\n console.error('');\n term.info('Chrome extension — pick one:');\n console.error(' (a) Web Store: https://chromewebstore.google.com/detail/<placeholder-listing-url>');\n console.error(' (b) Unpacked: chrome://extensions → enable Developer Mode →');\n console.error(` Load Unpacked → point at ${EXTENSION_DIR}`);\n console.error('');\n}\n\n/** Public entry point — invoked from the lazy-loaded `install` action in bin. */\nexport async function runInstall(_opts: { json?: boolean; yes?: boolean } = {}): Promise<void> {\n term.info('install starting...');\n\n // 1. Legacy token-path migration (silent on no-op).\n migrate.maybeMigrate(TOKEN_PATH);\n\n // 2. Token — skip generation if existing, else write silently.\n let token: string;\n if (fs.existsSync(TOKEN_PATH)) {\n token = fs.readFileSync(TOKEN_PATH, 'utf8').trim();\n const aclSuffix = process.platform === 'win32' ? ', ACL locked' : '';\n term.ok(`token (existing at ${TOKEN_PATH}, mode 0600${aclSuffix})`);\n } else {\n token = generateAndWrite({ silent: true });\n const aclSuffix = process.platform === 'win32' ? ', ACL locked' : '';\n term.ok(`token (added at ${TOKEN_PATH}, mode 0600${aclSuffix})`);\n }\n\n // 3. Windows ACL on token + parent dir. Non-Windows: no-op.\n for (const p of [TOKEN_PATH, TOKEN_DIR]) {\n const r = winAcl.lockAcl(p);\n if (!r.ok) {\n term.warn(`Could not restrict ACLs on ${p}: ${r.error}. Run \\`${r.cmd}\\` manually.`);\n }\n }\n\n // 4. Extract bundled extension (idempotent).\n const extZip = bundledExtensionZipPath();\n try {\n const extResult = extract.extractBundledExtension(extZip, EXTENSION_DIR);\n if (extResult.extracted) {\n term.ok(`extension (extracted v${extResult.version} to ${EXTENSION_DIR})`);\n } else {\n term.ok(`extension (existing v${extResult.version})`);\n }\n } catch (err) {\n term.fail(\n `extension (extract failed: ${(err as Error).message}; bundled extension at ${extZip} is intact — re-run install)`,\n );\n process.exit(1);\n }\n\n // 5. claude_desktop_config.json additive merge.\n applyClaudeDesktopMerge();\n\n // 6. Post-install instructions.\n printPostInstallInstructions();\n\n // 7. Foreground OOB pair — blocks until paired or SIGINT.\n await oobPair.run({ mode: 'install', expectedToken: token });\n\n // 8. Success summary. Read the manifest of the just-extracted extension to\n // get its version for the success line. If it's missing for any reason,\n // emit the version-less variant.\n let extVersionForSuccess = '';\n const manifestPath = path.join(EXTENSION_DIR, 'manifest.json');\n if (fs.existsSync(manifestPath)) {\n try {\n extVersionForSuccess = JSON.parse(fs.readFileSync(manifestPath, 'utf8')).version as string;\n } catch {\n /* fall through to versionless variant */\n }\n }\n if (extVersionForSuccess) {\n term.ok(`paired with extension (extension version ${extVersionForSuccess})`);\n } else {\n term.ok('paired with extension');\n }\n term.info('install complete. You can close this terminal.');\n process.exit(0);\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"install-BhzwhhI-.js","names":["winAcl.lockAcl","extract.extractBundledExtension","oobPair.run"],"sources":["../../src/install/extract.ts","../../src/install/configMerge.ts","../../src/install/install.ts"],"sourcesContent":["/**\n * Bundled-extension extractor.\n *\n * The npm-published binary ships with a copy of the WXT-built portal-extension\n * zip at `extension-bundle/extension.zip` (placed there by\n * `scripts/bundle-extension.ts` during prebuild). At install time we extract it\n * to `<env-paths data dir>/extension/` so customers can load it via Chrome's\n * \"Load Unpacked\" path on locked corporate machines that can't use the Web\n * Store.\n *\n * Idempotency: if the target directory already contains a manifest.json\n * with the same version as the bundled zip, we skip extraction so re-running\n * `install` doesn't churn the extracted dir. Drift (different version) is\n * handled by overwriting (with `extractAllTo(dir, true, false)`).\n *\n * Security: adm-zip's `extractAllTo(target, true, false)` does NOT preserve\n * original permissions and resolves entry paths relative to the extraction\n * target — no traversal beyond `targetDir`. A symlink pre-planted at the\n * target is risk-accepted.\n */\nimport AdmZip from 'adm-zip';\nimport * as fs from 'node:fs';\nimport * as path from 'node:path';\n\n/** Outcome of `extractBundledExtension(zip, target)`. */\nexport interface ExtractResult {\n /** True if the target dir was actually written (false = idempotent skip). */\n extracted: boolean;\n /** Manifest version of the bundled extension (always populated). */\n version: string;\n}\n\n// Validate that `json` parses to an object whose `version` field is a non-empty\n// string. Without this, an `as string` cast lets `version: undefined` flow into\n// the idempotency check, where `undefined === undefined` is true and every\n// re-install silently skips extraction.\nfunction readManifestVersion(json: string, source: string): string {\n let parsed: unknown;\n try {\n parsed = JSON.parse(json);\n } catch {\n throw new Error(`${source}: manifest.json is not valid JSON`);\n }\n if (!parsed || typeof parsed !== 'object') {\n throw new Error(`${source}: manifest.json is not a JSON object`);\n }\n const v = (parsed as { version?: unknown }).version;\n if (typeof v !== 'string' || v.length === 0) {\n throw new Error(`${source}: manifest.json missing version string`);\n }\n return v;\n}\n\n/**\n * Extract `zipPath` into `targetDir`, skipping when the existing manifest\n * version matches the incoming version.\n *\n * Throws if the bundled zip is missing `manifest.json` or its version field\n * is missing/non-string (catastrophic build-time mistake — caller maps to\n * the `extract failed: ...` line and exits 1).\n */\nexport function extractBundledExtension(zipPath: string, targetDir: string): ExtractResult {\n // Read the incoming zip's version BEFORE deciding to extract, so we can\n // implement the idempotency check without writing any bytes.\n const zip = new AdmZip(zipPath);\n const manifestEntry = zip.getEntry('manifest.json');\n if (!manifestEntry) {\n throw new Error(`bundled zip ${zipPath} missing manifest.json`);\n }\n const incomingVersion = readManifestVersion(manifestEntry.getData().toString('utf8'), `bundled zip ${zipPath}`);\n\n const existingManifest = path.join(targetDir, 'manifest.json');\n if (fs.existsSync(existingManifest)) {\n try {\n const existingVersion = readManifestVersion(\n fs.readFileSync(existingManifest, 'utf8'),\n `existing ${existingManifest}`,\n );\n if (existingVersion === incomingVersion) {\n return { extracted: false, version: incomingVersion };\n }\n } catch {\n // Existing manifest is malformed or missing version — fall through and overwrite.\n }\n }\n\n fs.mkdirSync(targetDir, { recursive: true });\n zip.extractAllTo(targetDir, /* overwrite */ true, /* keepOriginalPermission */ false);\n return { extracted: true, version: incomingVersion };\n}\n","/**\n * Pure additive merge for `claude_desktop_config.json`.\n *\n * Customers may already have other MCP servers configured in their\n * `claude_desktop_config.json`. We must add or update ONLY our own\n * `mcpServers[\"darwinium-portal-mcp\"]` entry, preserving everything else.\n *\n * To guard against config corruption, the caller (`install.ts`) writes a\n * `.bak` before any modification and uses temp+rename for atomicity. This\n * module is pure — no fs reads/writes, no side effects — so it's trivially\n * testable and cannot leak partial state.\n *\n * Pre-existing malformed JSON triggers a hard refusal: if we can't parse it,\n * we don't touch it. The caller maps the thrown error to the user-facing\n * fail line.\n */\n\n/** Shape of the per-server entry written under `mcpServers[<name>]`. */\nexport interface McpServerEntry {\n command: string;\n args: string[];\n env?: Record<string, string>;\n}\n\n/** Result of a merge: the new full JSON object plus diff metadata. */\nexport interface MergeResult {\n /** Full top-level JSON object after merge. Caller serializes + writes. */\n merged: object;\n /** True if the merged JSON differs from `current` (warrants a write). */\n changed: boolean;\n /** True if `current` already had a `mcpServers[serverName]` entry. */\n existed: boolean;\n}\n\n/**\n * Returns a merged JSON object with `mcpServers[serverName]` set to `entry`,\n * preserving all other top-level keys and other `mcpServers.*` siblings.\n *\n * `current` semantics:\n * - `null` → treated as empty starting point (file did not exist on disk).\n * - non-null non-object (e.g. array, string, number) → throws.\n *\n * Idempotency: if the existing entry is byte-equal (via JSON.stringify of\n * `args` and `env`) to the proposed entry, returns `{changed: false}` so the\n * caller can skip the `.bak` write and the temp-rename.\n */\nexport function mergeMcpServersEntry(current: unknown, serverName: string, entry: McpServerEntry): MergeResult {\n // Treat null (file absent) as empty starting point. Any other non-object is\n // a malformed file and we refuse to write.\n if (current !== null && (typeof current !== 'object' || Array.isArray(current))) {\n throw new Error('claude_desktop_config.json is not a JSON object');\n }\n const base: Record<string, unknown> = (current ?? {}) as Record<string, unknown>;\n\n // `mcpServers` must be a plain object or absent. Anything else (a string, an\n // array) is malformed, and spreading it would scatter its indices across the\n // rewritten config — so it takes the same hard refusal as malformed JSON\n // rather than being silently coerced.\n const rawServers = base.mcpServers;\n if (\n rawServers !== undefined &&\n (typeof rawServers !== 'object' || rawServers === null || Array.isArray(rawServers))\n ) {\n throw new Error('claude_desktop_config.json has a malformed \"mcpServers\" value');\n }\n const mcpServers: Record<string, unknown> = (rawServers ?? {}) as Record<string, unknown>;\n\n const existing = mcpServers[serverName];\n const existed = existing !== undefined;\n\n // Narrow before reading fields: a non-object entry simply fails the match and\n // gets overwritten, which is what the old `any` did implicitly.\n const existingEntry =\n typeof existing === 'object' && existing !== null ? (existing as Partial<McpServerEntry>) : undefined;\n\n // Drift detection: compare command, args, env via stable JSON encoding. We\n // intentionally use JSON.stringify(args) instead of array equality so order\n // and nested structures are compared deeply.\n const isMatch =\n existingEntry !== undefined &&\n existingEntry.command === entry.command &&\n JSON.stringify(existingEntry.args) === JSON.stringify(entry.args) &&\n JSON.stringify(existingEntry.env ?? {}) === JSON.stringify(entry.env ?? {});\n\n if (isMatch) {\n return { merged: base, changed: false, existed: true };\n }\n\n return {\n merged: { ...base, mcpServers: { ...mcpServers, [serverName]: entry } },\n changed: true,\n existed,\n };\n}\n","/**\n * `npx @darwinium/portal-mcp install` — orchestrator.\n *\n * Sequence:\n * 1. migrate.maybeMigrate() — legacy token-path move\n * 2. token: skip if exists, else generateAndWrite({silent:true})\n * 3. winAcl.lockAcl(TOKEN_PATH) AND winAcl.lockAcl(TOKEN_DIR)\n * 4. extract.extractBundledExtension(zip, EXTENSION_DIR)\n * 5. configMerge → write `.bak` then atomic temp+rename\n * 6. Print Claude Code marketplace + Chrome extension instructions\n * 7. oobPair.run({mode:'install', expectedToken: token})\n * 8. Success summary + process.exit(0)\n *\n * Security:\n * - Token never echoed — generateAndWrite({silent:true}).\n * - configMerge entry contains only `npx @darwinium/portal-mcp serve` —\n * no token, no env.\n * - `.bak` written via copyFileSync BEFORE temp-write; atomic fs.renameSync\n * replaces target. Malformed pre-existing JSON → refusal.\n */\nimport * as fs from 'node:fs';\nimport * as os from 'node:os';\nimport * as path from 'node:path';\nimport { TOKEN_DIR, TOKEN_PATH, EXTENSION_DIR } from '../token/paths.js';\nimport { generateAndWrite } from '../token/store.js';\nimport * as term from './term.js';\nimport * as migrate from './migrate.js';\nimport * as winAcl from './winAcl.js';\nimport * as extract from './extract.js';\nimport { bundledExtensionZipPath } from './bundlePaths.js';\nimport { mergeMcpServersEntry, type McpServerEntry } from './configMerge.js';\nimport * as oobPair from './oobPair.js';\n\n/** Config entry — never includes the token; only the cli invocation. */\nconst MCP_ENTRY: McpServerEntry = {\n command: 'npx',\n args: ['-y', '@darwinium/portal-mcp', 'serve'],\n};\n\nconst SERVER_NAME = 'darwinium-portal-mcp';\n\n/** Resolve the platform-correct claude_desktop_config.json path. */\nfunction resolveClaudeDesktopConfigPath(): string {\n if (process.platform === 'darwin') {\n return path.join(os.homedir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');\n }\n if (process.platform === 'win32') {\n const appData = process.env.APPDATA ?? path.join(os.homedir(), 'AppData', 'Roaming');\n return path.join(appData, 'Claude', 'claude_desktop_config.json');\n }\n // Linux fallback — Anthropic's documented location matches XDG_CONFIG_HOME default.\n return path.join(os.homedir(), '.config', 'Claude', 'claude_desktop_config.json');\n}\n\n/** Read + parse claude_desktop_config.json. Returns null on ENOENT. */\nfunction readConfigJson(configPath: string): unknown {\n if (!fs.existsSync(configPath)) return null;\n const raw = fs.readFileSync(configPath, 'utf8');\n try {\n return JSON.parse(raw);\n } catch (err) {\n // Re-throw as a tagged error the caller can map to the user-facing fail line.\n throw new Error(`claude_desktop_config.json is not valid JSON: ${(err as Error).message}`);\n }\n}\n\n/** Atomic write: `.bak` (if pre-existing) → temp → rename. */\nfunction writeConfigJsonAtomic(configPath: string, merged: object, existed: boolean): void {\n if (existed) {\n fs.copyFileSync(configPath, `${configPath}.bak`);\n }\n const tmpPath = `${configPath}.tmp.${process.pid}`;\n fs.writeFileSync(tmpPath, JSON.stringify(merged, null, 2), { mode: 0o644 });\n fs.renameSync(tmpPath, configPath);\n}\n\n/**\n * Apply the configMerge to claude_desktop_config.json. Emits exactly one\n * summary-line variant for the config row.\n */\nfunction applyClaudeDesktopMerge(): void {\n const configPath = resolveClaudeDesktopConfigPath();\n const configDir = path.dirname(configPath);\n\n if (!fs.existsSync(configDir)) {\n // Claude Desktop not installed — warn and continue.\n term.warn(\n `config (Claude Desktop config dir not found at ${configDir} — install Claude Desktop or use the Claude Code marketplace path).`,\n );\n return;\n }\n\n let current: unknown;\n try {\n current = readConfigJson(configPath);\n } catch {\n // Refuse to overwrite invalid JSON.\n term.fail(`config (refusing to write — ${configPath} is not valid JSON; please fix or delete and re-run)`);\n process.exit(1);\n }\n\n let result: ReturnType<typeof mergeMcpServersEntry>;\n try {\n result = mergeMcpServersEntry(current, SERVER_NAME, MCP_ENTRY);\n } catch (err) {\n // mergeMcpServersEntry throws on non-object top-level — same fail line.\n term.fail(`config (refusing to write — ${configPath} is not a JSON object: ${(err as Error).message})`);\n process.exit(1);\n }\n\n const fileExisted = current !== null;\n if (!result.changed) {\n term.ok('config (existing match)');\n return;\n }\n\n writeConfigJsonAtomic(configPath, result.merged, fileExisted);\n if (result.existed) {\n term.ok(`config (updated darwinium-portal-mcp entry; .bak written)`);\n } else if (fileExisted) {\n term.ok(`config (added darwinium-portal-mcp entry to ${configPath}; .bak written)`);\n } else {\n // First-ever config file — there's no pre-existing content to back up.\n term.ok(`config (added darwinium-portal-mcp entry to ${configPath}; new file)`);\n }\n}\n\n/** Print the Claude Code + Chrome extension instructions block. */\nfunction printPostInstallInstructions(): void {\n console.error('');\n term.info('Claude Code: run `/plugin marketplace add darwinium-com/portal-mcp-marketplace`');\n console.error(' then `/plugin install portal-mcp` from inside Claude Code.');\n console.error('');\n term.info('Chrome extension — pick one:');\n console.error(' (a) Web Store: https://chromewebstore.google.com/detail/<placeholder-listing-url>');\n console.error(' (b) Unpacked: chrome://extensions → enable Developer Mode →');\n console.error(` Load Unpacked → point at ${EXTENSION_DIR}`);\n console.error('');\n}\n\n/** Public entry point — invoked from the lazy-loaded `install` action in bin. */\nexport async function runInstall(_opts: { json?: boolean; yes?: boolean } = {}): Promise<void> {\n term.info('install starting...');\n\n // 1. Legacy token-path migration (silent on no-op).\n migrate.maybeMigrate(TOKEN_PATH);\n\n // 2. Token — skip generation if existing, else write silently.\n let token: string;\n if (fs.existsSync(TOKEN_PATH)) {\n token = fs.readFileSync(TOKEN_PATH, 'utf8').trim();\n const aclSuffix = process.platform === 'win32' ? ', ACL locked' : '';\n term.ok(`token (existing at ${TOKEN_PATH}, mode 0600${aclSuffix})`);\n } else {\n token = generateAndWrite({ silent: true });\n const aclSuffix = process.platform === 'win32' ? ', ACL locked' : '';\n term.ok(`token (added at ${TOKEN_PATH}, mode 0600${aclSuffix})`);\n }\n\n // 3. Windows ACL on token + parent dir. Non-Windows: no-op.\n for (const p of [TOKEN_PATH, TOKEN_DIR]) {\n const r = winAcl.lockAcl(p);\n if (!r.ok) {\n term.warn(`Could not restrict ACLs on ${p}: ${r.error}. Run \\`${r.cmd}\\` manually.`);\n }\n }\n\n // 4. Extract bundled extension (idempotent).\n const extZip = bundledExtensionZipPath();\n try {\n const extResult = extract.extractBundledExtension(extZip, EXTENSION_DIR);\n if (extResult.extracted) {\n term.ok(`extension (extracted v${extResult.version} to ${EXTENSION_DIR})`);\n } else {\n term.ok(`extension (existing v${extResult.version})`);\n }\n } catch (err) {\n term.fail(\n `extension (extract failed: ${(err as Error).message}; bundled extension at ${extZip} is intact — re-run install)`,\n );\n process.exit(1);\n }\n\n // 5. claude_desktop_config.json additive merge.\n applyClaudeDesktopMerge();\n\n // 6. Post-install instructions.\n printPostInstallInstructions();\n\n // 7. Foreground OOB pair — blocks until paired or SIGINT.\n await oobPair.run({ mode: 'install', expectedToken: token });\n\n // 8. Success summary. Read the manifest of the just-extracted extension to\n // get its version for the success line. If it's missing for any reason,\n // emit the version-less variant.\n let extVersionForSuccess = '';\n const manifestPath = path.join(EXTENSION_DIR, 'manifest.json');\n if (fs.existsSync(manifestPath)) {\n try {\n extVersionForSuccess = JSON.parse(fs.readFileSync(manifestPath, 'utf8')).version as string;\n } catch {\n /* fall through to versionless variant */\n }\n }\n if (extVersionForSuccess) {\n term.ok(`paired with extension (extension version ${extVersionForSuccess})`);\n } else {\n term.ok('paired with extension');\n }\n term.info('install complete. You can close this terminal.');\n process.exit(0);\n}\n"],"mappings":"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"}
@@ -2,7 +2,7 @@
2
2
  import { Command } from "commander";
3
3
 
4
4
  //#region src/version.ts
5
- const VERSION = "0.0.2";
5
+ const VERSION = "0.0.3";
6
6
 
7
7
  //#endregion
8
8
  //#region src/bin/portal-mcp.ts
@@ -1 +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"}
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.3';\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"}
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@darwinium/portal-mcp",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "description": "Darwinium Portal MCP — local stdio MCP server bridging Claude Desktop / Claude Code to the active *.darwinium.com tab via a Chrome MV3 extension.",
5
5
  "author": "Darwinium",
6
6
  "license": "Apache-2.0",
@@ -38,7 +38,6 @@
38
38
  "dist",
39
39
  "docs",
40
40
  "extension-bundle",
41
- "TESTING.md",
42
41
  "README.md",
43
42
  "LICENSE",
44
43
  "NOTICE"
@@ -47,7 +46,7 @@
47
46
  "clean": "rimraf dist",
48
47
  "bundle-extension": "node scripts/bundle-extension.mjs",
49
48
  "build": "rimraf dist && node scripts/gen-version.mjs && node scripts/vendor-instructions.mjs && tsdown && node -e \"require('fs').chmodSync('dist/bin/portal-mcp.js', 0o755)\"",
50
- "package:macos": "node scripts/build-mcpb.mjs --target=darwin-arm64 --handover",
49
+ "package:macos": "node scripts/build-mcpb.mjs --runtime=bun --target=darwin-arm64 --handover",
51
50
  "smoke-test": "node --experimental-strip-types scripts/smoke-test.ts",
52
51
  "smoke-test:eaddrinuse": "node --experimental-strip-types scripts/smoke-test-eaddrinuse.ts",
53
52
  "test": "npm run build && npm run smoke-test",
package/TESTING.md DELETED
@@ -1,478 +0,0 @@
1
- # portal-mcp — Manual Verification (Phase 1)
2
-
3
- The two automated smoke tests (`yarn smoke-test`, `yarn smoke-test:eaddrinuse`) cover
4
- Phase 1 Success Criteria 3 and 4. Success Criteria 1, 2, and 5 require a running
5
- portal session and the loaded extension; document the manual steps here.
6
-
7
- ## Prerequisites
8
-
9
- - Built binary: `yarn workspace @darwinium/portal-mcp build`
10
- - Built extension (Plan 05): `yarn workspace @jfrog/portal-extension build`
11
- then load `dwn_aphex/packages/portal-extension/.output/chrome-mv3/` via
12
- `chrome://extensions` → Developer Mode → Load Unpacked
13
- - `wscat` installed: `npm i -g wscat`
14
- - Logged-in `*.darwinium.com` portal tab open in Chrome
15
-
16
- ## Success Criterion 5 — bridge round-trip (page-side end-to-end via DevTools)
17
-
18
- **Phase 1 boundary:** the SW WS client is deferred to Phase 2 (BRIDGE-04). The
19
- page-side bridge (Plan 05) is fully reachable from the journey page's DevTools
20
- console. The portal-mcp binary's WS server (Plan 02) is reachable via wscat.
21
- Phase 1 verifies them INDEPENDENTLY; Phase 2 connects them.
22
-
23
- ### Path A — Page-side end-to-end (Plan 04 + Plan 05)
24
-
25
- 1. Build the extension and load it unpacked:
26
- ```sh
27
- yarn workspace @jfrog/portal-extension build
28
- # Then: chrome://extensions → Developer Mode → Load Unpacked
29
- # → select dwn_aphex/packages/portal-extension/.output/chrome-mv3/
30
- ```
31
-
32
- 2. Build aphex-frontend and start the dev server (per CLAUDE.md project guide):
33
- ```sh
34
- yarn rebuild
35
- # Dev server: https://localhost:8000 (or your local portal URL on *.darwinium.com)
36
- ```
37
-
38
- 3. Open a journey page in the portal. Open DevTools console.
39
-
40
- 4. Verify the symbol-keyed registry is alive (Plan 04 output):
41
- ```js
42
- window[Symbol.for('darwinium.pageCommands')]().map(c => `${c.name} (_pageId=${c._pageId})`);
43
- // Expected: includes 'getDarwiniumInstructions (_pageId=global)' and
44
- // 'getCurrentNodeContext (_pageId=InvestigationsJourney)'
45
- ```
46
-
47
- 5. After Plan 02-04 (Phase 2): the DevTools `window.__darwinium-Bridge-Request`
48
- affordance is REMOVED (T-05-08 — literal hyphenated only here so the removal-check
49
- greps still pass). Drive commands via the Phase 2 SW path instead — open the popup,
50
- paste the token printed by the binary on stderr, click **Save & Connect**, then drive
51
- a `listCommands` request through the binary from a wscat client (the binary forwards
52
- binary → SW → ISOLATED → MAIN → registry):
53
- ```sh
54
- # Substitute <hex> with the contents of ~/.config/darwinium-portal-mcp/token
55
- wscat -c ws://127.0.0.1:9224 -s darwinium.v1 -s tok.<hex>
56
- > {"type":"hello","token":"<hex>","version":"0.1.0"}
57
- > {"type":"req","id":"test-1","op":"listCommands"}
58
- # Expected response (with the popup connected to a *.darwinium.com tab):
59
- # {"type":"resp","id":"test-1","result":[{"name":"...","description":"...","args":[...],"_pageId":"..."}]}
60
- ```
61
-
62
- 6. Run a command via the SW path:
63
- ```sh
64
- > {"type":"req","id":"test-2","op":"runCommand","args":{"name":"getDarwiniumInstructions","args":{}}}
65
- # Expected: {"type":"resp","id":"test-2","result":{"instructions":"...long static prompt string..."}}
66
- ```
67
-
68
- 7. Optional: drive the bridge directly via raw CustomEvents in the page DevTools console (no helper):
69
- ```js
70
- const id = crypto.randomUUID();
71
- document.addEventListener('dwn-mcp-resp', (e) => {
72
- if (e.detail.id === id) console.log('resp:', e.detail);
73
- }, { once: true });
74
- document.dispatchEvent(new CustomEvent('dwn-mcp-req', { detail: { id, op: 'listCommands' } }));
75
- // Expected: a 'dwn-mcp-resp' fires with detail.result === the command list.
76
- ```
77
-
78
- ### Path B — Binary WS server reachability (Plan 02)
79
-
80
- (Phase 1 has no SW WS client; this verifies the WS server itself is alive.)
81
-
82
- 1. Start the portal-mcp binary (with stdio piped to /dev/null so it doesn't block on initialize):
83
- ```sh
84
- node dwn_aphex/packages/portal-mcp/dist/bin/portal-mcp.js serve < /dev/null
85
- # Stderr: "portal-mcp: WS server listening on ws://127.0.0.1:9224"
86
- ```
87
-
88
- 2. From another terminal, connect via wscat. Phase 2 token-handshake (BRIDGE-02)
89
- requires the `darwinium.v1` and `tok.<hex>` subprotocols; without them the WS
90
- upgrade fails (Pitfall 1 — `handleProtocols` rejection). Substitute `<hex>` with
91
- the contents of `~/.config/darwinium-portal-mcp/token`:
92
- ```sh
93
- wscat -c ws://127.0.0.1:9224 -s darwinium.v1 -s tok.<hex>
94
- > {"type":"hello","token":"<hex>","version":"0.1.0"}
95
- > {"type":"req","id":"test-1","op":"listCommands"}
96
- ```
97
- Expected response when the popup is connected to a `*.darwinium.com` tab:
98
- ```json
99
- {"type":"resp","id":"test-1","result":[{"name":"...","description":"...","args":[...],"_pageId":"..."}]}
100
- ```
101
- With NO popup-connected tab, the response is the verbatim D-C2 string:
102
- ```json
103
- {"type":"resp","id":"test-1","error":"Not connected. Click Connect in the Darwinium MCP extension popup on the *.darwinium.com tab you want to use."}
104
- ```
105
-
106
- ### Phase 2 closes the loop
107
-
108
- Phase 2's SW WS client connects Path B's WS server to Path A's `bridgeRequest`,
109
- making `wscat → binary → SW → ISOLATED → MAIN → registry → response` work
110
- end-to-end. The page-side and binary-side are verified INDEPENDENTLY in Phase 1.
111
-
112
- ## Success Criterion 1 — DevTools console: symbol-keyed registry
113
-
114
- After Plan 04 lands the PageContextProvider migration:
115
-
116
- 1. Open the portal in Chrome on a journey page; open DevTools console.
117
- 2. Run: `window[Symbol.for('darwinium.pageCommands')]()`
118
- 3. Verify the returned array includes:
119
- - The existing per-page commands (e.g. `getEventDetail`)
120
- - `getDarwiniumInstructions` (global)
121
- - `getCurrentNodeContext` (global with journey-page override)
122
- 4. Verify each entry has a `_pageId` field.
123
-
124
- ## Success Criterion 2 — In-portal ChatModal regression check
125
-
126
- After Plan 04 atomically migrates ChatModal:
127
-
128
- 1. Open the in-portal chat modal on a journey page.
129
- 2. Click "List page commands" (or whatever the existing UI affordance is for
130
- `getPageCommands`).
131
- 3. Verify the listed commands match what the symbol-keyed registry returns
132
- (Success Criterion 1).
133
- 4. Run a known-safe page command (e.g. `getEventDetail` from the sidebar context).
134
- 5. Verify the command executes and returns its result without errors.
135
-
136
- ## Phase 2 Manual Matrix
137
-
138
- These scenarios verify Phase 2 end-to-end behavior. Run each after building the binary
139
- (`yarn workspace @darwinium/portal-mcp build`), building the extension
140
- (`yarn workspace @jfrog/portal-extension build`), loading the extension via
141
- `chrome://extensions → Developer Mode → Load Unpacked → .output/chrome-mv3/`, and
142
- opening a logged-in `*.darwinium.com` tab.
143
-
144
- For each scenario, record PASS or FAIL with details.
145
-
146
- ### Scenario (a) — Cold start with no tab connected (MCP-03 / D-B1 timeout path)
147
-
148
- 1. Start the binary in a fresh terminal: `node dwn_aphex/packages/portal-mcp/dist/bin/portal-mcp.js serve`
149
- 2. Without clicking Connect in the popup, send an `initialize` JSON-RPC frame to the
150
- binary's stdin (or simulate via Claude Desktop config that points at the binary).
151
- 3. **Expected:** `initialize.instructions` is `""` (empty string) — the 5s WS-readiness
152
- wait expires per D-B1.
153
-
154
- ### Scenario (b) — Connected tab returns live commands (MCP-04)
155
-
156
- 1. With the binary running, open the extension popup on a `*.darwinium.com` journey page.
157
- 2. Paste the token printed on binary stderr; click **Save & Connect**.
158
- 3. Popup transitions to `Connected: <tab URL>`.
159
- 4. Run a `tools/call get_page_commands` against the binary (stdin or via an MCP host).
160
- 5. **Expected:** the response includes the live command list with `_pageId` field on each
161
- entry, matching what `window[Symbol.for('darwinium.pageCommands')]()` returns in DevTools.
162
-
163
- ### Scenario (c) — 5-minute idle keepalive (BRIDGE-03)
164
-
165
- 1. With the popup in `Connected:` state, leave the browser idle (no clicks anywhere) for 5 minutes.
166
- 2. Background Claude Desktop / Code (the MCP host); background Chrome.
167
- 3. After 5 minutes, run a `tools/call run_page_command` against the binary.
168
- 4. **Expected:** the call succeeds without the user having to re-Connect. The 20s WS ping
169
- has kept the SW alive (Chrome 116+ idle-timer reset).
170
-
171
- ### Scenario (d) — Navigate mid-call returns mode-3 error (MCP-05 / D-C4)
172
-
173
- 1. With the popup in `Connected:` state on a journey page, set up a `tools/call run_page_command`
174
- with `expected_page_id` matching the current page (visible in `get_page_commands` response).
175
- 2. Have a colleague (or use a script) navigate the connected tab to a different journey
176
- IMMEDIATELY before issuing the call.
177
- 3. **Expected:** the binary returns mode-3 PAGE_NAVIGATED error: `Page navigated. Current page is
178
- <new-pageId>. Re-call get_page_commands and retry against the new page.`
179
- 4. Verify `<new-pageId>` matches the actual new page (the MAIN-world `setupPageIdObserver`'s
180
- `dwn-mcp-pageid-changed` event has propagated through ISOLATED → SW → binary).
181
-
182
- ### Scenario (e) — Bad token returns mode-4 error + popup state (MCP-07 / D-C5)
183
-
184
- 1. Stop the binary. Edit `~/.config/darwinium-portal-mcp/token` to a different 64-char hex
185
- string. Restart the binary.
186
- 2. The popup's still-running SW will retry; on the next attempt the WS upgrade succeeds
187
- (handleProtocols shape gate accepts) but the post-upgrade hello-frame check fails →
188
- `ws.close(4401, 'invalid token')`.
189
- 3. **Expected:** the popup transitions to `Token mismatch` state (red dot) within ~30s
190
- (next chrome.alarms tick or sooner if backoff has fired). Error row appears with the
191
- verbatim D-C5 popup string.
192
- 4. From the host side, run a `tools/call get_page_commands` — the binary returns the verbatim
193
- D-C5 host string: `Extension token does not match the binary's token. Re-run install or rotate-token.`
194
- 5. Click **Re-pair** in the popup; paste the new token; click Save & Connect.
195
- 6. **Expected:** popup transitions back to `Connected:`; subsequent `tools/call` succeeds.
196
-
197
- ### Scenario (f) — Chrome backgrounded for 30+ seconds (BRIDGE-03 SW keepalive)
198
-
199
- 1. With the popup in `Connected:` state, close all Chrome windows EXCEPT the one with the
200
- connected tab. Move focus away from Chrome (cmd-tab to another app) for at least 30 seconds.
201
- 2. Return focus to Chrome and run a `tools/call run_page_command`.
202
- 3. **Expected:** the call succeeds — the 20s WS ping kept the SW alive past the 30s idle threshold.
203
-
204
- ### Scenario (g) — Extension reload survives without manual tab refresh (Plan 02-08 / Plan F gap)
205
-
206
- **Requirements covered:** EXT-02, EXT-03, EXT-06, BRIDGE-03 (lifecycle robustness)
207
-
208
- 1. Cold-start the binary (`node dist/bin/portal-mcp.js serve`) and the extension (Load Unpacked from `.output/chrome-mv3/`).
209
- 2. Open a `*.darwinium.com` (or `https://localhost:8000`) tab; click Connect in the popup; verify Connected pill.
210
- 3. Run `tools/call get_page_commands` from Claude Desktop — confirm a successful response with the live command list.
211
- 4. Visit `chrome://extensions`. Click the **Reload** icon on the "Darwinium Portal MCP" entry. (This is the exact gesture that previously bricked open tabs until manual refresh.)
212
- 5. WITHOUT touching the tab, immediately run `tools/call get_page_commands` from Claude Desktop again.
213
- 6. Inspect the tab's DevTools Console (F12 → Console). Filter for `Darwinium`.
214
-
215
- **Expected:**
216
- - **Step 5 outcome:** the call succeeds (the SW's `chrome.runtime.onInstalled` re-injection completed; the new content scripts forward to the new SW; the binary keeps no page-id state, so the fresh `listCommands` round-trip is all it needs). Acceptable alternative: the call returns the new TAB_STALE error verbatim — `Extension was reloaded; refresh the connected portal tab to restore the bridge. Re-injection is being attempted automatically.` — in which case a SECOND `tools/call get_page_commands` issued ~1 second later succeeds.
217
- - **Step 6 outcome:** at most ONE `Darwinium extension was reloaded — refresh this tab to restore the MCP bridge.` warning line in the console (NOT spammed; if the warning appears, it's because the OLD content script's listener fired before the NEW re-injected one took over — both legitimate). NO `Uncaught Error: Extension context invalidated.` from `portal-isolated.js`.
218
- - **Popup state:** transitions through `Connecting...` → `Connected:` (no false `Disconnected` flash; Plan 02-06's debounce holds).
219
- - **SW console** (chrome://extensions → "Inspect views: service worker"): one log line of the form `[portal-extension] re-injected content scripts into N open Darwinium tab(s) (reason: update)`.
220
-
221
- **Failure modes:**
222
- - If Step 5 returns `Not connected. Click Connect...` (NO_TAB) instead of TAB_STALE or success: command-router.ts substring detection failed to match the Chrome version's wording. Inspect the actual `(err as Error).message` in the SW console; widen the regex in command-router.ts.
223
- - If Step 6 shows `Uncaught Error: Extension context invalidated.`: the Layer 1 guards in portal-isolated.content.ts didn't catch a chrome.* call site. Re-audit every chrome.* in the file.
224
- - If the SW console shows `chrome.scripting.executeScript ... permission denied`: the 'scripting' permission did not bake into the manifest — Task 1 fix needed.
225
-
226
- ### D-A2 ChatModal Regression Check (PAGE-05)
227
-
228
- This verifies that Phase 2's external-MCP path does NOT change the in-portal ChatModal's
229
- existing per-command approval UX (D-A2 lock).
230
-
231
- 1. With the binary stopped (or just don't connect via the popup), open the in-portal chat
232
- modal on a journey page (the existing `ChatModal` component, NOT the extension popup).
233
- 2. Issue a chat message that triggers the LLM to call a non-auto-accept page command
234
- (per the existing `autoAcceptRPCFunctions` list).
235
- 3. **Expected:** the in-portal chat shows the existing approval UX (per
236
- `EnhancedChat.tsx` `showRPCConfirmation`) — same behavior as before Phase 2.
237
- 4. Verify by scrolling through the chat history that no Phase 2 changes (popup, SW connection
238
- state, etc.) affect the in-portal flow.
239
-
240
- ---
241
-
242
- If any scenario FAILs, file a gap-closure ticket; `/gsd-plan-phase --gaps 02-end-to-end-round-trip`
243
- generates a Phase 2 Wave 3 plan to address it.
244
-
245
- ## Phase 3 Manual Test Matrix
246
-
247
- These scenarios verify the Phase 3 customer install flow end-to-end. Each row in
248
- the cross-platform matrix should be run by a human operator on a fresh / dirty
249
- state per the pre-state column. Capture stderr transcripts (so they can be
250
- diffed against the locked UI-SPEC §Surface 2 expected lines) and on-disk state
251
- (token file mode, claude_desktop_config.json contents, env-paths data dir).
252
-
253
- ### Cross-platform install end-to-end
254
-
255
- | Platform | Pre-state | Command | Expected stderr lines (UI-SPEC §Surface 2) | Token file | claude_desktop_config.json | Pair flow |
256
- | -------- | ---------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
257
- | macOS | clean (no env-paths) | `npx @darwinium/portal-mcp install` | `install starting...`, `✓ token (added at ~/Library/Application Support/darwinium-portal-mcp/token, mode 0600)`, `✓ extension (extracted ...)`, `✓ config (added ...)`, boxed pairing prompt, `waiting for popup...` | `~/Library/Application Support/darwinium-portal-mcp/token` mode 0600 | `~/Library/Application Support/Claude/claude_desktop_config.json` with `.bak` | popup paste → `✓ paired with extension`, exit 0 |
258
- | macOS | re-install (unchanged) | `npx @darwinium/portal-mcp install` | `✓ token (existing at ..., mode 0600)`, `✓ config (existing match)`, `✓ extension (existing v...)` | unchanged | unchanged (no `.bak` written) | popup paste → exit 0 |
259
- | macOS | rotate-token | `npx @darwinium/portal-mcp rotate-token` | `rotate-token starting...`, `✓ token (regenerated at ..., mode 0600)`, boxed pairing prompt with "click \"Re-pair\"" verb | new 64-hex value at same path, mode 0600 | unchanged | popup Re-pair → `rotate complete. The old token is no longer accepted.` |
260
- | Windows | clean | `npx @darwinium/portal-mcp install` | as macOS but with `, ACL locked` suffix on the token line; LOCALAPPDATA path used | `%LOCALAPPDATA%\darwinium-portal-mcp\Data\token` icacls inheritance:r, user:F | `%APPDATA%\Claude\claude_desktop_config.json` with `.bak` | as macOS |
261
- | Linux | clean | `npx @darwinium/portal-mcp install` | as macOS, no migration line (Linux silently skips Phase 2 → Phase 3 migration) | `~/.local/share/darwinium-portal-mcp/token` mode 0600 | `~/.config/Claude/claude_desktop_config.json` with `.bak` | as macOS |
262
-
263
- ### First-launch migration test (D-F1, macOS / Windows)
264
-
265
- 1. Pre-state: `printf '%s' "<random 64-hex>" > ~/.config/darwinium-portal-mcp/token; chmod 0600 ~/.config/darwinium-portal-mcp/token` on macOS (or the Windows equivalent path).
266
- 2. `npx @darwinium/portal-mcp install`.
267
- 3. Expected stderr line: `portal-mcp: ✓ migrated token from ~/.config/darwinium-portal-mcp/token to <new env-paths path>`.
268
- 4. Verify on-disk: token now at the env-paths data dir; Phase 2 path no longer exists.
269
-
270
- ### Malformed claude_desktop_config.json test (D-F2)
271
-
272
- 1. Pre-state: write `not json` to the platform-correct config path.
273
- 2. `npx @darwinium/portal-mcp install`.
274
- 3. Expected stderr line (verbatim): `portal-mcp: ✗ config (refusing to write — <path> is not valid JSON; please fix or delete and re-run)`.
275
- 4. Verify exit code is 1; the malformed file is unchanged on disk (no overwrite).
276
-
277
- ### Re-arm test (D-E3)
278
-
279
- 1. `npx @darwinium/portal-mcp install`.
280
- 2. Wait 60s without entering the code into the popup.
281
- 3. Expected stderr: `portal-mcp: code expired. 0 successful pairings.\nportal-mcp: press Enter to generate a new one, or Ctrl+C to abort.`.
282
- 4. Press Enter; verify a fresh boxed pairing-code prompt is reprinted with a different 6-digit code.
283
-
284
- ### 3-attempt lockout test (D-E3)
285
-
286
- 1. `npx @darwinium/portal-mcp install`.
287
- 2. From a separate terminal, send three deliberately-wrong codes via wscat:
288
- ```sh
289
- wscat -c ws://127.0.0.1:9224 -s darwinium.v1 -s pair.000000
290
- wscat -c ws://127.0.0.1:9224 -s darwinium.v1 -s pair.000001
291
- wscat -c ws://127.0.0.1:9224 -s darwinium.v1 -s pair.000002
292
- ```
293
- 3. Expected stderr (in order): `⚠ wrong code (attempt 1 of 3).` → `⚠ wrong code (attempt 2 of 3).` → `⚠ wrong code (attempt 3 of 3).` followed by the re-arm prompt.
294
-
295
- ### Doctor pass/fail/warn matrix
296
-
297
- Run `npx @darwinium/portal-mcp doctor --json | jq` in each of:
298
-
299
- 1. **Clean install** — all 9 checks pass except `git.token-tree-warning` (warn if TOKEN_DIR is inside a git work tree).
300
- 2. **Missing token** — `token.mode` and `token.parent.mode` fail; `extension.reachable` fails (no token to handshake with).
301
- 3. **Malformed config** — `config.desktop.entry` fails with detail `not valid JSON`.
302
- 4. **Port 9224 held** — start `serve` in another terminal first; `port.9224.bindable` fails with EADDRINUSE detail.
303
- 5. **Token inside git tree** — `git.token-tree-warning` warns with the repo root in the detail.
304
- 6. **Claude Desktop absent** — `config.desktop.entry` is `level:'warn'` (not fail), so exit code is still 0 if everything else passes.
305
-
306
- For each, verify: stdout is a single JSON line, stderr is empty (`--json` mode), `result.checks.length === 9`, exit code matches `summary.fail > 0 ? 1 : 0`.
307
-
308
- ### Marketplace install path (DIST-02)
309
-
310
- 1. From inside Claude Code: `/plugin marketplace add darwinium-com/portal-mcp-marketplace` (waiting on plan 03-03).
311
- 2. `/plugin install portal-mcp`.
312
- 3. `npx @darwinium/portal-mcp doctor --json | jq '.checks[] | select(.id == "config.code.marketplace")'`.
313
- 4. Verify the check passes with detail set to the resolved plugin path.
314
- 5. Run a tool call from inside Claude Code (e.g. `get_page_commands`) — verify response contains the live page commands.
315
-
316
- ### D-A2 ChatModal regression check
317
-
318
- This verifies that Phase 3's external customer install flow does NOT change the
319
- in-portal ChatModal's existing per-command approval UX (D-A2 lock — Phase 1
320
- lineage).
321
-
322
- 1. With the binary stopped (or the popup not connected), open the in-portal
323
- chat modal on a journey page.
324
- 2. Issue a chat message that triggers a non-auto-accept page command.
325
- 3. Expected: the existing approval UX appears (per `EnhancedChat.tsx`
326
- `showRPCConfirmation`) — same behavior as Phase 1 + Phase 2.
327
- 4. Verify by scrolling chat history that no Phase 3 changes (install flow,
328
- pairing, OOB code) affect the in-portal flow.
329
-
330
- ---
331
-
332
- ## CI Matrix (recommended — Wave 2 task)
333
-
334
- End-to-end install + popup pair is necessarily manual on the dev OS (Chrome
335
- extension popup interaction). What CAN be automated cross-platform is the
336
- structured-output contract. RESEARCH §Open Question 15 recommends:
337
-
338
- ```yaml
339
- # .github/workflows/portal-mcp-doctor-matrix.yml (Wave 2 — not in this plan)
340
- strategy:
341
- matrix:
342
- os: [ubuntu-latest, macos-latest, windows-latest]
343
- runs-on: ${{ matrix.os }}
344
- steps:
345
- - uses: actions/checkout@v4
346
- - uses: actions/setup-node@v4
347
- with: { node-version: 20 }
348
- - run: yarn install --immutable
349
- - run: yarn workspace @darwinium/portal-mcp build
350
- - run: yarn workspace @darwinium/portal-mcp smoke-test
351
- - run: |
352
- node dwn_aphex/packages/portal-mcp/dist/bin/portal-mcp.js doctor --json > doctor.json
353
- # Assert the structured-output contract:
354
- jq -e '.checks | length == 9' doctor.json
355
- jq -e '.platform | IN("darwin", "win32", "linux")' doctor.json
356
- jq -e '.checks | map(.id) | sort' doctor.json
357
- ```
358
-
359
- The 9-check count + locked check IDs + platform-correct env-paths resolution
360
- are the most important cross-platform invariants; a failed CI matrix here
361
- indicates a stable-API regression that customers would hit.
362
-
363
- ---
364
-
365
- ## Unaided-walkthrough protocol (DIST-05 SC-4 gate)
366
-
367
- This is the truth-of-the-pudding test for whether the customer-facing setup docs
368
- actually work. Per RESEARCH §Pitfall 9, the docs are only "done" if someone who
369
- has not seen the project can complete setup using only the public URLs.
370
-
371
- **Recruit ONE Darwinium engineer who has NOT been on standup or planning sessions
372
- for portal-mcp.** Ideal verifier: a backend or infra engineer unfamiliar with the
373
- portal frontend.
374
-
375
- Provide them ONLY:
376
-
377
- - The public setup URL: <https://darwinium.com/portal-mcp/setup>
378
- - The npm install command: `npx -y @darwinium/portal-mcp install`
379
- - A blank notepad and a stopwatch.
380
-
381
- Do NOT answer questions during the walkthrough. Sit silently and observe.
382
- Capture:
383
-
384
- - **Time-to-first-Connected-state** (target: < 5 minutes from start to popup
385
- showing `Connected: <tab URL>`).
386
- - **Every confusion point.** "What does this mean?" / "Where is this option?" /
387
- any moment they hesitate or backtrack — record verbatim.
388
- - **Every command they ran that wasn't in the docs.** This is the strongest
389
- signal that a step is under-documented.
390
- - **Every error they hit and how (or whether) they recovered.** Errors the docs
391
- didn't preempt are doc bugs.
392
-
393
- Capture results in
394
- `.planning/phases/03-install-flow-customer-docs/UNAIDED-WALKTHROUGH-RESULT.md`
395
- using the template in plan 03-04 Task 6 — verifier name + role + exposure level,
396
- time-to-paired, confusion points, commands run, errors hit, doc revisions
397
- required, three sign-off boxes.
398
-
399
- **Pass criteria:** the walkthrough completes WITHOUT external help. If the
400
- verifier needs help, the docs need revision; iterate (preferably with a
401
- different verifier on the second pass — first-time-eyes is the test) before
402
- Phase 3 close.
403
-
404
- ---
405
-
406
- ## Cross-platform CI matrix expectations
407
-
408
- `.github/workflows/portal-mcp-cross-platform.yml` runs `doctor --json` on each
409
- platform (`ubuntu-latest`, `macos-latest`, `windows-latest`) and asserts the
410
- locked **structural** contract:
411
-
412
- - `result.version` is a non-empty string (the binary's semver).
413
- - `result.platform` matches `process.platform` of the runner
414
- (`linux` / `darwin` / `win32`).
415
- - `result.checks.length === 9`.
416
- - The 9 check IDs match the locked set: `binary.present`, `token.mode`,
417
- `token.parent.mode`, `token.acl`, `config.desktop.entry`,
418
- `config.code.marketplace`, `extension.reachable`, `port.9224.bindable`,
419
- `git.token-tree-warning`.
420
- - `result.summary` has numeric `pass` / `fail` / `warn` fields.
421
-
422
- **Individual check pass/fail is NOT asserted by CI.** Reason: Claude Desktop /
423
- Claude Code are not installed on the GitHub-hosted runners, and the Chrome
424
- extension is not loaded. The `extension.reachable` check will fail every time;
425
- the `config.desktop.entry` and `config.code.marketplace` checks will warn. That
426
- is the expected runner state. The structural contract — which IDs exist, the
427
- platform field, the version string — is what CI gates.
428
-
429
- **Why a structural-only gate is sufficient:** drift in the locked check IDs or
430
- platform field is a customer-impacting stable-API regression. Drift in
431
- individual pass/fail is environment-dependent and would produce false alarms.
432
- End-to-end install + popup pair is necessarily manual on the dev OS; the
433
- developer signs off on the manual matrix in the Phase 3 close gate.
434
-
435
- The workflow has a second job, `wxt-zip-artifact`, that runs only on
436
- `ubuntu-latest` (sufficient for the artifact build) and asserts the production
437
- `wxt zip` artifact's manifest excludes `localhost` host_permissions (WR-07
438
- enforcement at the Web-Store-shippable artifact level). The uploaded
439
- `portal-extension-zip` artifact becomes the source of truth for Phase 4 Web
440
- Store submission.
441
-
442
- ---
443
-
444
- ## Four-error-mode regression matrix (DIST-06)
445
-
446
- For each of the four MCP error modes (D-C2..D-C5), walk through the
447
- customer-facing UX before Phase 3 close. The customer troubleshooting doc
448
- (DIST-06, staged at
449
- `.planning/phases/03-install-flow-customer-docs/customer-docs/troubleshooting.md`)
450
- covers all four; this matrix verifies the doc + the binary + the extension are
451
- consistent.
452
-
453
- | Error mode | Trigger | Expected popup state | Expected `doctor` check |
454
- |--------------------------|------------------------------------------------------------------------------------------|---------------------------------------------------------------|-------------------------------------------------------------|
455
- | No tab connected | Close all `*.darwinium.com` tabs; ask Claude to call `get_page_commands` | `Disconnected` (paired) — pill text matches D-C2 verbatim | `extension.reachable: fail (timeout)` |
456
- | Connection lost mid-call | Stop the SW mid-command (chrome://extensions → Inspect → close DevTools); immediately retry | `Connecting...` then auto-recovers within ≤30 s | `extension.reachable: pass after the recover` |
457
- | Page navigated mid-call | Start a long page command; navigate the connected tab during; observe rejection | `Connected: <new tab URL>` (popup updates after navigation) | `extension.reachable: pass` |
458
- | Token mismatch | Run `npx -y @darwinium/portal-mcp rotate-token`; observe popup transition | `Token mismatch` → after Re-pair flow → `Connected: <tab URL>` | `extension.reachable: 4401` (transient) then `pass` |
459
-
460
- Each row should be tested manually before Phase 3 close. Record PASS / FAIL
461
- inline. If any row fails, the bug is in one of three places: the
462
- troubleshooting doc copy is wrong, the binary's error message is wrong, or the
463
- popup's state machine is wrong. The doctor check is the tie-breaker — if doctor
464
- agrees with the popup but the troubleshooting doc says otherwise, fix the doc.
465
-
466
- ---
467
-
468
- ## Troubleshooting
469
-
470
- - If the binary's first stdout line isn't JSON-RPC, a transitive dependency is
471
- logging on import. Run `node --trace-warnings dist/bin/portal-mcp.js serve` and
472
- look for `console.log` calls. See PITFALLS.md Pitfall 1.
473
- - If port 9224 is held: `lsof -i:9224` shows the holding process.
474
- - If the second instance prints a stack trace instead of the user-readable message,
475
- file a bug — the smoke-test-eaddrinuse should have caught this.
476
- - If the wscat upgrade fails with `Unexpected server response: 400` on the SW path
477
- (Phase 2), confirm both subprotocols are passed: `-s darwinium.v1 -s tok.<hex>`.
478
- Without them `handleProtocols` rejects (Pitfall 1).