|
| 1 | +# Lessons Learned: `dapp derive-ii-principal` on Headless Linux CI |
| 2 | + |
| 3 | +## What the command does |
| 4 | + |
| 5 | +`dapp derive-ii-principal <origin>` opens a headless Playwright/Chromium browser, |
| 6 | +navigates to a page at the canister origin, triggers an II login popup, clicks |
| 7 | +through the II UI, and writes the derived principal to stdout. Called 4× in |
| 8 | +`04-deploy.sh` to set controllers on freshly created canisters. |
| 9 | + |
| 10 | +--- |
| 11 | + |
| 12 | +## Root cause: icp-cli 0.2.1 config bug (not an II update) |
| 13 | + |
| 14 | +`icp-cli` bundles a dev build of Internet Identity that has **DUMMY_AUTH compiled |
| 15 | +in** (`pP=!0` build flag). In DUMMY_AUTH mode, the II auth class is always `ga` |
| 16 | +(a pure software Ed25519 key), which never calls any WebAuthn APIs. No platform |
| 17 | +authenticator is needed. |
| 18 | + |
| 19 | +However, `icp-cli` 0.2.1 deploys this canister with a buggy config: |
| 20 | + |
| 21 | +``` |
| 22 | +dummy_auth = opt null ← icp-cli 0.2.1 (WRONG: Some(None)) |
| 23 | +dummy_auth = opt opt {...} ← correct would be Some(Some({...})) |
| 24 | +``` |
| 25 | + |
| 26 | +The II bundle checks: `Vr.dummy_auth[0]?.[0] !== void 0` |
| 27 | + |
| 28 | +- With `opt null` (Some(None)): `Vr.dummy_auth[0]` is `None`/`undefined` → check is `false` → **real WebAuthn path** (`ps` class, calls `credentials.create()`) |
| 29 | +- With `opt opt {...}` (Some(Some({...}))): check is `true` → **DUMMY_AUTH path** (`ga` class, pure software) |
| 30 | + |
| 31 | +On headless Linux, `credentials.create()` throws `NotSupportedError`. That's the |
| 32 | +entire failure. The bundle has DUMMY_AUTH capability; the config bug disables it. |
| 33 | + |
| 34 | +**This is not an II version change.** The same bug exists in all icp-cli 0.2.x |
| 35 | +releases. The CI used a slightly newer II build than local (`start.xYiFCL5p.js` |
| 36 | +vs `start.CggNA08c.js`), but both have the same config bug. |
| 37 | + |
| 38 | +--- |
| 39 | + |
| 40 | +## The fix |
| 41 | + |
| 42 | +Patch the II JS bundle in the `context.route()` proxy handler to force the |
| 43 | +DUMMY_AUTH check to `true`: |
| 44 | + |
| 45 | +```javascript |
| 46 | +// In context.route() after decompressing the response body: |
| 47 | +const p = url.pathname; |
| 48 | +if (p.includes('/_app/immutable/entry/start') && p.endsWith('.js')) { |
| 49 | + let src = body.toString('utf8'); |
| 50 | + // Force DUMMY_AUTH — bypasses all real WebAuthn, uses pure Ed25519 software key |
| 51 | + src = src.replaceAll('Vr.dummy_auth[0]?.[0]!==void 0?', 'true?'); |
| 52 | + // Unique seed per invocation so each call registers a fresh credential; |
| 53 | + // avoids "credential already registered" conflicts across the 4 CLI calls |
| 54 | + src = src.replace('return BigInt(0)}', 'return BigInt(Math.floor(Math.random()*Number.MAX_SAFE_INTEGER))}'); |
| 55 | + body = Buffer.from(src, 'utf8'); |
| 56 | +} |
| 57 | +``` |
| 58 | + |
| 59 | +With DUMMY_AUTH active, no `credentials.create()` or `credentials.get()` is ever |
| 60 | +called. The entire CDP virtual authenticator approach (which dominated earlier |
| 61 | +debugging sessions) was unnecessary. |
| 62 | + |
| 63 | +--- |
| 64 | + |
| 65 | +## Remaining CI patches (environment workarounds only) |
| 66 | + |
| 67 | +These fix headless Linux environment gaps. They do not change II auth logic. |
| 68 | + |
| 69 | +### 1 — DNS: `*.localhost` doesn't resolve |
| 70 | + |
| 71 | +**Problem**: Chromium's sandboxed network service on Linux doesn't resolve |
| 72 | +`*.localhost` subdomains. PocketIC canisters live at `<id>.localhost:8080`. |
| 73 | + |
| 74 | +**Fix**: `context.route()` intercepts all `*.localhost` requests and proxies them |
| 75 | +via Node.js `http.request()` to `127.0.0.1`, preserving the `Host` header so |
| 76 | +PocketIC routes to the right canister. |
| 77 | + |
| 78 | +**Why not `--host-resolver-rules` alone?** The Chrome flag works for browser |
| 79 | +requests but not for Playwright's `route.fetch()`, which runs in Node.js and uses |
| 80 | +OS DNS. Since we need to patch the bundle anyway, the Node.js proxy handles both. |
| 81 | + |
| 82 | +### 2 — Gzip: `route.fulfill()` doesn't decompress |
| 83 | + |
| 84 | +**Problem**: PocketIC serves gzip/brotli assets. `route.fulfill()` passes the |
| 85 | +raw bytes to Chrome with hop-by-hop headers stripped; Chrome tries to parse |
| 86 | +compressed bytes as JS and throws "Invalid or unexpected token". |
| 87 | + |
| 88 | +**Fix**: Decompress in Node.js (`zlib.gunzipSync`, `brotliDecompressSync`) before |
| 89 | +calling `route.fulfill()`, and strip all hop-by-hop headers. |
| 90 | + |
| 91 | +### 3 — `isUserVerifyingPlatformAuthenticatorAvailable()` returns false |
| 92 | + |
| 93 | +**Problem**: Newer II builds (icp-cli 0.2.1's `start.xYiFCL5p.js`) gate the |
| 94 | +"Create new identity" button on this returning `true`. Headless Linux returns |
| 95 | +`false`, leaving the button disabled even after DUMMY_AUTH is forced. |
| 96 | + |
| 97 | +**Fix**: `addInitScript` overrides the API to return `Promise.resolve(true)` before |
| 98 | +any page JS runs. Must be synchronous (before II's SvelteKit boot code), which is |
| 99 | +why `addInitScript` is used rather than a post-load patch. |
| 100 | + |
| 101 | +### 4 — `credentials.get()` throws `NotSupportedError`, freezes UI |
| 102 | + |
| 103 | +**Problem**: The II `/authorize` page calls `credentials.get({allowCredentials: []})` |
| 104 | +on mount for passkey autofill. On headless Linux this throws `NotSupportedError`, |
| 105 | +which propagates up and blocks the UI from rendering buttons. |
| 106 | + |
| 107 | +**Fix**: `addInitScript` replaces `navigator.credentials.get` with a function that |
| 108 | +rejects with `NotAllowedError`. II treats this as "no passkeys found" and renders |
| 109 | +the UI normally. |
| 110 | + |
| 111 | +--- |
| 112 | + |
| 113 | +## The dead end: CDP virtual authenticators |
| 114 | + |
| 115 | +Earlier debugging sessions spent significant time building a CDP virtual |
| 116 | +authenticator approach: intercept `credentials.create()`, synthesise a valid |
| 117 | +WebAuthn attestation object with correct CBOR, DER signatures, authData layout, |
| 118 | +etc. (9 layers of patches). **This was entirely unnecessary.** |
| 119 | + |
| 120 | +The clue that should have ended this earlier: `icp-cli` ships a *dev* II build. |
| 121 | +Dev builds never need real WebAuthn. If you're patching CBOR decoders and |
| 122 | +generating synthetic ECDSA signatures, you're on the wrong path. |
| 123 | + |
| 124 | +**Rule**: Before adding any WebAuthn workaround for local II dev, confirm |
| 125 | +`dummy_auth` is active. If DUMMY_AUTH is supposed to be on but isn't, the config |
| 126 | +is wrong — fix the config (or patch the check), don't simulate WebAuthn. |
| 127 | + |
| 128 | +--- |
| 129 | + |
| 130 | +## 4-call credential conflict |
| 131 | + |
| 132 | +`04-deploy.sh` calls `derive-ii-principal` 4× against the same running II |
| 133 | +canister. Without the random seed patch, all 4 calls would derive the same |
| 134 | +Ed25519 key (seed 0 → 32 zero bytes credential ID). The first call registers it; |
| 135 | +calls 2–4 fail with "credential already registered". |
| 136 | + |
| 137 | +The `vz()` random seed patch ensures each CLI invocation creates a unique |
| 138 | +credential, so all 4 calls succeed independently. |
| 139 | + |
| 140 | +For Playwright E2E tests the same issue applies: each test gets a fresh browser |
| 141 | +context (no II session) but shares the same II canister. A per-context random |
| 142 | +seed (generated once in the `tests/fixtures.ts` fixture) prevents conflicts |
| 143 | +between tests while allowing the second II popup within the same test to reuse |
| 144 | +the existing session ("Continue" appears directly). |
| 145 | + |
| 146 | +--- |
| 147 | + |
| 148 | +## Files changed |
| 149 | + |
| 150 | +| File | Purpose | |
| 151 | +|------|---------| |
| 152 | +| `packages-rs/my-canister-dapp-cli/playwright-js/derive-principal.mjs` | CLI: all 4 patches above | |
| 153 | +| `tests/fixtures.ts` | E2E tests: same 4 patches as a Playwright test fixture | |
| 154 | +| `tests/ii-helpers.ts` | Simplified II popup flow (CWP → "Create new identity" directly) | |
0 commit comments