@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.
package/TESTING.md ADDED
@@ -0,0 +1,478 @@
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).
@@ -0,0 +1,59 @@
1
+ import * as path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ //#region src/install/bundlePaths.ts
5
+ /**
6
+ * Path resolution that survives single-file compilation.
7
+ *
8
+ * The npm distribution runs `dist/bin/*.js` under Node, where `import.meta.url`
9
+ * points at a real file and bundled resources sit at `<package-root>/`. The
10
+ * macOS distribution (`scripts/build-macos-bundle.mjs`) instead compiles this
11
+ * source into one executable with `bun build --compile`, where every module is
12
+ * served from a virtual filesystem:
13
+ *
14
+ * import.meta.url file:///$bunfs/root/portal-mcp ← NOT on disk
15
+ * process.argv[1] /$bunfs/root/portal-mcp ← NOT on disk
16
+ * process.execPath /path/to/portal-mcp ← the real binary
17
+ *
18
+ * So anything that resolves a sibling file from `import.meta.url` silently
19
+ * resolves inside `/$bunfs/` and fails `existsSync`. Both call sites that care
20
+ * — the bundled extension zip in `install.ts` and `binary.present` in
21
+ * `doctor.ts` — go through this module instead.
22
+ */
23
+ /** Prefix bun mounts compiled modules under. Not a real filesystem path. */
24
+ const COMPILED_VFS_PREFIX = "/$bunfs/";
25
+ /** True when running from a `bun build --compile` single-file executable. */
26
+ function isCompiledBinary() {
27
+ return fileURLToPath(import.meta.url).startsWith(COMPILED_VFS_PREFIX);
28
+ }
29
+ /**
30
+ * On-disk path of the running program.
31
+ *
32
+ * Under a compiled binary `argv[1]` is a virtual path, so `doctor`'s
33
+ * `binary.present` check must stat `process.execPath` instead. Under Node
34
+ * `execPath` is the node runtime itself, so `argv[1]` remains correct there.
35
+ */
36
+ function selfPath() {
37
+ return isCompiledBinary() ? process.execPath : process.argv[1] ?? "";
38
+ }
39
+ /**
40
+ * Directory containing bundled resources (currently just `extension-bundle/`).
41
+ *
42
+ * - Compiled binary: resources ship beside the executable, so this is the
43
+ * executable's own directory. In the `.mcpb` layout that is
44
+ * `<extension-dir>/server/`, holding `portal-mcp` and `extension-bundle/`.
45
+ * - Node/npm: chunks land in `<package-root>/dist/bin/`, so the package root —
46
+ * holding `extension-bundle/` per package.json `files` — is two levels up.
47
+ */
48
+ function bundleResourceRoot() {
49
+ if (isCompiledBinary()) return path.dirname(process.execPath);
50
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
51
+ }
52
+ /** Absolute path to the bundled Chrome extension zip. */
53
+ function bundledExtensionZipPath() {
54
+ return path.join(bundleResourceRoot(), "extension-bundle", "extension.zip");
55
+ }
56
+
57
+ //#endregion
58
+ export { selfPath as n, bundledExtensionZipPath as t };
59
+ //# sourceMappingURL=bundlePaths-CSftU0OK.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bundlePaths-CSftU0OK.js","names":[],"sources":["../../src/install/bundlePaths.ts"],"sourcesContent":["/**\n * Path resolution that survives single-file compilation.\n *\n * The npm distribution runs `dist/bin/*.js` under Node, where `import.meta.url`\n * points at a real file and bundled resources sit at `<package-root>/`. The\n * macOS distribution (`scripts/build-macos-bundle.mjs`) instead compiles this\n * source into one executable with `bun build --compile`, where every module is\n * served from a virtual filesystem:\n *\n * import.meta.url file:///$bunfs/root/portal-mcp ← NOT on disk\n * process.argv[1] /$bunfs/root/portal-mcp ← NOT on disk\n * process.execPath /path/to/portal-mcp ← the real binary\n *\n * So anything that resolves a sibling file from `import.meta.url` silently\n * resolves inside `/$bunfs/` and fails `existsSync`. Both call sites that care\n * — the bundled extension zip in `install.ts` and `binary.present` in\n * `doctor.ts` — go through this module instead.\n */\nimport * as path from 'node:path';\nimport { fileURLToPath } from 'node:url';\n\n/** Prefix bun mounts compiled modules under. Not a real filesystem path. */\nconst COMPILED_VFS_PREFIX = '/$bunfs/';\n\n/** True when running from a `bun build --compile` single-file executable. */\nexport function isCompiledBinary(): boolean {\n return fileURLToPath(import.meta.url).startsWith(COMPILED_VFS_PREFIX);\n}\n\n/**\n * On-disk path of the running program.\n *\n * Under a compiled binary `argv[1]` is a virtual path, so `doctor`'s\n * `binary.present` check must stat `process.execPath` instead. Under Node\n * `execPath` is the node runtime itself, so `argv[1]` remains correct there.\n */\nexport function selfPath(): string {\n return isCompiledBinary() ? process.execPath : (process.argv[1] ?? '');\n}\n\n/**\n * Directory containing bundled resources (currently just `extension-bundle/`).\n *\n * - Compiled binary: resources ship beside the executable, so this is the\n * executable's own directory. In the `.mcpb` layout that is\n * `<extension-dir>/server/`, holding `portal-mcp` and `extension-bundle/`.\n * - Node/npm: chunks land in `<package-root>/dist/bin/`, so the package root —\n * holding `extension-bundle/` per package.json `files` — is two levels up.\n */\nexport function bundleResourceRoot(): string {\n if (isCompiledBinary()) {\n return path.dirname(process.execPath);\n }\n return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');\n}\n\n/** Absolute path to the bundled Chrome extension zip. */\nexport function bundledExtensionZipPath(): string {\n return path.join(bundleResourceRoot(), 'extension-bundle', 'extension.zip');\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;AAsBA,MAAM,sBAAsB;;AAG5B,SAAgB,mBAA4B;AAC1C,QAAO,cAAc,OAAO,KAAK,IAAI,CAAC,WAAW,oBAAoB;;;;;;;;;AAUvE,SAAgB,WAAmB;AACjC,QAAO,kBAAkB,GAAG,QAAQ,WAAY,QAAQ,KAAK,MAAM;;;;;;;;;;;AAYrE,SAAgB,qBAA6B;AAC3C,KAAI,kBAAkB,CACpB,QAAO,KAAK,QAAQ,QAAQ,SAAS;AAEvC,QAAO,KAAK,QAAQ,KAAK,QAAQ,cAAc,OAAO,KAAK,IAAI,CAAC,EAAE,MAAM,KAAK;;;AAI/E,SAAgB,0BAAkC;AAChD,QAAO,KAAK,KAAK,oBAAoB,EAAE,oBAAoB,gBAAgB"}