@wipcomputer/wip-ldm-os 0.4.85-alpha.3 → 0.4.85-alpha.32

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.
Files changed (67) hide show
  1. package/README.md +22 -2
  2. package/SKILL.md +136 -14
  3. package/bin/ldm.js +525 -75
  4. package/docs/universal-installer/SPEC.md +16 -3
  5. package/docs/universal-installer/TECHNICAL.md +4 -4
  6. package/lib/deploy.mjs +104 -20
  7. package/lib/detect.mjs +35 -4
  8. package/lib/registry-migrations.mjs +296 -0
  9. package/package.json +24 -3
  10. package/scripts/test-boot-hook-registration.mjs +136 -0
  11. package/scripts/test-boot-payload-trim.mjs +200 -0
  12. package/scripts/test-crc-agentid-tenant-boundary.mjs +80 -0
  13. package/scripts/test-crc-e2ee-key-persistence.mjs +150 -0
  14. package/scripts/test-crc-e2ee-session-route.mjs +129 -0
  15. package/scripts/test-crc-pair-login-flow.mjs +115 -0
  16. package/scripts/test-crc-pair-relink-audit-and-rotation.mjs +164 -0
  17. package/scripts/test-crc-pair-status-poll-token.mjs +73 -0
  18. package/scripts/test-crc-websocket-abuse-limits.mjs +128 -0
  19. package/scripts/test-doctor-hook-dedupe.mjs +188 -0
  20. package/scripts/test-install-prompt-policy.mjs +84 -0
  21. package/scripts/test-installer-skill-directory.mjs +55 -0
  22. package/scripts/test-installer-skill-dry-run-destinations.mjs +100 -0
  23. package/scripts/test-installer-target-self-update.mjs +131 -0
  24. package/scripts/test-kaleidoscope-onboarding-copy.mjs +170 -0
  25. package/scripts/test-kaleidoscope-public-stats-baseline.mjs +129 -0
  26. package/scripts/test-kaleidoscope-qr-authenticator-confirmation.mjs +89 -0
  27. package/scripts/test-ldm-status-concurrency.mjs +118 -0
  28. package/scripts/test-ldm-status-timeout.mjs +96 -0
  29. package/scripts/test-legacy-npm-sources-migration.mjs +460 -0
  30. package/scripts/test-readme-install-prompt.mjs +66 -0
  31. package/shared/boot/boot-config.json +18 -8
  32. package/shared/docs/dev-guide-wipcomputerinc.md.tmpl +5 -4
  33. package/shared/rules/security.md +4 -0
  34. package/shared/templates/install-prompt.md +20 -2
  35. package/src/boot/README.md +24 -1
  36. package/src/boot/boot-config.json +18 -8
  37. package/src/boot/boot-hook.mjs +118 -28
  38. package/src/boot/installer.mjs +33 -10
  39. package/src/hosted-mcp/.env.example +4 -0
  40. package/src/hosted-mcp/README.md +37 -0
  41. package/src/hosted-mcp/app/footer.js +74 -0
  42. package/src/hosted-mcp/app/kaleidoscope-login.html +1290 -0
  43. package/src/hosted-mcp/app/pair.html +165 -57
  44. package/src/hosted-mcp/app/sprites.png +0 -0
  45. package/src/hosted-mcp/app/wip-logo.png +0 -0
  46. package/src/hosted-mcp/codex-relay-e2ee-registry.mjs +208 -0
  47. package/src/hosted-mcp/codex-relay-ws-abuse-limits.mjs +140 -0
  48. package/src/hosted-mcp/demo/footer.js +2 -2
  49. package/src/hosted-mcp/demo/index.html +169 -51
  50. package/src/hosted-mcp/demo/login.html +390 -28
  51. package/src/hosted-mcp/demo/privacy.html +4 -214
  52. package/src/hosted-mcp/demo/tos.html +4 -189
  53. package/src/hosted-mcp/deploy.sh +308 -56
  54. package/src/hosted-mcp/docs/self-host.md +268 -0
  55. package/src/hosted-mcp/legal/internet-services/kaleidoscope/index.html +257 -0
  56. package/src/hosted-mcp/legal/internet-services/terms/site.html +224 -168
  57. package/src/hosted-mcp/legal/legal-footer.js +75 -0
  58. package/src/hosted-mcp/legal/legal.css +166 -0
  59. package/src/hosted-mcp/legal/privacy/en-ww/index.html +4 -221
  60. package/src/hosted-mcp/legal/privacy/index.html +253 -0
  61. package/src/hosted-mcp/nginx/codex-relay.conf +25 -0
  62. package/src/hosted-mcp/nginx/conf.d/redact-logs.conf +60 -0
  63. package/src/hosted-mcp/nginx/mcp-oauth.conf +58 -0
  64. package/src/hosted-mcp/nginx/wip.computer.conf +25 -1
  65. package/src/hosted-mcp/scripts/audit-logs.sh +205 -0
  66. package/src/hosted-mcp/scripts/verify-deploy.sh +102 -0
  67. package/src/hosted-mcp/server.mjs +1681 -166
@@ -0,0 +1,1290 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
+ <title>Kaleidoscope</title>
7
+ <meta name="description" content="Kaleidoscope by WIP Computer, Inc. Every AI. One experience.">
8
+ <style>
9
+ *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
10
+
11
+ :root {
12
+ --bg: #FFFFFF;
13
+ --text: #1a1a1a;
14
+ --text-muted: #8a8580;
15
+ --footer-muted: #6b6b6b;
16
+ --footer-border: #ececec;
17
+ --accent: #0033FF;
18
+ --accent-hover: #0033FF;
19
+ --input-bg: #F5F3ED;
20
+ --input-border: #E0DDD6;
21
+ --font: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
22
+ }
23
+
24
+ html, body {
25
+ height: 100%;
26
+ font-family: var(--font);
27
+ background: var(--bg);
28
+ color: var(--text);
29
+ -webkit-text-size-adjust: 100%;
30
+ -webkit-font-smoothing: antialiased;
31
+ }
32
+
33
+ .login-page {
34
+ display: flex;
35
+ flex-direction: column;
36
+ align-items: center;
37
+ justify-content: center;
38
+ min-height: 100vh;
39
+ min-height: 100dvh;
40
+ padding: 24px;
41
+ padding-top: calc(24px + env(safe-area-inset-top, 0px));
42
+ overflow-y: auto;
43
+ -webkit-overflow-scrolling: touch;
44
+ }
45
+
46
+ .login-card {
47
+ position: relative;
48
+ max-width: 380px;
49
+ width: calc(100vw - 48px);
50
+ text-align: center;
51
+ }
52
+
53
+ .login-title {
54
+ font-size: 26px;
55
+ font-weight: 600;
56
+ letter-spacing: -0.02em;
57
+ margin-bottom: 8px;
58
+ }
59
+
60
+ .login-buttons {
61
+ display: flex;
62
+ flex-direction: column;
63
+ gap: 12px;
64
+ margin-bottom: 16px;
65
+ }
66
+
67
+ .btn {
68
+ display: block;
69
+ width: 100%;
70
+ padding: 18px;
71
+ border: none;
72
+ border-radius: 12px;
73
+ font-size: 18px;
74
+ font-weight: 600;
75
+ font-family: var(--font);
76
+ cursor: pointer;
77
+ transition: background 0.15s, transform 0.1s;
78
+ -webkit-tap-highlight-color: transparent;
79
+ }
80
+
81
+ .btn:active {
82
+ transform: scale(0.98);
83
+ }
84
+
85
+ .btn-primary {
86
+ background: var(--accent);
87
+ color: white;
88
+ }
89
+
90
+ .btn-primary:hover {
91
+ background: var(--accent-hover);
92
+ }
93
+
94
+ .btn:disabled {
95
+ opacity: 0.5;
96
+ cursor: not-allowed;
97
+ transform: none;
98
+ }
99
+
100
+ .login-status {
101
+ margin-top: 16px;
102
+ font-size: 14px;
103
+ padding: 12px 16px;
104
+ border-radius: 10px;
105
+ display: none;
106
+ text-align: left;
107
+ }
108
+
109
+ .login-status.show { display: block; }
110
+ .login-status.loading { background: #E8EEFF; color: var(--accent); }
111
+ .login-status.error { background: #FFF0F0; color: #D32F2F; }
112
+ .login-status.success { background: #F0FFF4; color: #2E7D32; }
113
+
114
+ .site-footer {
115
+ padding: 0 0 32px;
116
+ line-height: 1.45;
117
+ }
118
+ .site-footer__inner {
119
+ max-width: 1040px;
120
+ margin: 0 auto;
121
+ padding: 48px 24px 0;
122
+ }
123
+ .site-footer__cols {
124
+ display: grid;
125
+ grid-template-columns: 1fr repeat(3, auto);
126
+ gap: 48px;
127
+ padding-bottom: 40px;
128
+ align-items: start;
129
+ border-top: 1px solid var(--footer-border);
130
+ padding-top: 48px;
131
+ margin-top: -48px;
132
+ }
133
+ .site-footer__col-group {
134
+ display: flex;
135
+ flex-direction: column;
136
+ gap: 28px;
137
+ }
138
+ .site-footer__brand-col {
139
+ display: flex;
140
+ flex-direction: column;
141
+ gap: 4px;
142
+ }
143
+ .site-footer__brand-name {
144
+ font-size: 13px;
145
+ color: var(--text);
146
+ font-weight: 500;
147
+ text-decoration: none;
148
+ transition: opacity 200ms ease;
149
+ }
150
+ .site-footer__brand-name:hover {
151
+ opacity: 0.65;
152
+ }
153
+ .site-footer__brand-tag {
154
+ font-size: 13px;
155
+ }
156
+ .site-footer__brand-tag,
157
+ .site-footer__col a,
158
+ .site-footer__bottom,
159
+ .site-footer__bottom .right a,
160
+ .site-footer__passkeys,
161
+ .site-footer__info,
162
+ .site-footer__link-icon {
163
+ color: var(--footer-muted);
164
+ }
165
+ .site-footer__col-title {
166
+ font-size: 13px;
167
+ font-weight: 600;
168
+ color: var(--text);
169
+ margin-bottom: 14px;
170
+ }
171
+ .site-footer__col ul {
172
+ list-style: none;
173
+ margin: 0;
174
+ padding: 0;
175
+ display: flex;
176
+ flex-direction: column;
177
+ gap: 10px;
178
+ }
179
+ .site-footer__col a {
180
+ font-size: 13px;
181
+ text-decoration: none;
182
+ }
183
+ .site-footer__col a:hover,
184
+ .site-footer__bottom .right a:hover,
185
+ .site-footer__passkeys:hover,
186
+ .site-footer__info:hover,
187
+ .site-footer__link-with-icon:hover .site-footer__link-icon {
188
+ color: var(--text);
189
+ }
190
+ .site-footer__link-with-icon,
191
+ .site-footer__passkeys-row {
192
+ display: inline-flex;
193
+ align-items: center;
194
+ }
195
+ .site-footer__link-with-icon {
196
+ gap: 8px;
197
+ }
198
+ .site-footer__link-icon {
199
+ display: inline-flex;
200
+ width: 14px;
201
+ height: 14px;
202
+ align-items: center;
203
+ justify-content: center;
204
+ }
205
+ .site-footer__passkeys {
206
+ display: inline-flex;
207
+ align-items: center;
208
+ gap: 8px;
209
+ background: transparent;
210
+ border: 0;
211
+ padding: 0;
212
+ font: inherit;
213
+ font-size: 13px;
214
+ cursor: pointer;
215
+ transition: color 180ms ease;
216
+ }
217
+ .site-footer__passkeys-dot {
218
+ width: 7px;
219
+ height: 7px;
220
+ border-radius: 50%;
221
+ background: #c9433a;
222
+ display: inline-block;
223
+ transition: background 200ms ease;
224
+ }
225
+ .site-footer__passkeys.is-on .site-footer__passkeys-dot {
226
+ background: #2f7a5a;
227
+ }
228
+ .site-footer__passkeys-label {
229
+ position: relative;
230
+ display: inline-block;
231
+ }
232
+ .site-footer__passkeys-label > [data-passkeys-label] {
233
+ position: absolute;
234
+ left: 0;
235
+ top: 0;
236
+ white-space: nowrap;
237
+ }
238
+ .site-footer__passkeys-ghost {
239
+ visibility: hidden;
240
+ white-space: nowrap;
241
+ }
242
+ .site-footer__passkeys-row {
243
+ gap: 6px;
244
+ }
245
+ .site-footer__info {
246
+ display: inline-flex;
247
+ align-items: center;
248
+ justify-content: center;
249
+ width: 18px;
250
+ height: 18px;
251
+ padding: 0;
252
+ background: transparent;
253
+ border: 0;
254
+ cursor: pointer;
255
+ transition: color 180ms ease;
256
+ }
257
+ .site-footer__info-wrap {
258
+ position: relative;
259
+ display: inline-flex;
260
+ }
261
+ .site-footer__info-popover {
262
+ position: absolute;
263
+ bottom: calc(100% + 8px);
264
+ left: 50%;
265
+ --shift: 0px;
266
+ transform: translateX(calc(-50% + var(--shift))) translateY(4px);
267
+ width: min(240px, calc(100vw - 32px));
268
+ padding: 10px 12px;
269
+ background: var(--text);
270
+ color: var(--bg);
271
+ font-size: 12px;
272
+ line-height: 1.45;
273
+ text-align: left;
274
+ border-radius: 6px;
275
+ opacity: 0;
276
+ pointer-events: none;
277
+ transition: opacity 180ms ease, transform 180ms ease;
278
+ z-index: 20;
279
+ white-space: normal;
280
+ }
281
+ .site-footer__info-popover::after {
282
+ content: "";
283
+ position: absolute;
284
+ top: 100%;
285
+ left: 50%;
286
+ transform: translateX(calc(-50% - var(--shift)));
287
+ border: 5px solid transparent;
288
+ border-top-color: var(--text);
289
+ }
290
+ .site-footer__info[aria-expanded="true"] + .site-footer__info-popover {
291
+ opacity: 1;
292
+ transform: translateX(calc(-50% + var(--shift))) translateY(0);
293
+ }
294
+ .site-footer__bottom {
295
+ border-top: 1px solid var(--footer-border);
296
+ padding-top: 18px;
297
+ display: flex;
298
+ flex-wrap: wrap;
299
+ gap: 16px;
300
+ font-size: 13px;
301
+ }
302
+ .site-footer__bottom .right {
303
+ margin-left: auto;
304
+ display: flex;
305
+ gap: 20px;
306
+ align-items: center;
307
+ }
308
+ .site-footer__bottom .right a {
309
+ text-decoration: none;
310
+ }
311
+ @media (max-width: 960px) {
312
+ .site-footer__cols { gap: 32px; }
313
+ }
314
+ @media (max-width: 720px) {
315
+ .site-footer__cols {
316
+ grid-template-columns: 1fr;
317
+ grid-auto-flow: row;
318
+ gap: 24px;
319
+ }
320
+ .site-footer__col-group { gap: 24px; }
321
+ .site-footer__brand-col,
322
+ .site-footer__col-group:nth-of-type(1),
323
+ .site-footer__col-group:nth-of-type(2),
324
+ .site-footer__col-group:nth-of-type(3) {
325
+ grid-column: 1;
326
+ grid-row: auto;
327
+ justify-self: start;
328
+ }
329
+ }
330
+ </style>
331
+ </head>
332
+ <body>
333
+
334
+ <div class="login-page" id="loginPage">
335
+ <div class="login-card">
336
+ <div style="display:flex;align-items:center;justify-content:center;gap:10px;margin-bottom:8px;margin-left:-6px;"><span id="loginIcon" style="width:34px;height:34px;flex-shrink:0;overflow:hidden;"></span><h1 class="login-title" style="margin-bottom:0;">Kaleidoscope</h1></div>
337
+ <p style="color:#8a8580;font-size:16px;margin:0 0 32px 0;letter-spacing:0.2px;">Every AI. One experience.</p>
338
+
339
+ <!-- Signup view (default) -->
340
+ <div id="signup-view">
341
+ <div class="login-buttons">
342
+ <button class="btn btn-primary" id="createBtn" onclick="doCreateAccount()">Enter the Kaleidoscope</button>
343
+ </div>
344
+ <div style="margin-top:12px;">
345
+ <input type="text" id="handleInput" name="kaleidoscope-handle" placeholder="What should Lēsa call you? (optional)" autocapitalize="none" autocorrect="off" autocomplete="off" spellcheck="false" data-1p-ignore="true" data-lpignore="true" onfocus="setTimeout(function(){document.getElementById('handleInput').scrollIntoView({behavior:'smooth',block:'center'})},300)" style="width:100%;padding:16px 18px;border:1px solid #E0DDD6;border-radius:12px;font-size:18px;font-family:var(--font);background:#F5F3ED;color:#1a1a1a;outline:none;text-align:center;" />
346
+ </div>
347
+ <p style="color:#b0aaa4;font-size:13px;font-style:italic;margin:16px 0 0;text-align:center;opacity:0.8;">Use your phone to securely create your account</p>
348
+ <div style="margin-top:12px;text-align:center;">
349
+ <a id="signInBtn" onclick="doSignIn()" style="color:var(--accent);font-size:16px;cursor:pointer;text-decoration:none;">Already have an account? Sign in.</a>
350
+ </div>
351
+ </div>
352
+
353
+ <!-- QR code view (Chrome desktop fallback) -->
354
+ <div id="qr-view" style="display:none">
355
+ <p style="color:#8a8580;font-size:16px;margin:0 0 24px 0;">Scan with your phone to continue.</p>
356
+ <div style="display:flex;justify-content:center;margin-bottom:24px;">
357
+ <img id="qr-image" style="width:200px;height:200px;border-radius:12px;background:#F5F3ED;" />
358
+ </div>
359
+ <p id="qr-status-text" style="color:var(--accent);font-size:14px;">Waiting for phone...</p>
360
+ <div style="margin-top:20px;">
361
+ <a onclick="cancelQrLogin()" style="color:var(--text-muted);font-size:14px;cursor:pointer;text-decoration:none;">Cancel</a>
362
+ </div>
363
+ </div>
364
+
365
+ <!-- Success view (legacy login flow) -->
366
+ <div id="success-view" style="display:none">
367
+ <p style="font-size:18px;margin-bottom:12px;color:var(--text);">Welcome, <span id="welcome-name"></span>.</p>
368
+ <div class="login-buttons">
369
+ <a href="/demo/" class="btn btn-primary" style="text-decoration:none;text-align:center;">Try the Demo</a>
370
+ </div>
371
+ <p style="color:var(--text-muted);font-size:15px;margin:18px 0 0;">Your passkey has been saved to your phone. You can use it to sign in to any WIP Computer service.</p>
372
+ </div>
373
+
374
+ <!-- Pair-approved view (CRC pair-mode, desktop-side after phone approves) -->
375
+ <div id="pair-approved-view" style="display:none">
376
+ <p style="font-size:18px;margin-bottom:12px;color:var(--text);">Approved on your phone.</p>
377
+ <p style="color:var(--text-muted);font-size:15px;margin-bottom:24px;">Continue pairing on your phone. Your laptop will pick this up after you confirm.</p>
378
+ </div>
379
+
380
+ <!-- QR authenticator confirmation view (phone-side after approving another device) -->
381
+ <div id="qr-auth-confirm-view" style="display:none">
382
+ <p style="font-size:18px;margin:0 0 24px;color:var(--text);">Your authenticated Kaleidoscope session is available on your other device.</p>
383
+ <div class="login-buttons">
384
+ <button class="btn btn-primary" id="qrAuthPrimaryBtn" onclick="backToLoginAfterQrScan()">Back to login</button>
385
+ </div>
386
+ <p id="qr-auth-close-message" style="display:none;color:var(--text-muted);font-size:15px;margin:20px 0 0;">You can close this page.</p>
387
+ </div>
388
+
389
+ <div class="login-status" id="loginStatus" style="position:absolute;left:0;right:0;margin-top:16px;text-align:center;"></div>
390
+ </div>
391
+ </div>
392
+ <footer class="site-footer" data-screen-label="Footer">
393
+ <div class="site-footer__inner">
394
+ <div class="site-footer__cols">
395
+ <div class="site-footer__brand-col">
396
+ <a class="site-footer__brand-name" href="https://wip.computer/">WIP Computer, Inc.</a>
397
+ <div class="site-footer__brand-tag">Learning Dreaming Machines</div>
398
+ <div class="site-footer__brand-tag">Made in California.</div>
399
+ </div>
400
+ <div class="site-footer__col-group">
401
+ <div class="site-footer__col">
402
+ <div class="site-footer__col-title">AI Infrastructure</div>
403
+ <ul>
404
+ <li><a href="https://github.com/wipcomputer/wip-ldm-os" target="_blank" rel="noopener">LDM OS</a></li>
405
+ <li><a href="https://github.com/wipcomputer/memory-crystal" target="_blank" rel="noopener">Memory Crystal</a></li>
406
+ <li><a href="https://github.com/wipcomputer/dream-weaver-protocol" target="_blank" rel="noopener">Dream Weaver</a></li>
407
+ <li><a href="https://github.com/wipcomputer/wip-ldm-os/blob/main/docs/bridge/README.md" target="_blank" rel="noopener">Bridge</a></li>
408
+ </ul>
409
+ </div>
410
+ </div>
411
+ <div class="site-footer__col-group">
412
+ <div class="site-footer__col">
413
+ <div class="site-footer__col-title">AI Skills</div>
414
+ <ul>
415
+ <li><a href="https://github.com/wipcomputer/wip-ldm-os/blob/main/docs/universal-installer/TECHNICAL.md" target="_blank" rel="noopener">Universal Installer</a></li>
416
+ <li><a href="https://github.com/wipcomputer/wip-1password" target="_blank" rel="noopener">1Password</a></li>
417
+ <li><a href="https://github.com/wipcomputer/wip-ai-devops-toolbox" target="_blank" rel="noopener">AI DevOps Toolbox</a></li>
418
+ <li><a href="https://github.com/wipcomputer/wip-x-xai-grok" target="_blank" rel="noopener">X + xAI</a></li>
419
+ </ul>
420
+ </div>
421
+ <div class="site-footer__col">
422
+ <div class="site-footer__col-title">Applications</div>
423
+ <ul>
424
+ <li><a href="https://wip.computer/login?next=/demo" target="_blank" rel="noopener">Kaleidoscope</a></li>
425
+ <li><a href="https://github.com/wipcomputer/wip-codex-remote-control" target="_blank" rel="noopener">Remote Control</a></li>
426
+ <li><a href="https://github.com/wipcomputer/CLVR" target="_blank" rel="noopener">CLVR</a></li>
427
+ <li><a href="https://github.com/wipcomputer/wip-markdown-viewer" target="_blank" rel="noopener">Markdown Viewer</a></li>
428
+ </ul>
429
+ </div>
430
+ </div>
431
+ <div class="site-footer__col-group">
432
+ <div class="site-footer__col">
433
+ <div class="site-footer__col-title">Tools</div>
434
+ <ul>
435
+ <li>
436
+ <a href="https://wip.computer/agent.txt" target="_blank" rel="noopener" data-agent>Are you an AI agent?</a>
437
+ </li>
438
+ <li>
439
+ <a href="https://wip.computer/visualizations/kaleidoscope/onboarding/live/">Visualizations</a>
440
+ </li>
441
+ <li class="site-footer__passkeys-row">
442
+ <button type="button" class="site-footer__passkeys" aria-pressed="false" aria-label="Local passkeys off" data-passkeys>
443
+ <span class="site-footer__passkeys-dot"></span>
444
+ <span class="site-footer__passkeys-label">
445
+ <span data-passkeys-label>Local passkeys off</span>
446
+ <span class="site-footer__passkeys-ghost" aria-hidden="true">Local passkeys off</span>
447
+ </span>
448
+ </button>
449
+ <span class="site-footer__info-wrap">
450
+ <button type="button" class="site-footer__info" aria-label="About local passkeys" data-passkeys-info aria-describedby="passkeys-info-tip">
451
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
452
+ <circle cx="12" cy="12" r="10"></circle>
453
+ <line x1="12" y1="16" x2="12" y2="12"></line>
454
+ <line x1="12" y1="8" x2="12.01" y2="8"></line>
455
+ </svg>
456
+ </button>
457
+ <span class="site-footer__info-popover" id="passkeys-info-tip" role="tooltip">
458
+ Local passkeys are off by default on desktop. Your mobile device's passkeys are used for login and device sync. Turn this on to use or save passkeys on this computer.
459
+ </span>
460
+ </span>
461
+ </li>
462
+ </ul>
463
+ </div>
464
+ <div class="site-footer__col">
465
+ <div class="site-footer__col-title">Connect</div>
466
+ <ul>
467
+ <li>
468
+ <a href="https://github.com/wipcomputer" target="_blank" rel="noopener" class="site-footer__link-with-icon">
469
+ <span class="site-footer__link-icon" aria-hidden="true">
470
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
471
+ <path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.4 3-.405 1.02.005 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"></path>
472
+ </svg>
473
+ </span>
474
+ <span>@wipcomputer</span>
475
+ </a>
476
+ </li>
477
+ <li>
478
+ <a href="https://x.com/wipcomputer" target="_blank" rel="noopener" class="site-footer__link-with-icon">
479
+ <span class="site-footer__link-icon" aria-hidden="true">
480
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
481
+ <path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24h-6.66l-5.214-6.817-5.97 6.817H1.673l7.73-8.835L1.254 2.25h6.83l4.713 6.231 5.447-6.231zM17.083 19.77h1.833L7.084 4.126H5.117l11.966 15.644z"></path>
482
+ </svg>
483
+ </span>
484
+ <span>@wipcomputer</span>
485
+ </a>
486
+ </li>
487
+ </ul>
488
+ </div>
489
+ </div>
490
+ </div>
491
+ <div class="site-footer__bottom">
492
+ <div>Copyright © 2026 WIP Computer, Inc. All rights reserved.</div>
493
+ <div class="right">
494
+ <a href="https://wip.computer/legal/privacy/" target="_blank" rel="noopener">Privacy Policy</a>
495
+ <a href="https://wip.computer/legal/internet-services/terms/site.html" target="_blank" rel="noopener">Terms of Use</a>
496
+ </div>
497
+ </div>
498
+ </div>
499
+ </footer>
500
+
501
+ <script>
502
+ // ── Random kaleidoscope icon from sprite sheet ──
503
+ var SPRITE_COLS = 8;
504
+ var SPRITE_ROWS = 3;
505
+ var SPRITE_TOTAL = SPRITE_COLS * SPRITE_ROWS;
506
+
507
+ function makeIconHTML(size) {
508
+ var idx = Math.floor(Math.random() * SPRITE_TOTAL);
509
+ var col = idx % SPRITE_COLS;
510
+ var row = Math.floor(idx / SPRITE_COLS);
511
+ var bgPosX = (col / (SPRITE_COLS - 1)) * 100;
512
+ var bgPosY = (row / (SPRITE_ROWS - 1)) * 100;
513
+ return '<div style="width:' + size + 'px;height:' + size + 'px;overflow:hidden;"><div style="width:100%;height:100%;background:url(/app/sprites.png);background-size:' + (SPRITE_COLS * 100) + '% ' + (SPRITE_ROWS * 100) + '%;background-position:' + bgPosX + '% ' + bgPosY + '%;"></div></div>';
514
+ }
515
+
516
+ var loginIcon = document.getElementById('loginIcon');
517
+ if (loginIcon) loginIcon.innerHTML = makeIconHTML(34);
518
+
519
+ // Rotate icon every 3s
520
+ var loginRotateIdx = Math.floor(Math.random() * SPRITE_TOTAL);
521
+ setInterval(function() {
522
+ loginRotateIdx = (loginRotateIdx + 1) % SPRITE_TOTAL;
523
+ var col = loginRotateIdx % SPRITE_COLS;
524
+ var row = Math.floor(loginRotateIdx / SPRITE_COLS);
525
+ var bgPosX = (col / (SPRITE_COLS - 1)) * 100;
526
+ var bgPosY = (row / (SPRITE_ROWS - 1)) * 100;
527
+ if (loginIcon) loginIcon.innerHTML = '<div style="width:34px;height:34px;overflow:hidden;"><div style="width:100%;height:100%;background:url(/app/sprites.png);background-size:' + (SPRITE_COLS * 100) + '% ' + (SPRITE_ROWS * 100) + '%;background-position:' + bgPosX + '% ' + bgPosY + '%;"></div></div>';
528
+ }, 3000);
529
+
530
+ // ── WebAuthn helpers ──
531
+ function b64urlToBytes(b64) {
532
+ var bin = atob(b64.replace(/-/g, '+').replace(/_/g, '/') + '=='.slice(0, (4 - b64.length % 4) % 4));
533
+ return Uint8Array.from(bin, function(c) { return c.charCodeAt(0); });
534
+ }
535
+ function bytesToB64url(buf) {
536
+ return btoa(String.fromCharCode.apply(null, new Uint8Array(buf)))
537
+ .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
538
+ }
539
+
540
+ function setStatus(msg, type) {
541
+ var el = document.getElementById('loginStatus');
542
+ el.style.transition = '';
543
+ el.style.opacity = '';
544
+ el.textContent = msg;
545
+ el.className = msg ? 'login-status show ' + type : 'login-status';
546
+ }
547
+
548
+ function clearStatus() {
549
+ var el = document.getElementById('loginStatus');
550
+ el.style.transition = 'opacity 0.5s';
551
+ el.style.opacity = '0';
552
+ setTimeout(function() { el.className = 'login-status'; el.style.transition = ''; el.style.opacity = ''; }, 500);
553
+ }
554
+
555
+ // ── Browser detection ──
556
+ function isMobileDevice() {
557
+ return navigator.maxTouchPoints > 0 && window.innerWidth < 768;
558
+ }
559
+
560
+ function isSafariDesktop() {
561
+ if (isMobileDevice()) return false;
562
+ return /Safari\//.test(navigator.userAgent) && !/Chrome\//.test(navigator.userAgent);
563
+ }
564
+
565
+ function needsCustomQR() {
566
+ return !isLocalPasskeysOn();
567
+ }
568
+
569
+ // ── Local passkeys toggle (persisted in localStorage) ──
570
+ function defaultLocalPasskeysOn() {
571
+ return isMobileDevice();
572
+ }
573
+
574
+ function getLocalPasskeysPreference() {
575
+ var stored = localStorage.getItem('localPasskeys');
576
+ if (stored === 'on' || stored === 'off') return stored;
577
+ return defaultLocalPasskeysOn() ? 'on' : 'off';
578
+ }
579
+
580
+ function isLocalPasskeysOn() {
581
+ return getLocalPasskeysPreference() === 'on';
582
+ }
583
+
584
+ function localPasskeysInfoCopy() {
585
+ if (defaultLocalPasskeysOn()) {
586
+ return "Local passkeys are on by default on mobile devices. This device's passkeys are used for login and device sync. Turn this off to use or save passkeys on a different device.";
587
+ }
588
+ return "Local passkeys are off by default on desktop. Your mobile device's passkeys are used for login and device sync. Turn this on to use or save passkeys on this computer.";
589
+ }
590
+
591
+ function toggleLocalPasskeys() {
592
+ var on = isLocalPasskeysOn();
593
+ localStorage.setItem('localPasskeys', on ? 'off' : 'on');
594
+ updatePasskeysDot();
595
+ }
596
+
597
+ function updatePasskeysDot() {
598
+ var button = document.querySelector('[data-passkeys]');
599
+ var label = document.querySelector('[data-passkeys-label]');
600
+ if (!button || !label) return;
601
+ var on = isLocalPasskeysOn();
602
+ button.classList.toggle('is-on', on);
603
+ button.setAttribute('aria-pressed', on ? 'true' : 'false');
604
+ button.setAttribute('aria-label', 'Local passkeys ' + (on ? 'on' : 'off'));
605
+ label.textContent = 'Local passkeys ' + (on ? 'on' : 'off');
606
+ }
607
+
608
+ function initFooterPasskeys() {
609
+ var button = document.querySelector('[data-passkeys]');
610
+ if (!button) return;
611
+ button.addEventListener('click', toggleLocalPasskeys);
612
+ updatePasskeysDot();
613
+ }
614
+
615
+ function initFooterPasskeysInfo() {
616
+ var button = document.querySelector('[data-passkeys-info]');
617
+ if (!button) return;
618
+ var popover = document.getElementById(button.getAttribute('aria-describedby'));
619
+ var startScrollY = 0;
620
+ if (popover) popover.textContent = localPasskeysInfoCopy();
621
+
622
+ function setOpen(open) {
623
+ button.setAttribute('aria-expanded', open ? 'true' : 'false');
624
+ if (open) {
625
+ startScrollY = window.scrollY;
626
+ positionPopover();
627
+ }
628
+ }
629
+
630
+ function positionPopover() {
631
+ if (!popover || button.getAttribute('aria-expanded') !== 'true') return;
632
+ popover.style.setProperty('--shift', '0px');
633
+ var rect = popover.getBoundingClientRect();
634
+ var padding = 16;
635
+ var shift = 0;
636
+ if (rect.left < padding) shift = padding - rect.left;
637
+ if (rect.right > window.innerWidth - padding) shift = window.innerWidth - padding - rect.right;
638
+ popover.style.setProperty('--shift', shift + 'px');
639
+ }
640
+
641
+ button.addEventListener('click', function(event) {
642
+ event.stopPropagation();
643
+ setOpen(button.getAttribute('aria-expanded') !== 'true');
644
+ });
645
+ document.addEventListener('click', function(event) {
646
+ if (!button.contains(event.target) && (!popover || !popover.contains(event.target))) {
647
+ setOpen(false);
648
+ }
649
+ });
650
+ document.addEventListener('keydown', function(event) {
651
+ if (event.key === 'Escape') setOpen(false);
652
+ });
653
+ window.addEventListener('scroll', function() {
654
+ if (button.getAttribute('aria-expanded') === 'true' && Math.abs(window.scrollY - startScrollY) > 24) {
655
+ setOpen(false);
656
+ }
657
+ }, { passive: true });
658
+ window.addEventListener('resize', positionPopover);
659
+ }
660
+
661
+ // ── `next` continuation carrier ──
662
+ //
663
+ // Three whitelisted next shapes:
664
+ //
665
+ // PAIR_NEXT_REGEX /pair/<CODE> using the daemon alphabet
666
+ // (CODEX_PAIR_ALPHABET, length 6, L is
667
+ // included; I/O/0/1 excluded). C8 applies:
668
+ // URL fallback is mobile-only, the desktop
669
+ // must not become the pairing authority.
670
+ //
671
+ // REMOTE_CONTROL_NEXT_REGEX /codex-remote-control/<UUID>. Standard
672
+ // ?next semantics; allowed on both
673
+ // desktop and mobile (this is navigation
674
+ // continuation, not authority transfer).
675
+ //
676
+ // DEMO_NEXT_REGEX /demo. Standard post-login continuation
677
+ // from the homepage CTA.
678
+ //
679
+ // Anything else is silently dropped. `next` is NOT a general redirect
680
+ // primitive. The server validates authoritatively; this client-side
681
+ // check is defense-in-depth.
682
+ var PAIR_NEXT_REGEX = /^\/pair\/[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/;
683
+ var REMOTE_CONTROL_NEXT_REGEX = /^\/codex-remote-control\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
684
+ var DEMO_NEXT_REGEX = /^\/demo$/;
685
+
686
+ function isWhitelistedNext(raw) {
687
+ return typeof raw === 'string' && (PAIR_NEXT_REGEX.test(raw) || REMOTE_CONTROL_NEXT_REGEX.test(raw) || DEMO_NEXT_REGEX.test(raw));
688
+ }
689
+
690
+ function readPairNextFromQuery() {
691
+ try {
692
+ var raw = new URLSearchParams(window.location.search).get('next');
693
+ if (!raw) return null;
694
+ return isWhitelistedNext(raw) ? raw : null;
695
+ } catch (e) { return null; }
696
+ }
697
+
698
+ function isPairNextOnDesktop() {
699
+ var next = readPairNextFromQuery();
700
+ return !!(next && PAIR_NEXT_REGEX.test(next) && !isMobileDevice());
701
+ }
702
+
703
+ // Direct Remote Control login continuation. Explicit branch ... NOT
704
+ // routed through pair-mode helpers. Called from doSignIn / doCreateAccount
705
+ // the moment /webauthn/auth-verify or /webauthn/register-verify returns
706
+ // success, BEFORE any qr-login/approve or pair-mode logic runs.
707
+ //
708
+ // If the URL was /login?next=/codex-remote-control/<UUID> AND auth-verify
709
+ // returned a real apiKey, store the credentials in sessionStorage so the
710
+ // destination page's auth gate sees them, then location.replace into the
711
+ // Remote Control surface. Returns true if it redirected; caller must
712
+ // return early.
713
+ //
714
+ // If the URL has no remote-control next, returns false (caller proceeds
715
+ // with the existing QR / pair / welcome logic).
716
+ //
717
+ // If the URL has a remote-control next BUT result.apiKey is missing,
718
+ // console.warn and return false. The caller then falls through to the
719
+ // welcome view (defense in depth from PR #805) so the user is not
720
+ // stranded on a blank /codex-remote-control page that bounces back to
721
+ // sign-in.
722
+ //
723
+ // This is deliberately separate from followPairNextIfPresent because
724
+ // pair-mode behavior (C6 desktop strip, C8 desktop-no-redirect) is
725
+ // orthogonal to standard Remote Control login continuation.
726
+ //
727
+ // Also writes a short-lived same-origin handoff cookie as a Safari-safe
728
+ // fallback: some browsers drop sessionStorage across location.replace
729
+ // under privacy/partition heuristics. Path=/ is required because Safari
730
+ // will silently reject document.cookie writes whose Path attribute is
731
+ // not the current path or an ancestor of it; /login is not an ancestor
732
+ // of /codex-remote-control, so the previous Path=/codex-remote-control
733
+ // scope was dropped on Safari. The Remote Control page reads either
734
+ // source and clears both Path scopes after consumption. See
735
+ // setHandoffCookie below.
736
+ function setHandoffCookie(name, value) {
737
+ document.cookie = name + '=' + encodeURIComponent(value)
738
+ + '; Path=/'
739
+ + '; Max-Age=60'
740
+ + '; Secure'
741
+ + '; SameSite=Strict';
742
+ }
743
+ function redirectToRemoteControlIfDirectLogin(result) {
744
+ try {
745
+ var raw = new URLSearchParams(window.location.search).get('next');
746
+ if (!raw || !REMOTE_CONTROL_NEXT_REGEX.test(raw)) return false;
747
+ if (!result || !result.apiKey) {
748
+ console.warn('redirectToRemoteControlIfDirectLogin: missing apiKey on result; falling through to welcome view. result=', result);
749
+ return false;
750
+ }
751
+ console.log('redirectToRemoteControlIfDirectLogin: storing wip_api_key + redirecting', { agentId: result.agentId, next: raw });
752
+ sessionStorage.setItem('wip_api_key', result.apiKey);
753
+ if (result.agentId) sessionStorage.setItem('wip_handle', result.agentId);
754
+ setHandoffCookie('wip_rc_api_key', result.apiKey);
755
+ if (result.agentId) setHandoffCookie('wip_rc_handle', result.agentId);
756
+ location.replace(raw);
757
+ return true;
758
+ } catch (e) {
759
+ console.warn('redirectToRemoteControlIfDirectLogin: unexpected error', e);
760
+ return false;
761
+ }
762
+ }
763
+
764
+ // Phone-side post-passkey hook. Called after successful passkey + (optional)
765
+ // qr-login/approve. Decides whether to redirect to the next URL based on
766
+ // authority rules from plan C6 + C8:
767
+ //
768
+ // - QR-scan path (approveResponse.next from the server): follow it for
769
+ // a whitelisted next. The phone scanned the desktop's QR,
770
+ // signed in, and the server returned a sanitized `next`.
771
+ //
772
+ // - URL fallback path (no approveResponse.next):
773
+ // - /codex-remote-control/<UUID>: allowed on both desktop and
774
+ // mobile. Standard navigation continuation.
775
+ // - /pair/<CODE>: mobile-only (C8). Desktop with no
776
+ // approveResponse must not become the pairing authority.
777
+ //
778
+ // REGRESSION COVERED: a desktop browser with "Local passkeys" on,
779
+ // loading /login?next=/pair/<CODE> and completing desktop WebAuthn,
780
+ // MUST NOT redirect to /pair/<CODE> or complete pairing. If a future
781
+ // change opens this gate, it violates plan constraint C8 ("Default
782
+ // authority is phone-held passkey; local desktop passkeys are
783
+ // testing-only"). The codex-remote-control next does NOT trip this
784
+ // rule because it's a navigation continuation, not authority transfer.
785
+ //
786
+ // Returns true if it redirected (caller should not run other view-switching).
787
+ async function followPairNextIfPresent(approveResponse, identity, isNewAccount) {
788
+ var next = null;
789
+ // Path 1: QR phone-side approve. Server-blessed next, either shape.
790
+ if (approveResponse && typeof approveResponse.next === 'string' && isWhitelistedNext(approveResponse.next)) {
791
+ if (DEMO_NEXT_REGEX.test(approveResponse.next)) {
792
+ return showQrAuthenticatorConfirmation(identity, isNewAccount === true);
793
+ }
794
+ next = approveResponse.next;
795
+ } else {
796
+ // Path 2: URL ?next= fallback (no approveResponse). Branch by
797
+ // shape: codex-remote-control allowed on any device, pair-mode
798
+ // restricted to mobile per C8.
799
+ var urlNext = readPairNextFromQuery();
800
+ if (urlNext) {
801
+ if (REMOTE_CONTROL_NEXT_REGEX.test(urlNext) || DEMO_NEXT_REGEX.test(urlNext)) {
802
+ next = urlNext;
803
+ } else if (PAIR_NEXT_REGEX.test(urlNext) && isMobileDevice()) {
804
+ next = urlNext;
805
+ }
806
+ // Path 3 (desktop + pair next, no approveResponse): next stays null.
807
+ }
808
+ }
809
+ if (!next) return false;
810
+ // Auth-handoff guard: if we don't have an apiKey to store, do NOT
811
+ // redirect to next. Stranding the user on /codex-remote-control/<UUID>
812
+ // without a key in sessionStorage would render a redirect-to-login
813
+ // loop or a blank page (depending on the destination's auth gate).
814
+ // Falling through here lets the caller render the existing
815
+ // welcome-name view, which at least shows the user something they
816
+ // can interact with.
817
+ if (!identity || !identity.apiKey) {
818
+ console.warn('followPairNextIfPresent: missing apiKey on identity; skipping redirect to', next);
819
+ return false;
820
+ }
821
+ sessionStorage.setItem('wip_api_key', identity.apiKey);
822
+ if (identity.agentId) sessionStorage.setItem('wip_handle', identity.agentId);
823
+ if (PAIR_NEXT_REGEX.test(next) && identity.codex_pair_presence_token) {
824
+ sessionStorage.setItem('wip_codex_pair_presence_token', identity.codex_pair_presence_token);
825
+ }
826
+ storeDemoHandoffIfNeeded(next, identity, isNewAccount === true);
827
+ location.replace(next);
828
+ return true;
829
+ }
830
+
831
+ // ── QR Login (Chrome desktop fallback) ──
832
+ var qrLoginPollTimer = null;
833
+ var qrLoginMode = null;
834
+
835
+ function storeDemoHandoffIfNeeded(next, identity, isNewAccount) {
836
+ if (!DEMO_NEXT_REGEX.test(next) || !identity || !identity.apiKey) return;
837
+ sessionStorage.setItem('lesa-token', identity.apiKey);
838
+ if (identity.agentId) sessionStorage.setItem('lesa-agent', identity.agentId);
839
+ if (identity.tenantId) sessionStorage.setItem('lesa-tenant', identity.tenantId);
840
+ if (isNewAccount) sessionStorage.setItem('lesa-new-account', 'true');
841
+ }
842
+
843
+ function showQrAuthenticatorConfirmation(identity, isNewAccount) {
844
+ ['signup-view', 'qr-view', 'success-view', 'pair-approved-view'].forEach(function(id) {
845
+ var el = document.getElementById(id);
846
+ if (el) el.style.display = 'none';
847
+ });
848
+ var primary = document.getElementById('qrAuthPrimaryBtn');
849
+ if (primary) primary.textContent = 'Back to login';
850
+ var closeMessage = document.getElementById('qr-auth-close-message');
851
+ if (closeMessage) closeMessage.style.display = 'none';
852
+ document.getElementById('qr-auth-confirm-view').style.display = 'block';
853
+ setStatus('', '');
854
+ return true;
855
+ }
856
+
857
+ function backToLoginAfterQrScan() {
858
+ qrSessionId = null;
859
+ qrHandle = null;
860
+ qrMode = 'register';
861
+ qrSessionMode = false;
862
+ location.replace('/login?next=/demo');
863
+ }
864
+
865
+ async function startQrLogin(handle, mode) {
866
+ var btn = document.getElementById('createBtn');
867
+ btn.disabled = true;
868
+ setStatus('', '');
869
+ qrLoginMode = mode || 'register';
870
+
871
+ try {
872
+ var pairNext = readPairNextFromQuery();
873
+ var startBody = { handle: handle || undefined, mode: mode || 'register' };
874
+ if (pairNext) startBody.next = pairNext;
875
+ var res = await fetch('/api/qr-login', {
876
+ method: 'POST',
877
+ headers: { 'Content-Type': 'application/json' },
878
+ body: JSON.stringify(startBody),
879
+ });
880
+ var data = await res.json();
881
+
882
+ // Switch to QR view
883
+ document.getElementById('signup-view').style.display = 'none';
884
+ document.getElementById('qr-view').style.display = 'block';
885
+ document.getElementById('qr-image').src = data.qrUrl;
886
+
887
+ // Start polling
888
+ qrLoginPollTimer = setInterval(function() {
889
+ pollQrLogin(data.sessionId);
890
+ }, 2000);
891
+
892
+ // Expire after 5 minutes
893
+ setTimeout(function() {
894
+ if (qrLoginPollTimer) {
895
+ cancelQrLogin();
896
+ setStatus('Session expired. Try again.', 'error');
897
+ setTimeout(clearStatus, 3000);
898
+ }
899
+ }, 5 * 60 * 1000);
900
+
901
+ } catch (err) {
902
+ setStatus('Error: ' + err.message, 'error');
903
+ btn.disabled = false;
904
+ }
905
+ }
906
+
907
+ async function pollQrLogin(sessionId) {
908
+ try {
909
+ var res = await fetch('/api/qr-login/status?s=' + sessionId);
910
+ if (res.status === 404) {
911
+ // Session expired on server
912
+ cancelQrLogin();
913
+ setStatus('Session expired. Try again.', 'error');
914
+ setTimeout(clearStatus, 3000);
915
+ return;
916
+ }
917
+ var data = await res.json();
918
+ if (data.status === 'approved') {
919
+ clearInterval(qrLoginPollTimer);
920
+ qrLoginPollTimer = null;
921
+ document.getElementById('qr-view').style.display = 'none';
922
+ // Branch by next shape. Three cases:
923
+ //
924
+ // 1. Pair-mode (URL ?next=/pair/<CODE>): server stripped apiKey
925
+ // and next from this response (plan C6). Desktop never
926
+ // redirects (C8). Show the dedicated pair-approved view.
927
+ // The phone is the actor that completes pair-complete.
928
+ //
929
+ // 2. Codex remote control continuation (data.next is a
930
+ // /codex-remote-control/<UUID>): standard post-login next.
931
+ // Desktop authenticates with the server-provided apiKey and
932
+ // redirects to next. Server returned the full login response
933
+ // (apiKey, credentialLabel, next) for this case.
934
+ //
935
+ // 3. Legacy login mode (no whitelisted next anywhere): show the
936
+ // existing welcome view.
937
+ var urlNext = readPairNextFromQuery();
938
+ if (urlNext && PAIR_NEXT_REGEX.test(urlNext)) {
939
+ document.getElementById('pair-approved-view').style.display = 'block';
940
+ } else if (data.next && (REMOTE_CONTROL_NEXT_REGEX.test(data.next) || DEMO_NEXT_REGEX.test(data.next)) && data.apiKey) {
941
+ // Standard post-login continuation: pick up the credentials
942
+ // so the destination page is authenticated, then redirect.
943
+ sessionStorage.setItem('wip_api_key', data.apiKey);
944
+ if (data.agentId) sessionStorage.setItem('wip_handle', data.agentId);
945
+ storeDemoHandoffIfNeeded(data.next, data, qrLoginMode === 'register');
946
+ location.replace(data.next);
947
+ } else {
948
+ storeDemoHandoffIfNeeded('/demo', data, qrLoginMode === 'register');
949
+ document.getElementById('success-view').style.display = 'block';
950
+ // Use credentialLabel (matches the saved-passkey label the user
951
+ // sees in iOS Passwords / 1Password). Falls back to agentId for
952
+ // back-compat with older deploys.
953
+ document.getElementById('welcome-name').textContent = data.credentialLabel || data.agentId || 'you';
954
+ }
955
+ }
956
+ } catch (err) {
957
+ // Network error, keep polling
958
+ }
959
+ }
960
+
961
+ function cancelQrLogin() {
962
+ if (qrLoginPollTimer) {
963
+ clearInterval(qrLoginPollTimer);
964
+ qrLoginPollTimer = null;
965
+ }
966
+ qrLoginMode = null;
967
+ document.getElementById('qr-view').style.display = 'none';
968
+ document.getElementById('signup-view').style.display = 'block';
969
+ document.getElementById('createBtn').disabled = false;
970
+ document.getElementById('signInBtn').style.pointerEvents = 'auto';
971
+ }
972
+
973
+ // ── Phone-side QR session detection ──
974
+ var urlParams = new URLSearchParams(window.location.search);
975
+ var qrSessionId = urlParams.get('s');
976
+ var qrHandle = urlParams.get('h');
977
+ var qrMode = urlParams.get('m') || 'register';
978
+ var qrSessionMode = !!qrSessionId;
979
+
980
+ // True only while the auto-start setTimeout is invoking
981
+ // doCreateAccount / doSignIn. Read by the NotAllowedError catches so
982
+ // they can suppress the user-facing "Cancelled. Try again when ready."
983
+ // message when the rejection was caused by a strict user-activation
984
+ // policy (Safari Private Browsing) rather than an actual user
985
+ // cancellation. The user's eventual tap on the existing button or
986
+ // link runs the same handler with a real user gesture, and the same
987
+ // qrSessionId completes /api/qr-login/approve.
988
+ var qrAutoStarting = false;
989
+
990
+ if (qrSessionMode) {
991
+ // Clean the URL so session ID doesn't persist
992
+ history.replaceState(null, '', '/login');
993
+ // Pre-fill handle if provided
994
+ if (qrHandle) document.getElementById('handleInput').value = qrHandle;
995
+ // Auto-start the right flow on phone (no extra tap needed).
996
+ // Restored from the pre-#784 known-good QR phone UX. Safari (normal
997
+ // mode) users never see the static login page (Face ID pops within
998
+ // 300ms). Browsers with stricter user-activation policies (Safari
999
+ // Private Browsing, sometimes Chrome on iOS) may reject the
1000
+ // auto-start with NotAllowedError. The catch path below replaces
1001
+ // the generic "Cancelled" status with a clear, QR-specific
1002
+ // instructional message ("This browser blocked automatic Face ID.
1003
+ // Tap ... to continue.") and leaves the existing controls enabled
1004
+ // so the user's tap completes the flow with a real gesture.
1005
+ setTimeout(async function() {
1006
+ qrAutoStarting = true;
1007
+ try {
1008
+ if (qrMode === 'signin') {
1009
+ await doSignIn();
1010
+ } else {
1011
+ await doCreateAccount();
1012
+ }
1013
+ } finally {
1014
+ qrAutoStarting = false;
1015
+ }
1016
+ }, 300);
1017
+ } else if (isPairNextOnDesktop()) {
1018
+ // `codex-daemon link` opens /login?next=/pair/<CODE> on the desktop.
1019
+ // The desktop's only authority in that flow is to display QR and wait.
1020
+ setTimeout(function() {
1021
+ startQrLogin('', 'signin');
1022
+ }, 0);
1023
+ }
1024
+
1025
+ // ── Create Account ──
1026
+ async function doCreateAccount() {
1027
+ var username = (document.getElementById('handleInput').value || '').trim().replace(/^@/, '').toLowerCase().replace(/[^a-z0-9\-]/g, '').slice(0, 30);
1028
+ var btn = document.getElementById('createBtn');
1029
+ btn.disabled = true;
1030
+
1031
+ // Pair-mode desktop URLs must always use the QR path. A local
1032
+ // desktop passkey is testing-only and must not become pair authority.
1033
+ if (isPairNextOnDesktop() && !qrSessionMode) {
1034
+ startQrLogin(username, 'signin');
1035
+ return;
1036
+ }
1037
+
1038
+ // Local passkeys off means use WIP's QR cross-device login on any device.
1039
+ if (needsCustomQR() && !qrSessionMode) {
1040
+ startQrLogin(username);
1041
+ return;
1042
+ }
1043
+
1044
+ setStatus('Preparing...', 'loading');
1045
+
1046
+ try {
1047
+ var optRes = await fetch('/webauthn/register-options', {
1048
+ method: 'POST',
1049
+ headers: { 'Content-Type': 'application/json' },
1050
+ body: JSON.stringify({ username: username || undefined }),
1051
+ });
1052
+ var optData = await optRes.json();
1053
+ var challengeId = optData.challengeId;
1054
+ var options = optData.options;
1055
+ if (!options) throw new Error('Server returned no options');
1056
+
1057
+ options.challenge = b64urlToBytes(options.challenge);
1058
+ options.user.id = b64urlToBytes(options.user.id);
1059
+ if (options.excludeCredentials) {
1060
+ options.excludeCredentials = options.excludeCredentials.map(function(c) { return Object.assign({}, c, { id: b64urlToBytes(c.id) }); });
1061
+ }
1062
+
1063
+ // Phone-first by default. If local passkeys toggled on, show all options.
1064
+ // On mobile (including QR session mode), always use platform (Face ID directly).
1065
+ var mobile = isMobileDevice();
1066
+ var localOn = isLocalPasskeysOn();
1067
+ if (!options.authenticatorSelection) options.authenticatorSelection = {};
1068
+ if (mobile) {
1069
+ options.authenticatorSelection.authenticatorAttachment = 'platform';
1070
+ } else if (!localOn) {
1071
+ options.authenticatorSelection.authenticatorAttachment = 'cross-platform';
1072
+ }
1073
+ options.authenticatorSelection.residentKey = 'preferred';
1074
+ options.authenticatorSelection.userVerification = 'required';
1075
+
1076
+ setStatus(mobile ? 'Waiting for biometric...' : localOn ? 'Waiting for biometric...' : 'Scan the QR code with your phone...', 'loading');
1077
+ var credential = await navigator.credentials.create({ publicKey: options });
1078
+
1079
+ var reqBody = {
1080
+ challengeId: challengeId,
1081
+ credential: {
1082
+ id: credential.id,
1083
+ rawId: bytesToB64url(credential.rawId),
1084
+ type: credential.type,
1085
+ response: {
1086
+ attestationObject: bytesToB64url(credential.response.attestationObject),
1087
+ clientDataJSON: bytesToB64url(credential.response.clientDataJSON),
1088
+ transports: credential.response.getTransports ? credential.response.getTransports() : [],
1089
+ },
1090
+ },
1091
+ };
1092
+
1093
+ setStatus('Verifying...', 'loading');
1094
+ var verRes = await fetch('/webauthn/register-verify', {
1095
+ method: 'POST',
1096
+ headers: { 'Content-Type': 'application/json' },
1097
+ body: JSON.stringify(reqBody),
1098
+ });
1099
+ var result = await verRes.json();
1100
+
1101
+ if (result.success) {
1102
+ // Direct Remote Control login continuation. Runs before any
1103
+ // qr-login/approve or pair-mode logic so the apiKey lands in
1104
+ // sessionStorage and the destination's auth gate finds it.
1105
+ if (redirectToRemoteControlIfDirectLogin(result)) return;
1106
+ // If phone completing a desktop QR session, approve it
1107
+ var approveResponse = null;
1108
+ if (qrSessionMode && qrSessionId) {
1109
+ var approveRes = await fetch('/api/qr-login/approve', {
1110
+ method: 'POST',
1111
+ headers: { 'Content-Type': 'application/json' },
1112
+ body: JSON.stringify({
1113
+ sessionId: qrSessionId,
1114
+ agentId: result.agentId,
1115
+ apiKey: result.apiKey,
1116
+ tenantId: result.tenantId,
1117
+ credentialLabel: result.credentialLabel,
1118
+ }),
1119
+ });
1120
+ try { approveResponse = await approveRes.json(); } catch (e) { approveResponse = null; }
1121
+ }
1122
+ // Mark that user has an account (persists across sessions)
1123
+ localStorage.setItem('kscope-has-account', 'true');
1124
+ // CRC pair-mode: if the server returned a sanitized `next`, store
1125
+ // the api_key on the phone and redirect to /pair/<CODE>. Phone is
1126
+ // the actor that completes pair-complete (plan C6).
1127
+ if (await followPairNextIfPresent(approveResponse, result, true)) return;
1128
+ storeDemoHandoffIfNeeded('/demo', result, true);
1129
+ document.getElementById('signup-view').style.display = 'none';
1130
+ document.getElementById('success-view').style.display = 'block';
1131
+ // Prefer the typed handle for the welcome view. credentialLabel
1132
+ // is the saved-passkey label and can be a generated user id.
1133
+ document.getElementById('welcome-name').textContent = username || result.credentialLabel || result.agentId || 'you';
1134
+ setStatus('', '');
1135
+ } else {
1136
+ setStatus(result.error || 'Registration failed', 'error');
1137
+ btn.disabled = false;
1138
+ }
1139
+ } catch (err) {
1140
+ if (err.name === 'NotAllowedError') {
1141
+ // Distinguish auto-start rejection from a user cancellation.
1142
+ // When the QR auto-start hits a strict user-activation policy
1143
+ // (Safari Private Browsing, future stricter modes), surface a
1144
+ // clear, QR-specific instruction instead of the generic
1145
+ // "Cancelled" copy. The existing primary button stays enabled
1146
+ // (btn.disabled = false below); the user's tap is a real
1147
+ // gesture and reuses the same qrSessionId.
1148
+ if (qrSessionMode && qrAutoStarting) {
1149
+ setStatus('This browser blocked automatic Face ID. Tap Enter the Kaleidoscope to continue.', 'error');
1150
+ } else {
1151
+ setStatus('Cancelled. Try again when ready.', 'error');
1152
+ setTimeout(function() { clearStatus(); }, 3000);
1153
+ }
1154
+ } else {
1155
+ setStatus('Error: ' + err.message, 'error');
1156
+ }
1157
+ btn.disabled = false;
1158
+ }
1159
+ }
1160
+
1161
+ async function doSignIn() {
1162
+ document.getElementById('signInBtn').style.pointerEvents = 'none';
1163
+ document.getElementById('createBtn').disabled = true;
1164
+
1165
+ // Pair-mode desktop URLs must always use the QR path. A local
1166
+ // desktop passkey is testing-only and must not become pair authority.
1167
+ if (isPairNextOnDesktop() && !qrSessionMode) {
1168
+ startQrLogin('', 'signin');
1169
+ return;
1170
+ }
1171
+
1172
+ // Local passkeys off means use WIP's QR cross-device login on any device.
1173
+ // With local passkeys on, let Chrome show native dialog (supports YubiKey).
1174
+ if (needsCustomQR() && !qrSessionMode) {
1175
+ startQrLogin('', 'signin');
1176
+ return;
1177
+ }
1178
+
1179
+ setStatus('Preparing...', 'loading');
1180
+
1181
+ try {
1182
+ var optRes = await fetch('/webauthn/auth-options', {
1183
+ method: 'POST',
1184
+ headers: { 'Content-Type': 'application/json' },
1185
+ body: '{}',
1186
+ });
1187
+ var optData = await optRes.json();
1188
+ var challengeId = optData.challengeId;
1189
+ var options = optData.options;
1190
+ if (!options) throw new Error('Server returned no options');
1191
+
1192
+ options.challenge = b64urlToBytes(options.challenge);
1193
+ if (options.allowCredentials) {
1194
+ options.allowCredentials = options.allowCredentials.map(function(c) { return Object.assign({}, c, { id: b64urlToBytes(c.id) }); });
1195
+ }
1196
+
1197
+ setStatus('Waiting for biometric...', 'loading');
1198
+ var assertion = await navigator.credentials.get({ publicKey: options });
1199
+
1200
+ var reqBody = {
1201
+ challengeId: challengeId,
1202
+ credential: {
1203
+ id: assertion.id, rawId: bytesToB64url(assertion.rawId), type: assertion.type,
1204
+ response: {
1205
+ authenticatorData: bytesToB64url(assertion.response.authenticatorData),
1206
+ clientDataJSON: bytesToB64url(assertion.response.clientDataJSON),
1207
+ signature: bytesToB64url(assertion.response.signature),
1208
+ userHandle: assertion.response.userHandle ? bytesToB64url(assertion.response.userHandle) : null,
1209
+ },
1210
+ },
1211
+ };
1212
+
1213
+ setStatus('Verifying...', 'loading');
1214
+ var verRes = await fetch('/webauthn/auth-verify', {
1215
+ method: 'POST',
1216
+ headers: { 'Content-Type': 'application/json' },
1217
+ body: JSON.stringify(reqBody),
1218
+ });
1219
+ var result = await verRes.json();
1220
+
1221
+ if (result.success) {
1222
+ // Direct Remote Control login continuation. Runs before any
1223
+ // qr-login/approve or pair-mode logic so the apiKey lands in
1224
+ // sessionStorage and the destination's auth gate finds it.
1225
+ if (redirectToRemoteControlIfDirectLogin(result)) return;
1226
+ // If phone completing a desktop QR session, approve it
1227
+ var approveResponse = null;
1228
+ if (qrSessionMode && qrSessionId) {
1229
+ var approveRes = await fetch('/api/qr-login/approve', {
1230
+ method: 'POST',
1231
+ headers: { 'Content-Type': 'application/json' },
1232
+ body: JSON.stringify({
1233
+ sessionId: qrSessionId,
1234
+ agentId: result.agentId,
1235
+ apiKey: result.apiKey,
1236
+ tenantId: result.tenantId,
1237
+ credentialLabel: result.credentialLabel,
1238
+ }),
1239
+ });
1240
+ try { approveResponse = await approveRes.json(); } catch (e) { approveResponse = null; }
1241
+ }
1242
+ // CRC pair-mode: if the server returned a sanitized `next`, store
1243
+ // the api_key on the phone and redirect to /pair/<CODE>. Phone is
1244
+ // the actor that completes pair-complete (plan C6).
1245
+ if (await followPairNextIfPresent(approveResponse, result, false)) return;
1246
+ storeDemoHandoffIfNeeded('/demo', result, false);
1247
+ document.getElementById('signup-view').style.display = 'none';
1248
+ document.getElementById('success-view').style.display = 'block';
1249
+ // Use credentialLabel from the server, which matches the saved-
1250
+ // passkey label the user sees in iOS Passwords / 1Password.
1251
+ document.getElementById('welcome-name').textContent = result.credentialLabel || result.agentId || 'you';
1252
+ setStatus('', '');
1253
+ } else {
1254
+ setStatus(result.error || 'Authentication failed', 'error');
1255
+ document.getElementById('signInBtn').style.pointerEvents = 'auto';
1256
+ document.getElementById('createBtn').disabled = false;
1257
+ }
1258
+ } catch (err) {
1259
+ if (err.name === 'NotAllowedError') {
1260
+ // Distinguish auto-start rejection from a user cancellation.
1261
+ // When the QR auto-start hits a strict user-activation policy
1262
+ // (Safari Private Browsing, future stricter modes), surface a
1263
+ // clear, QR-specific instruction instead of the generic
1264
+ // "Cancelled" copy. The sign-in link and primary button stay
1265
+ // enabled below; the user's tap is a real gesture and reuses
1266
+ // the same qrSessionId.
1267
+ if (qrSessionMode && qrAutoStarting) {
1268
+ setStatus('This browser blocked automatic Face ID. Tap Already have an account? Sign in. to continue.', 'error');
1269
+ } else {
1270
+ setStatus('Cancelled. Try again when ready.', 'error');
1271
+ setTimeout(function() { clearStatus(); }, 3000);
1272
+ }
1273
+ } else {
1274
+ setStatus('Error: ' + err.message, 'error');
1275
+ }
1276
+ document.getElementById('signInBtn').style.pointerEvents = 'auto';
1277
+ document.getElementById('createBtn').disabled = false;
1278
+ }
1279
+ }
1280
+
1281
+ // Enter key on handle input triggers create
1282
+ document.getElementById('handleInput').addEventListener('keydown', function(e) {
1283
+ if (e.key === 'Enter') doCreateAccount();
1284
+ });
1285
+
1286
+ initFooterPasskeys();
1287
+ initFooterPasskeysInfo();
1288
+ </script>
1289
+ </body>
1290
+ </html>