@wipcomputer/wip-ldm-os 0.4.85-alpha.2 → 0.4.85-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,843 @@
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: #FFFDF5;
13
+ --text: #1a1a1a;
14
+ --text-muted: #8a8580;
15
+ --accent: #0033FF;
16
+ --accent-hover: #0033FF;
17
+ --input-bg: #F5F3ED;
18
+ --input-border: #E0DDD6;
19
+ --font: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
20
+ }
21
+
22
+ html, body {
23
+ height: 100%;
24
+ font-family: var(--font);
25
+ background: var(--bg);
26
+ color: var(--text);
27
+ -webkit-text-size-adjust: 100%;
28
+ -webkit-font-smoothing: antialiased;
29
+ }
30
+
31
+ @media (min-width: 768px) {
32
+ html, body { overflow: hidden; }
33
+ }
34
+
35
+ .login-page {
36
+ display: flex;
37
+ flex-direction: column;
38
+ align-items: center;
39
+ justify-content: center;
40
+ min-height: 100vh;
41
+ min-height: 100dvh;
42
+ padding: 24px;
43
+ padding-top: calc(24px + env(safe-area-inset-top, 0px));
44
+ overflow-y: auto;
45
+ -webkit-overflow-scrolling: touch;
46
+ }
47
+
48
+ .login-card {
49
+ position: relative;
50
+ max-width: 380px;
51
+ width: 100%;
52
+ text-align: center;
53
+ }
54
+
55
+ .login-title {
56
+ font-size: 26px;
57
+ font-weight: 600;
58
+ letter-spacing: -0.02em;
59
+ margin-bottom: 8px;
60
+ }
61
+
62
+ .login-buttons {
63
+ display: flex;
64
+ flex-direction: column;
65
+ gap: 12px;
66
+ margin-bottom: 16px;
67
+ }
68
+
69
+ .btn {
70
+ display: block;
71
+ width: 100%;
72
+ padding: 18px;
73
+ border: none;
74
+ border-radius: 12px;
75
+ font-size: 18px;
76
+ font-weight: 600;
77
+ font-family: var(--font);
78
+ cursor: pointer;
79
+ transition: background 0.15s, transform 0.1s;
80
+ -webkit-tap-highlight-color: transparent;
81
+ }
82
+
83
+ .btn:active {
84
+ transform: scale(0.98);
85
+ }
86
+
87
+ .btn-primary {
88
+ background: var(--accent);
89
+ color: white;
90
+ }
91
+
92
+ .btn-primary:hover {
93
+ background: var(--accent-hover);
94
+ }
95
+
96
+ .btn:disabled {
97
+ opacity: 0.5;
98
+ cursor: not-allowed;
99
+ transform: none;
100
+ }
101
+
102
+ .login-status {
103
+ margin-top: 16px;
104
+ font-size: 14px;
105
+ padding: 12px 16px;
106
+ border-radius: 10px;
107
+ display: none;
108
+ text-align: left;
109
+ }
110
+
111
+ .login-status.show { display: block; }
112
+ .login-status.loading { background: #E8EEFF; color: var(--accent); }
113
+ .login-status.error { background: #FFF0F0; color: #D32F2F; }
114
+ .login-status.success { background: #F0FFF4; color: #2E7D32; }
115
+ </style>
116
+ </head>
117
+ <body>
118
+
119
+ <div class="login-page" id="loginPage">
120
+ <div class="login-card">
121
+ <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>
122
+ <p style="color:#8a8580;font-size:16px;margin:0 0 32px 0;letter-spacing:0.2px;">Every AI. One experience.</p>
123
+
124
+ <!-- Signup view (default) -->
125
+ <div id="signup-view">
126
+ <div class="login-buttons">
127
+ <button class="btn btn-primary" id="createBtn" onclick="doCreateAccount()">Enter the Kaleidoscope</button>
128
+ </div>
129
+ <div style="margin-top:12px;">
130
+ <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;" />
131
+ </div>
132
+ <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>
133
+ <div style="margin-top:12px;text-align:center;">
134
+ <a id="signInBtn" onclick="doSignIn()" style="color:var(--accent);font-size:16px;cursor:pointer;text-decoration:none;">Already have an account? Sign in.</a>
135
+ </div>
136
+ </div>
137
+
138
+ <!-- QR code view (Chrome desktop fallback) -->
139
+ <div id="qr-view" style="display:none">
140
+ <p style="color:#8a8580;font-size:16px;margin:0 0 24px 0;">Scan with your phone to continue.</p>
141
+ <div style="display:flex;justify-content:center;margin-bottom:24px;">
142
+ <img id="qr-image" style="width:200px;height:200px;border-radius:12px;background:#F5F3ED;" />
143
+ </div>
144
+ <p id="qr-status-text" style="color:var(--accent);font-size:14px;">Waiting for phone...</p>
145
+ <div style="margin-top:20px;">
146
+ <a onclick="cancelQrLogin()" style="color:var(--text-muted);font-size:14px;cursor:pointer;text-decoration:none;">Cancel</a>
147
+ </div>
148
+ </div>
149
+
150
+ <!-- Success view (legacy login flow). No action button. -->
151
+ <div id="success-view" style="display:none">
152
+ <p style="font-size:18px;margin-bottom:12px;color:var(--text);">Welcome, <span id="welcome-name"></span>.</p>
153
+ <p style="color:var(--text-muted);font-size:15px;margin-bottom:8px;">Your passkey has been saved to your phone.</p>
154
+ <p style="color:var(--text-muted);font-size:15px;margin-bottom:24px;">You can use it to sign in to any WIP Computer service.</p>
155
+ </div>
156
+
157
+ <!-- Pair-approved view (CRC pair-mode, desktop-side after phone approves) -->
158
+ <div id="pair-approved-view" style="display:none">
159
+ <p style="font-size:18px;margin-bottom:12px;color:var(--text);">Approved on your phone.</p>
160
+ <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>
161
+ </div>
162
+
163
+ <div class="login-status" id="loginStatus" style="position:absolute;left:0;right:0;margin-top:16px;text-align:center;"></div>
164
+ </div>
165
+ </div>
166
+ <div id="kscope-footer"></div>
167
+
168
+ <script src="/app/footer.js"></script>
169
+ <script>
170
+ // ── Random kaleidoscope icon from sprite sheet ──
171
+ var SPRITE_COLS = 8;
172
+ var SPRITE_ROWS = 3;
173
+ var SPRITE_TOTAL = SPRITE_COLS * SPRITE_ROWS;
174
+
175
+ function makeIconHTML(size) {
176
+ var idx = Math.floor(Math.random() * SPRITE_TOTAL);
177
+ var col = idx % SPRITE_COLS;
178
+ var row = Math.floor(idx / SPRITE_COLS);
179
+ var bgPosX = (col / (SPRITE_COLS - 1)) * 100;
180
+ var bgPosY = (row / (SPRITE_ROWS - 1)) * 100;
181
+ 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>';
182
+ }
183
+
184
+ var loginIcon = document.getElementById('loginIcon');
185
+ if (loginIcon) loginIcon.innerHTML = makeIconHTML(34);
186
+
187
+ // Rotate icon every 3s
188
+ var loginRotateIdx = Math.floor(Math.random() * SPRITE_TOTAL);
189
+ setInterval(function() {
190
+ loginRotateIdx = (loginRotateIdx + 1) % SPRITE_TOTAL;
191
+ var col = loginRotateIdx % SPRITE_COLS;
192
+ var row = Math.floor(loginRotateIdx / SPRITE_COLS);
193
+ var bgPosX = (col / (SPRITE_COLS - 1)) * 100;
194
+ var bgPosY = (row / (SPRITE_ROWS - 1)) * 100;
195
+ 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>';
196
+ }, 3000);
197
+
198
+ // ── WebAuthn helpers ──
199
+ function b64urlToBytes(b64) {
200
+ var bin = atob(b64.replace(/-/g, '+').replace(/_/g, '/') + '=='.slice(0, (4 - b64.length % 4) % 4));
201
+ return Uint8Array.from(bin, function(c) { return c.charCodeAt(0); });
202
+ }
203
+ function bytesToB64url(buf) {
204
+ return btoa(String.fromCharCode.apply(null, new Uint8Array(buf)))
205
+ .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
206
+ }
207
+
208
+ function setStatus(msg, type) {
209
+ var el = document.getElementById('loginStatus');
210
+ el.style.transition = '';
211
+ el.style.opacity = '';
212
+ el.textContent = msg;
213
+ el.className = msg ? 'login-status show ' + type : 'login-status';
214
+ }
215
+
216
+ function clearStatus() {
217
+ var el = document.getElementById('loginStatus');
218
+ el.style.transition = 'opacity 0.5s';
219
+ el.style.opacity = '0';
220
+ setTimeout(function() { el.className = 'login-status'; el.style.transition = ''; el.style.opacity = ''; }, 500);
221
+ }
222
+
223
+ // ── Browser detection ──
224
+ function isMobileDevice() {
225
+ return navigator.maxTouchPoints > 0 && window.innerWidth < 768;
226
+ }
227
+
228
+ function isSafariDesktop() {
229
+ if (isMobileDevice()) return false;
230
+ return /Safari\//.test(navigator.userAgent) && !/Chrome\//.test(navigator.userAgent);
231
+ }
232
+
233
+ function needsCustomQR() {
234
+ return !isMobileDevice() && !isSafariDesktop() && !isLocalPasskeysOn();
235
+ }
236
+
237
+ // ── Local passkeys toggle (persisted in localStorage) ──
238
+ function isLocalPasskeysOn() {
239
+ return localStorage.getItem('localPasskeys') === 'on';
240
+ }
241
+
242
+ function toggleLocalPasskeys() {
243
+ var on = isLocalPasskeysOn();
244
+ localStorage.setItem('localPasskeys', on ? 'off' : 'on');
245
+ updatePasskeysDot();
246
+ }
247
+
248
+ function updatePasskeysDot() {
249
+ var dot = document.getElementById('passkeys-dot');
250
+ var label = document.getElementById('passkeys-label');
251
+ if (!dot) return;
252
+ if (isLocalPasskeysOn()) {
253
+ dot.style.background = '#2E7D32';
254
+ dot.style.opacity = '1';
255
+ if (label) label.textContent = 'Local passkeys on';
256
+ } else {
257
+ dot.style.background = '#D32F2F';
258
+ dot.style.opacity = '0.4';
259
+ if (label) label.textContent = 'Local passkeys off';
260
+ }
261
+ }
262
+
263
+ // ── `next` continuation carrier ──
264
+ //
265
+ // Two whitelisted next shapes:
266
+ //
267
+ // PAIR_NEXT_REGEX /pair/<CODE> using the daemon alphabet
268
+ // (CODEX_PAIR_ALPHABET, length 6, L is
269
+ // included; I/O/0/1 excluded). C8 applies:
270
+ // URL fallback is mobile-only, the desktop
271
+ // must not become the pairing authority.
272
+ //
273
+ // REMOTE_CONTROL_NEXT_REGEX /codex-remote-control/<UUID>. Standard
274
+ // ?next semantics; allowed on both
275
+ // desktop and mobile (this is navigation
276
+ // continuation, not authority transfer).
277
+ //
278
+ // Anything else is silently dropped. `next` is NOT a general redirect
279
+ // primitive. The server validates authoritatively; this client-side
280
+ // check is defense-in-depth.
281
+ var PAIR_NEXT_REGEX = /^\/pair\/[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/;
282
+ 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;
283
+
284
+ function isWhitelistedNext(raw) {
285
+ return typeof raw === 'string' && (PAIR_NEXT_REGEX.test(raw) || REMOTE_CONTROL_NEXT_REGEX.test(raw));
286
+ }
287
+
288
+ function readPairNextFromQuery() {
289
+ try {
290
+ var raw = new URLSearchParams(window.location.search).get('next');
291
+ if (!raw) return null;
292
+ return isWhitelistedNext(raw) ? raw : null;
293
+ } catch (e) { return null; }
294
+ }
295
+
296
+ function isPairNextOnDesktop() {
297
+ var next = readPairNextFromQuery();
298
+ return !!(next && PAIR_NEXT_REGEX.test(next) && !isMobileDevice());
299
+ }
300
+
301
+ // Direct Remote Control login continuation. Explicit branch ... NOT
302
+ // routed through pair-mode helpers. Called from doSignIn / doCreateAccount
303
+ // the moment /webauthn/auth-verify or /webauthn/register-verify returns
304
+ // success, BEFORE any qr-login/approve or pair-mode logic runs.
305
+ //
306
+ // If the URL was /login?next=/codex-remote-control/<UUID> AND auth-verify
307
+ // returned a real apiKey, store the credentials in sessionStorage so the
308
+ // destination page's auth gate sees them, then location.replace into the
309
+ // Remote Control surface. Returns true if it redirected; caller must
310
+ // return early.
311
+ //
312
+ // If the URL has no remote-control next, returns false (caller proceeds
313
+ // with the existing QR / pair / welcome logic).
314
+ //
315
+ // If the URL has a remote-control next BUT result.apiKey is missing,
316
+ // console.warn and return false. The caller then falls through to the
317
+ // welcome view (defense in depth from PR #805) so the user is not
318
+ // stranded on a blank /codex-remote-control page that bounces back to
319
+ // sign-in.
320
+ //
321
+ // This is deliberately separate from followPairNextIfPresent because
322
+ // pair-mode behavior (C6 desktop strip, C8 desktop-no-redirect) is
323
+ // orthogonal to standard Remote Control login continuation.
324
+ //
325
+ // Also writes a short-lived same-origin handoff cookie as a Safari-safe
326
+ // fallback: some browsers drop sessionStorage across location.replace
327
+ // under privacy/partition heuristics. Path=/ is required because Safari
328
+ // will silently reject document.cookie writes whose Path attribute is
329
+ // not the current path or an ancestor of it; /login is not an ancestor
330
+ // of /codex-remote-control, so the previous Path=/codex-remote-control
331
+ // scope was dropped on Safari. The Remote Control page reads either
332
+ // source and clears both Path scopes after consumption. See
333
+ // setHandoffCookie below.
334
+ function setHandoffCookie(name, value) {
335
+ document.cookie = name + '=' + encodeURIComponent(value)
336
+ + '; Path=/'
337
+ + '; Max-Age=60'
338
+ + '; Secure'
339
+ + '; SameSite=Strict';
340
+ }
341
+ function redirectToRemoteControlIfDirectLogin(result) {
342
+ try {
343
+ var raw = new URLSearchParams(window.location.search).get('next');
344
+ if (!raw || !REMOTE_CONTROL_NEXT_REGEX.test(raw)) return false;
345
+ if (!result || !result.apiKey) {
346
+ console.warn('redirectToRemoteControlIfDirectLogin: missing apiKey on result; falling through to welcome view. result=', result);
347
+ return false;
348
+ }
349
+ console.log('redirectToRemoteControlIfDirectLogin: storing wip_api_key + redirecting', { agentId: result.agentId, next: raw });
350
+ sessionStorage.setItem('wip_api_key', result.apiKey);
351
+ if (result.agentId) sessionStorage.setItem('wip_handle', result.agentId);
352
+ setHandoffCookie('wip_rc_api_key', result.apiKey);
353
+ if (result.agentId) setHandoffCookie('wip_rc_handle', result.agentId);
354
+ location.replace(raw);
355
+ return true;
356
+ } catch (e) {
357
+ console.warn('redirectToRemoteControlIfDirectLogin: unexpected error', e);
358
+ return false;
359
+ }
360
+ }
361
+
362
+ // Phone-side post-passkey hook. Called after successful passkey + (optional)
363
+ // qr-login/approve. Decides whether to redirect to the next URL based on
364
+ // authority rules from plan C6 + C8:
365
+ //
366
+ // - QR-scan path (approveResponse.next from the server): follow it for
367
+ // either whitelisted shape. The phone scanned the desktop's QR,
368
+ // signed in, and the server returned a sanitized `next`.
369
+ //
370
+ // - URL fallback path (no approveResponse.next):
371
+ // - /codex-remote-control/<UUID>: allowed on both desktop and
372
+ // mobile. Standard navigation continuation.
373
+ // - /pair/<CODE>: mobile-only (C8). Desktop with no
374
+ // approveResponse must not become the pairing authority.
375
+ //
376
+ // REGRESSION COVERED: a desktop browser with "Local passkeys" on,
377
+ // loading /login?next=/pair/<CODE> and completing desktop WebAuthn,
378
+ // MUST NOT redirect to /pair/<CODE> or complete pairing. If a future
379
+ // change opens this gate, it violates plan constraint C8 ("Default
380
+ // authority is phone-held passkey; local desktop passkeys are
381
+ // testing-only"). The codex-remote-control next does NOT trip this
382
+ // rule because it's a navigation continuation, not authority transfer.
383
+ //
384
+ // Returns true if it redirected (caller should not run other view-switching).
385
+ async function followPairNextIfPresent(approveResponse, identity) {
386
+ var next = null;
387
+ // Path 1: QR phone-side approve. Server-blessed next, either shape.
388
+ if (approveResponse && typeof approveResponse.next === 'string' && isWhitelistedNext(approveResponse.next)) {
389
+ next = approveResponse.next;
390
+ } else {
391
+ // Path 2: URL ?next= fallback (no approveResponse). Branch by
392
+ // shape: codex-remote-control allowed on any device, pair-mode
393
+ // restricted to mobile per C8.
394
+ var urlNext = readPairNextFromQuery();
395
+ if (urlNext) {
396
+ if (REMOTE_CONTROL_NEXT_REGEX.test(urlNext)) {
397
+ next = urlNext;
398
+ } else if (PAIR_NEXT_REGEX.test(urlNext) && isMobileDevice()) {
399
+ next = urlNext;
400
+ }
401
+ // Path 3 (desktop + pair next, no approveResponse): next stays null.
402
+ }
403
+ }
404
+ if (!next) return false;
405
+ // Auth-handoff guard: if we don't have an apiKey to store, do NOT
406
+ // redirect to next. Stranding the user on /codex-remote-control/<UUID>
407
+ // without a key in sessionStorage would render a redirect-to-login
408
+ // loop or a blank page (depending on the destination's auth gate).
409
+ // Falling through here lets the caller render the existing
410
+ // welcome-name view, which at least shows the user something they
411
+ // can interact with.
412
+ if (!identity || !identity.apiKey) {
413
+ console.warn('followPairNextIfPresent: missing apiKey on identity; skipping redirect to', next);
414
+ return false;
415
+ }
416
+ sessionStorage.setItem('wip_api_key', identity.apiKey);
417
+ if (identity.agentId) sessionStorage.setItem('wip_handle', identity.agentId);
418
+ location.replace(next);
419
+ return true;
420
+ }
421
+
422
+ // ── QR Login (Chrome desktop fallback) ──
423
+ var qrLoginPollTimer = null;
424
+
425
+ async function startQrLogin(handle, mode) {
426
+ var btn = document.getElementById('createBtn');
427
+ btn.disabled = true;
428
+ setStatus('', '');
429
+
430
+ try {
431
+ var pairNext = readPairNextFromQuery();
432
+ var startBody = { handle: handle || undefined, mode: mode || 'register' };
433
+ if (pairNext) startBody.next = pairNext;
434
+ var res = await fetch('/api/qr-login', {
435
+ method: 'POST',
436
+ headers: { 'Content-Type': 'application/json' },
437
+ body: JSON.stringify(startBody),
438
+ });
439
+ var data = await res.json();
440
+
441
+ // Switch to QR view
442
+ document.getElementById('signup-view').style.display = 'none';
443
+ document.getElementById('qr-view').style.display = 'block';
444
+ document.getElementById('qr-image').src = data.qrUrl;
445
+
446
+ // Start polling
447
+ qrLoginPollTimer = setInterval(function() {
448
+ pollQrLogin(data.sessionId);
449
+ }, 2000);
450
+
451
+ // Expire after 5 minutes
452
+ setTimeout(function() {
453
+ if (qrLoginPollTimer) {
454
+ cancelQrLogin();
455
+ setStatus('Session expired. Try again.', 'error');
456
+ setTimeout(clearStatus, 3000);
457
+ }
458
+ }, 5 * 60 * 1000);
459
+
460
+ } catch (err) {
461
+ setStatus('Error: ' + err.message, 'error');
462
+ btn.disabled = false;
463
+ }
464
+ }
465
+
466
+ async function pollQrLogin(sessionId) {
467
+ try {
468
+ var res = await fetch('/api/qr-login/status?s=' + sessionId);
469
+ if (res.status === 404) {
470
+ // Session expired on server
471
+ cancelQrLogin();
472
+ setStatus('Session expired. Try again.', 'error');
473
+ setTimeout(clearStatus, 3000);
474
+ return;
475
+ }
476
+ var data = await res.json();
477
+ if (data.status === 'approved') {
478
+ clearInterval(qrLoginPollTimer);
479
+ qrLoginPollTimer = null;
480
+ document.getElementById('qr-view').style.display = 'none';
481
+ // Branch by next shape. Three cases:
482
+ //
483
+ // 1. Pair-mode (URL ?next=/pair/<CODE>): server stripped apiKey
484
+ // and next from this response (plan C6). Desktop never
485
+ // redirects (C8). Show the dedicated pair-approved view.
486
+ // The phone is the actor that completes pair-complete.
487
+ //
488
+ // 2. Codex remote control continuation (data.next is a
489
+ // /codex-remote-control/<UUID>): standard post-login next.
490
+ // Desktop authenticates with the server-provided apiKey and
491
+ // redirects to next. Server returned the full login response
492
+ // (apiKey, credentialLabel, next) for this case.
493
+ //
494
+ // 3. Legacy login mode (no whitelisted next anywhere): show the
495
+ // existing welcome view.
496
+ var urlNext = readPairNextFromQuery();
497
+ if (urlNext && PAIR_NEXT_REGEX.test(urlNext)) {
498
+ document.getElementById('pair-approved-view').style.display = 'block';
499
+ } else if (data.next && REMOTE_CONTROL_NEXT_REGEX.test(data.next) && data.apiKey) {
500
+ // Codex remote control: pick up the credentials so the
501
+ // destination page is authenticated, then redirect.
502
+ sessionStorage.setItem('wip_api_key', data.apiKey);
503
+ if (data.agentId) sessionStorage.setItem('wip_handle', data.agentId);
504
+ location.replace(data.next);
505
+ } else {
506
+ document.getElementById('success-view').style.display = 'block';
507
+ // Use credentialLabel (matches the saved-passkey label the user
508
+ // sees in iOS Passwords / 1Password). Falls back to agentId for
509
+ // back-compat with older deploys.
510
+ document.getElementById('welcome-name').textContent = data.credentialLabel || data.agentId || 'you';
511
+ }
512
+ }
513
+ } catch (err) {
514
+ // Network error, keep polling
515
+ }
516
+ }
517
+
518
+ function cancelQrLogin() {
519
+ if (qrLoginPollTimer) {
520
+ clearInterval(qrLoginPollTimer);
521
+ qrLoginPollTimer = null;
522
+ }
523
+ document.getElementById('qr-view').style.display = 'none';
524
+ document.getElementById('signup-view').style.display = 'block';
525
+ document.getElementById('createBtn').disabled = false;
526
+ document.getElementById('signInBtn').style.pointerEvents = 'auto';
527
+ }
528
+
529
+ // ── Phone-side QR session detection ──
530
+ var urlParams = new URLSearchParams(window.location.search);
531
+ var qrSessionId = urlParams.get('s');
532
+ var qrHandle = urlParams.get('h');
533
+ var qrMode = urlParams.get('m') || 'register';
534
+ var qrSessionMode = !!qrSessionId;
535
+
536
+ // True only while the auto-start setTimeout is invoking
537
+ // doCreateAccount / doSignIn. Read by the NotAllowedError catches so
538
+ // they can suppress the user-facing "Cancelled. Try again when ready."
539
+ // message when the rejection was caused by a strict user-activation
540
+ // policy (Safari Private Browsing) rather than an actual user
541
+ // cancellation. The user's eventual tap on the existing button or
542
+ // link runs the same handler with a real user gesture, and the same
543
+ // qrSessionId completes /api/qr-login/approve.
544
+ var qrAutoStarting = false;
545
+
546
+ if (qrSessionMode) {
547
+ // Clean the URL so session ID doesn't persist
548
+ history.replaceState(null, '', '/login');
549
+ // Pre-fill handle if provided
550
+ if (qrHandle) document.getElementById('handleInput').value = qrHandle;
551
+ // Auto-start the right flow on phone (no extra tap needed).
552
+ // Restored from the pre-#784 known-good QR phone UX. Safari (normal
553
+ // mode) users never see the static login page (Face ID pops within
554
+ // 300ms). Browsers with stricter user-activation policies (Safari
555
+ // Private Browsing, sometimes Chrome on iOS) may reject the
556
+ // auto-start with NotAllowedError. The catch path below replaces
557
+ // the generic "Cancelled" status with a clear, QR-specific
558
+ // instructional message ("This browser blocked automatic Face ID.
559
+ // Tap ... to continue.") and leaves the existing controls enabled
560
+ // so the user's tap completes the flow with a real gesture.
561
+ setTimeout(async function() {
562
+ qrAutoStarting = true;
563
+ try {
564
+ if (qrMode === 'signin') {
565
+ await doSignIn();
566
+ } else {
567
+ await doCreateAccount();
568
+ }
569
+ } finally {
570
+ qrAutoStarting = false;
571
+ }
572
+ }, 300);
573
+ } else if (isPairNextOnDesktop()) {
574
+ // `codex-daemon link` opens /login?next=/pair/<CODE> on the desktop.
575
+ // The desktop's only authority in that flow is to display QR and wait.
576
+ setTimeout(function() {
577
+ startQrLogin('', 'signin');
578
+ }, 0);
579
+ }
580
+
581
+ // ── Create Account ──
582
+ async function doCreateAccount() {
583
+ var username = (document.getElementById('handleInput').value || '').trim().replace(/^@/, '').toLowerCase().replace(/[^a-z0-9\-]/g, '').slice(0, 30);
584
+ var btn = document.getElementById('createBtn');
585
+ btn.disabled = true;
586
+
587
+ // Pair-mode desktop URLs must always use the QR path. A local
588
+ // desktop passkey is testing-only and must not become pair authority.
589
+ if (isPairNextOnDesktop() && !qrSessionMode) {
590
+ startQrLogin(username, 'signin');
591
+ return;
592
+ }
593
+
594
+ // Chrome desktop without local passkeys: use custom QR code
595
+ if (needsCustomQR() && !qrSessionMode) {
596
+ startQrLogin(username);
597
+ return;
598
+ }
599
+
600
+ setStatus('Preparing...', 'loading');
601
+
602
+ try {
603
+ var optRes = await fetch('/webauthn/register-options', {
604
+ method: 'POST',
605
+ headers: { 'Content-Type': 'application/json' },
606
+ body: JSON.stringify({ username: username || undefined }),
607
+ });
608
+ var optData = await optRes.json();
609
+ var challengeId = optData.challengeId;
610
+ var options = optData.options;
611
+ if (!options) throw new Error('Server returned no options');
612
+
613
+ options.challenge = b64urlToBytes(options.challenge);
614
+ options.user.id = b64urlToBytes(options.user.id);
615
+ if (options.excludeCredentials) {
616
+ options.excludeCredentials = options.excludeCredentials.map(function(c) { return Object.assign({}, c, { id: b64urlToBytes(c.id) }); });
617
+ }
618
+
619
+ // Phone-first by default. If local passkeys toggled on, show all options.
620
+ // On mobile (including QR session mode), always use platform (Face ID directly).
621
+ var mobile = isMobileDevice();
622
+ var localOn = isLocalPasskeysOn();
623
+ if (!options.authenticatorSelection) options.authenticatorSelection = {};
624
+ if (mobile) {
625
+ options.authenticatorSelection.authenticatorAttachment = 'platform';
626
+ } else if (!localOn) {
627
+ options.authenticatorSelection.authenticatorAttachment = 'cross-platform';
628
+ }
629
+ options.authenticatorSelection.residentKey = 'preferred';
630
+ options.authenticatorSelection.userVerification = 'required';
631
+
632
+ setStatus(mobile ? 'Waiting for biometric...' : localOn ? 'Waiting for biometric...' : 'Scan the QR code with your phone...', 'loading');
633
+ var credential = await navigator.credentials.create({ publicKey: options });
634
+
635
+ var reqBody = {
636
+ challengeId: challengeId,
637
+ credential: {
638
+ id: credential.id,
639
+ rawId: bytesToB64url(credential.rawId),
640
+ type: credential.type,
641
+ response: {
642
+ attestationObject: bytesToB64url(credential.response.attestationObject),
643
+ clientDataJSON: bytesToB64url(credential.response.clientDataJSON),
644
+ transports: credential.response.getTransports ? credential.response.getTransports() : [],
645
+ },
646
+ },
647
+ };
648
+
649
+ setStatus('Verifying...', 'loading');
650
+ var verRes = await fetch('/webauthn/register-verify', {
651
+ method: 'POST',
652
+ headers: { 'Content-Type': 'application/json' },
653
+ body: JSON.stringify(reqBody),
654
+ });
655
+ var result = await verRes.json();
656
+
657
+ if (result.success) {
658
+ // Direct Remote Control login continuation. Runs before any
659
+ // qr-login/approve or pair-mode logic so the apiKey lands in
660
+ // sessionStorage and the destination's auth gate finds it.
661
+ if (redirectToRemoteControlIfDirectLogin(result)) return;
662
+ // If phone completing a desktop QR session, approve it
663
+ var approveResponse = null;
664
+ if (qrSessionMode && qrSessionId) {
665
+ var approveRes = await fetch('/api/qr-login/approve', {
666
+ method: 'POST',
667
+ headers: { 'Content-Type': 'application/json' },
668
+ body: JSON.stringify({
669
+ sessionId: qrSessionId,
670
+ agentId: result.agentId,
671
+ apiKey: result.apiKey,
672
+ credentialLabel: result.credentialLabel,
673
+ }),
674
+ });
675
+ try { approveResponse = await approveRes.json(); } catch (e) { approveResponse = null; }
676
+ }
677
+ // Mark that user has an account (persists across sessions)
678
+ localStorage.setItem('kscope-has-account', 'true');
679
+ // CRC pair-mode: if the server returned a sanitized `next`, store
680
+ // the api_key on the phone and redirect to /pair/<CODE>. Phone is
681
+ // the actor that completes pair-complete (plan C6).
682
+ if (await followPairNextIfPresent(approveResponse, result)) return;
683
+ document.getElementById('signup-view').style.display = 'none';
684
+ document.getElementById('success-view').style.display = 'block';
685
+ // Use credentialLabel from the server, which matches the userName
686
+ // saved by iOS Passwords / 1Password. Falls back to typed
687
+ // username, then agentId, then "you".
688
+ document.getElementById('welcome-name').textContent = result.credentialLabel || username || result.agentId || 'you';
689
+ setStatus('', '');
690
+ } else {
691
+ setStatus(result.error || 'Registration failed', 'error');
692
+ btn.disabled = false;
693
+ }
694
+ } catch (err) {
695
+ if (err.name === 'NotAllowedError') {
696
+ // Distinguish auto-start rejection from a user cancellation.
697
+ // When the QR auto-start hits a strict user-activation policy
698
+ // (Safari Private Browsing, future stricter modes), surface a
699
+ // clear, QR-specific instruction instead of the generic
700
+ // "Cancelled" copy. The existing primary button stays enabled
701
+ // (btn.disabled = false below); the user's tap is a real
702
+ // gesture and reuses the same qrSessionId.
703
+ if (qrSessionMode && qrAutoStarting) {
704
+ setStatus('This browser blocked automatic Face ID. Tap Enter the Kaleidoscope to continue.', 'error');
705
+ } else {
706
+ setStatus('Cancelled. Try again when ready.', 'error');
707
+ setTimeout(function() { clearStatus(); }, 3000);
708
+ }
709
+ } else {
710
+ setStatus('Error: ' + err.message, 'error');
711
+ }
712
+ btn.disabled = false;
713
+ }
714
+ }
715
+
716
+ async function doSignIn() {
717
+ document.getElementById('signInBtn').style.pointerEvents = 'none';
718
+ document.getElementById('createBtn').disabled = true;
719
+
720
+ // Pair-mode desktop URLs must always use the QR path. A local
721
+ // desktop passkey is testing-only and must not become pair authority.
722
+ if (isPairNextOnDesktop() && !qrSessionMode) {
723
+ startQrLogin('', 'signin');
724
+ return;
725
+ }
726
+
727
+ // Chrome desktop without local passkeys: use QR code for sign-in.
728
+ // With local passkeys on, let Chrome show native dialog (supports YubiKey).
729
+ if (needsCustomQR() && !qrSessionMode) {
730
+ startQrLogin('', 'signin');
731
+ return;
732
+ }
733
+
734
+ setStatus('Preparing...', 'loading');
735
+
736
+ try {
737
+ var optRes = await fetch('/webauthn/auth-options', {
738
+ method: 'POST',
739
+ headers: { 'Content-Type': 'application/json' },
740
+ body: '{}',
741
+ });
742
+ var optData = await optRes.json();
743
+ var challengeId = optData.challengeId;
744
+ var options = optData.options;
745
+ if (!options) throw new Error('Server returned no options');
746
+
747
+ options.challenge = b64urlToBytes(options.challenge);
748
+ if (options.allowCredentials) {
749
+ options.allowCredentials = options.allowCredentials.map(function(c) { return Object.assign({}, c, { id: b64urlToBytes(c.id) }); });
750
+ }
751
+
752
+ setStatus('Waiting for biometric...', 'loading');
753
+ var assertion = await navigator.credentials.get({ publicKey: options });
754
+
755
+ var reqBody = {
756
+ challengeId: challengeId,
757
+ credential: {
758
+ id: assertion.id, rawId: bytesToB64url(assertion.rawId), type: assertion.type,
759
+ response: {
760
+ authenticatorData: bytesToB64url(assertion.response.authenticatorData),
761
+ clientDataJSON: bytesToB64url(assertion.response.clientDataJSON),
762
+ signature: bytesToB64url(assertion.response.signature),
763
+ userHandle: assertion.response.userHandle ? bytesToB64url(assertion.response.userHandle) : null,
764
+ },
765
+ },
766
+ };
767
+
768
+ setStatus('Verifying...', 'loading');
769
+ var verRes = await fetch('/webauthn/auth-verify', {
770
+ method: 'POST',
771
+ headers: { 'Content-Type': 'application/json' },
772
+ body: JSON.stringify(reqBody),
773
+ });
774
+ var result = await verRes.json();
775
+
776
+ if (result.success) {
777
+ // Direct Remote Control login continuation. Runs before any
778
+ // qr-login/approve or pair-mode logic so the apiKey lands in
779
+ // sessionStorage and the destination's auth gate finds it.
780
+ if (redirectToRemoteControlIfDirectLogin(result)) return;
781
+ // If phone completing a desktop QR session, approve it
782
+ var approveResponse = null;
783
+ if (qrSessionMode && qrSessionId) {
784
+ var approveRes = await fetch('/api/qr-login/approve', {
785
+ method: 'POST',
786
+ headers: { 'Content-Type': 'application/json' },
787
+ body: JSON.stringify({
788
+ sessionId: qrSessionId,
789
+ agentId: result.agentId,
790
+ apiKey: result.apiKey,
791
+ credentialLabel: result.credentialLabel,
792
+ }),
793
+ });
794
+ try { approveResponse = await approveRes.json(); } catch (e) { approveResponse = null; }
795
+ }
796
+ // CRC pair-mode: if the server returned a sanitized `next`, store
797
+ // the api_key on the phone and redirect to /pair/<CODE>. Phone is
798
+ // the actor that completes pair-complete (plan C6).
799
+ if (await followPairNextIfPresent(approveResponse, result)) return;
800
+ document.getElementById('signup-view').style.display = 'none';
801
+ document.getElementById('success-view').style.display = 'block';
802
+ // Use credentialLabel from the server, which matches the saved-
803
+ // passkey label the user sees in iOS Passwords / 1Password.
804
+ document.getElementById('welcome-name').textContent = result.credentialLabel || result.agentId || 'you';
805
+ setStatus('', '');
806
+ } else {
807
+ setStatus(result.error || 'Authentication failed', 'error');
808
+ document.getElementById('signInBtn').style.pointerEvents = 'auto';
809
+ document.getElementById('createBtn').disabled = false;
810
+ }
811
+ } catch (err) {
812
+ if (err.name === 'NotAllowedError') {
813
+ // Distinguish auto-start rejection from a user cancellation.
814
+ // When the QR auto-start hits a strict user-activation policy
815
+ // (Safari Private Browsing, future stricter modes), surface a
816
+ // clear, QR-specific instruction instead of the generic
817
+ // "Cancelled" copy. The sign-in link and primary button stay
818
+ // enabled below; the user's tap is a real gesture and reuses
819
+ // the same qrSessionId.
820
+ if (qrSessionMode && qrAutoStarting) {
821
+ setStatus('This browser blocked automatic Face ID. Tap Already have an account? Sign in. to continue.', 'error');
822
+ } else {
823
+ setStatus('Cancelled. Try again when ready.', 'error');
824
+ setTimeout(function() { clearStatus(); }, 3000);
825
+ }
826
+ } else {
827
+ setStatus('Error: ' + err.message, 'error');
828
+ }
829
+ document.getElementById('signInBtn').style.pointerEvents = 'auto';
830
+ document.getElementById('createBtn').disabled = false;
831
+ }
832
+ }
833
+
834
+ // Enter key on handle input triggers create
835
+ document.getElementById('handleInput').addEventListener('keydown', function(e) {
836
+ if (e.key === 'Enter') doCreateAccount();
837
+ });
838
+
839
+ // Initialize footer dot
840
+ updatePasskeysDot();
841
+ </script>
842
+ </body>
843
+ </html>