@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
@@ -48,7 +48,7 @@ html, body {
48
48
  .login-card {
49
49
  position: relative;
50
50
  max-width: 380px;
51
- width: 100%;
51
+ width: calc(100vw - 48px);
52
52
  text-align: center;
53
53
  }
54
54
 
@@ -147,14 +147,28 @@ html, body {
147
147
  </div>
148
148
  </div>
149
149
 
150
- <!-- Success view -->
150
+ <!-- Success view (legacy login flow) -->
151
151
  <div id="success-view" style="display:none">
152
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
153
  <div class="login-buttons">
156
- <a href="/demo/" onclick="sessionStorage.clear();" class="btn btn-primary" style="text-decoration:none;text-align:center;">Try the Demo</a>
154
+ <a href="/demo/" class="btn btn-primary" style="text-decoration:none;text-align:center;">Try the Demo</a>
155
+ </div>
156
+ <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>
157
+ </div>
158
+
159
+ <!-- Pair-approved view (CRC pair-mode, desktop-side after phone approves) -->
160
+ <div id="pair-approved-view" style="display:none">
161
+ <p style="font-size:18px;margin-bottom:12px;color:var(--text);">Approved on your phone.</p>
162
+ <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>
163
+ </div>
164
+
165
+ <!-- QR authenticator confirmation view (phone-side after approving another device) -->
166
+ <div id="qr-auth-confirm-view" style="display:none">
167
+ <p style="font-size:18px;margin:0 0 24px;color:var(--text);">Your authenticated Kaleidoscope session is available on your other device.</p>
168
+ <div class="login-buttons">
169
+ <button class="btn btn-primary" id="qrAuthPrimaryBtn" onclick="backToLoginAfterQrScan()">Back to login</button>
157
170
  </div>
171
+ <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>
158
172
  </div>
159
173
 
160
174
  <div class="login-status" id="loginStatus" style="position:absolute;left:0;right:0;margin-top:16px;text-align:center;"></div>
@@ -228,12 +242,22 @@ function isSafariDesktop() {
228
242
  }
229
243
 
230
244
  function needsCustomQR() {
231
- return !isMobileDevice() && !isSafariDesktop() && !isLocalPasskeysOn();
245
+ return !isLocalPasskeysOn();
232
246
  }
233
247
 
234
248
  // ── Local passkeys toggle (persisted in localStorage) ──
249
+ function defaultLocalPasskeysOn() {
250
+ return isMobileDevice();
251
+ }
252
+
253
+ function getLocalPasskeysPreference() {
254
+ var stored = localStorage.getItem('localPasskeys');
255
+ if (stored === 'on' || stored === 'off') return stored;
256
+ return defaultLocalPasskeysOn() ? 'on' : 'off';
257
+ }
258
+
235
259
  function isLocalPasskeysOn() {
236
- return localStorage.getItem('localPasskeys') === 'on';
260
+ return getLocalPasskeysPreference() === 'on';
237
261
  }
238
262
 
239
263
  function toggleLocalPasskeys() {
@@ -257,19 +281,221 @@ function updatePasskeysDot() {
257
281
  }
258
282
  }
259
283
 
284
+ // ── `next` continuation carrier ──
285
+ //
286
+ // Three whitelisted next shapes:
287
+ //
288
+ // PAIR_NEXT_REGEX /pair/<CODE> using the daemon alphabet
289
+ // (CODEX_PAIR_ALPHABET, length 6, L is
290
+ // included; I/O/0/1 excluded). C8 applies:
291
+ // URL fallback is mobile-only, the desktop
292
+ // must not become the pairing authority.
293
+ //
294
+ // REMOTE_CONTROL_NEXT_REGEX /codex-remote-control/<UUID>. Standard
295
+ // ?next semantics; allowed on both
296
+ // desktop and mobile (this is navigation
297
+ // continuation, not authority transfer).
298
+ //
299
+ // DEMO_NEXT_REGEX /demo. Standard post-login continuation
300
+ // from the homepage CTA.
301
+ //
302
+ // Anything else is silently dropped. `next` is NOT a general redirect
303
+ // primitive. The server validates authoritatively; this client-side
304
+ // check is defense-in-depth.
305
+ var PAIR_NEXT_REGEX = /^\/pair\/[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/;
306
+ 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;
307
+ var DEMO_NEXT_REGEX = /^\/demo$/;
308
+
309
+ function isWhitelistedNext(raw) {
310
+ return typeof raw === 'string' && (PAIR_NEXT_REGEX.test(raw) || REMOTE_CONTROL_NEXT_REGEX.test(raw) || DEMO_NEXT_REGEX.test(raw));
311
+ }
312
+
313
+ function readPairNextFromQuery() {
314
+ try {
315
+ var raw = new URLSearchParams(window.location.search).get('next');
316
+ if (!raw) return null;
317
+ return isWhitelistedNext(raw) ? raw : null;
318
+ } catch (e) { return null; }
319
+ }
320
+
321
+ function isPairNextOnDesktop() {
322
+ var next = readPairNextFromQuery();
323
+ return !!(next && PAIR_NEXT_REGEX.test(next) && !isMobileDevice());
324
+ }
325
+
326
+ // Direct Remote Control login continuation. Explicit branch ... NOT
327
+ // routed through pair-mode helpers. Called from doSignIn / doCreateAccount
328
+ // the moment /webauthn/auth-verify or /webauthn/register-verify returns
329
+ // success, BEFORE any qr-login/approve or pair-mode logic runs.
330
+ //
331
+ // If the URL was /login?next=/codex-remote-control/<UUID> AND auth-verify
332
+ // returned a real apiKey, store the credentials in sessionStorage so the
333
+ // destination page's auth gate sees them, then location.replace into the
334
+ // Remote Control surface. Returns true if it redirected; caller must
335
+ // return early.
336
+ //
337
+ // If the URL has no remote-control next, returns false (caller proceeds
338
+ // with the existing QR / pair / welcome logic).
339
+ //
340
+ // If the URL has a remote-control next BUT result.apiKey is missing,
341
+ // console.warn and return false. The caller then falls through to the
342
+ // welcome view (defense in depth from PR #805) so the user is not
343
+ // stranded on a blank /codex-remote-control page that bounces back to
344
+ // sign-in.
345
+ //
346
+ // This is deliberately separate from followPairNextIfPresent because
347
+ // pair-mode behavior (C6 desktop strip, C8 desktop-no-redirect) is
348
+ // orthogonal to standard Remote Control login continuation.
349
+ //
350
+ // Also writes a short-lived same-origin handoff cookie as a Safari-safe
351
+ // fallback: some browsers drop sessionStorage across location.replace
352
+ // under privacy/partition heuristics. Path=/ is required because Safari
353
+ // will silently reject document.cookie writes whose Path attribute is
354
+ // not the current path or an ancestor of it; /login is not an ancestor
355
+ // of /codex-remote-control, so the previous Path=/codex-remote-control
356
+ // scope was dropped on Safari. The Remote Control page reads either
357
+ // source and clears both Path scopes after consumption. See
358
+ // setHandoffCookie below.
359
+ function setHandoffCookie(name, value) {
360
+ document.cookie = name + '=' + encodeURIComponent(value)
361
+ + '; Path=/'
362
+ + '; Max-Age=60'
363
+ + '; Secure'
364
+ + '; SameSite=Strict';
365
+ }
366
+ function redirectToRemoteControlIfDirectLogin(result) {
367
+ try {
368
+ var raw = new URLSearchParams(window.location.search).get('next');
369
+ if (!raw || !REMOTE_CONTROL_NEXT_REGEX.test(raw)) return false;
370
+ if (!result || !result.apiKey) {
371
+ console.warn('redirectToRemoteControlIfDirectLogin: missing apiKey on result; falling through to welcome view. result=', result);
372
+ return false;
373
+ }
374
+ console.log('redirectToRemoteControlIfDirectLogin: storing wip_api_key + redirecting', { agentId: result.agentId, next: raw });
375
+ sessionStorage.setItem('wip_api_key', result.apiKey);
376
+ if (result.agentId) sessionStorage.setItem('wip_handle', result.agentId);
377
+ setHandoffCookie('wip_rc_api_key', result.apiKey);
378
+ if (result.agentId) setHandoffCookie('wip_rc_handle', result.agentId);
379
+ location.replace(raw);
380
+ return true;
381
+ } catch (e) {
382
+ console.warn('redirectToRemoteControlIfDirectLogin: unexpected error', e);
383
+ return false;
384
+ }
385
+ }
386
+
387
+ // Phone-side post-passkey hook. Called after successful passkey + (optional)
388
+ // qr-login/approve. Decides whether to redirect to the next URL based on
389
+ // authority rules from plan C6 + C8:
390
+ //
391
+ // - QR-scan path (approveResponse.next from the server): follow it for
392
+ // a whitelisted next. The phone scanned the desktop's QR,
393
+ // signed in, and the server returned a sanitized `next`.
394
+ //
395
+ // - URL fallback path (no approveResponse.next):
396
+ // - /codex-remote-control/<UUID>: allowed on both desktop and
397
+ // mobile. Standard navigation continuation.
398
+ // - /pair/<CODE>: mobile-only (C8). Desktop with no
399
+ // approveResponse must not become the pairing authority.
400
+ //
401
+ // REGRESSION COVERED: a desktop browser with "Local passkeys" on,
402
+ // loading /login?next=/pair/<CODE> and completing desktop WebAuthn,
403
+ // MUST NOT redirect to /pair/<CODE> or complete pairing. If a future
404
+ // change opens this gate, it violates plan constraint C8 ("Default
405
+ // authority is phone-held passkey; local desktop passkeys are
406
+ // testing-only"). The codex-remote-control next does NOT trip this
407
+ // rule because it's a navigation continuation, not authority transfer.
408
+ //
409
+ // Returns true if it redirected (caller should not run other view-switching).
410
+ async function followPairNextIfPresent(approveResponse, identity, isNewAccount) {
411
+ var next = null;
412
+ // Path 1: QR phone-side approve. Server-blessed next.
413
+ if (approveResponse && typeof approveResponse.next === 'string' && isWhitelistedNext(approveResponse.next)) {
414
+ if (DEMO_NEXT_REGEX.test(approveResponse.next)) {
415
+ return showQrAuthenticatorConfirmation(identity, isNewAccount === true);
416
+ }
417
+ next = approveResponse.next;
418
+ } else {
419
+ // Path 2: URL ?next= fallback (no approveResponse). Branch by
420
+ // shape: codex-remote-control allowed on any device, pair-mode
421
+ // restricted to mobile per C8.
422
+ var urlNext = readPairNextFromQuery();
423
+ if (urlNext) {
424
+ if (REMOTE_CONTROL_NEXT_REGEX.test(urlNext) || DEMO_NEXT_REGEX.test(urlNext)) {
425
+ next = urlNext;
426
+ } else if (PAIR_NEXT_REGEX.test(urlNext) && isMobileDevice()) {
427
+ next = urlNext;
428
+ }
429
+ // Path 3 (desktop + pair next, no approveResponse): next stays null.
430
+ }
431
+ }
432
+ if (!next) return false;
433
+ // Auth-handoff guard: if we don't have an apiKey to store, do NOT
434
+ // redirect to next. Stranding the user on /codex-remote-control/<UUID>
435
+ // without a key in sessionStorage would render a redirect-to-login
436
+ // loop or a blank page (depending on the destination's auth gate).
437
+ // Falling through here lets the caller render the existing
438
+ // welcome-name view, which at least shows the user something they
439
+ // can interact with.
440
+ if (!identity || !identity.apiKey) {
441
+ console.warn('followPairNextIfPresent: missing apiKey on identity; skipping redirect to', next);
442
+ return false;
443
+ }
444
+ sessionStorage.setItem('wip_api_key', identity.apiKey);
445
+ if (identity.agentId) sessionStorage.setItem('wip_handle', identity.agentId);
446
+ storeDemoHandoffIfNeeded(next, identity, isNewAccount === true);
447
+ location.replace(next);
448
+ return true;
449
+ }
450
+
260
451
  // ── QR Login (Chrome desktop fallback) ──
261
452
  var qrLoginPollTimer = null;
453
+ var qrLoginMode = null;
454
+
455
+ function storeDemoHandoffIfNeeded(next, identity, isNewAccount) {
456
+ if (!DEMO_NEXT_REGEX.test(next) || !identity || !identity.apiKey) return;
457
+ sessionStorage.setItem('lesa-token', identity.apiKey);
458
+ if (identity.agentId) sessionStorage.setItem('lesa-agent', identity.agentId);
459
+ if (identity.tenantId) sessionStorage.setItem('lesa-tenant', identity.tenantId);
460
+ if (isNewAccount) sessionStorage.setItem('lesa-new-account', 'true');
461
+ }
462
+
463
+ function showQrAuthenticatorConfirmation(identity, isNewAccount) {
464
+ ['signup-view', 'qr-view', 'success-view', 'pair-approved-view'].forEach(function(id) {
465
+ var el = document.getElementById(id);
466
+ if (el) el.style.display = 'none';
467
+ });
468
+ var primary = document.getElementById('qrAuthPrimaryBtn');
469
+ if (primary) primary.textContent = 'Back to login';
470
+ var closeMessage = document.getElementById('qr-auth-close-message');
471
+ if (closeMessage) closeMessage.style.display = 'none';
472
+ document.getElementById('qr-auth-confirm-view').style.display = 'block';
473
+ setStatus('', '');
474
+ return true;
475
+ }
476
+
477
+ function backToLoginAfterQrScan() {
478
+ qrSessionId = null;
479
+ qrHandle = null;
480
+ qrMode = 'register';
481
+ qrSessionMode = false;
482
+ location.replace('/login?next=/demo');
483
+ }
262
484
 
263
485
  async function startQrLogin(handle, mode) {
264
486
  var btn = document.getElementById('createBtn');
265
487
  btn.disabled = true;
266
488
  setStatus('', '');
489
+ qrLoginMode = mode || 'register';
267
490
 
268
491
  try {
492
+ var pairNext = readPairNextFromQuery();
493
+ var startBody = { handle: handle || undefined, mode: mode || 'register' };
494
+ if (pairNext) startBody.next = pairNext;
269
495
  var res = await fetch('/api/qr-login', {
270
496
  method: 'POST',
271
497
  headers: { 'Content-Type': 'application/json' },
272
- body: JSON.stringify({ handle: handle || undefined, mode: mode || 'register' }),
498
+ body: JSON.stringify(startBody),
273
499
  });
274
500
  var data = await res.json();
275
501
 
@@ -313,8 +539,39 @@ async function pollQrLogin(sessionId) {
313
539
  clearInterval(qrLoginPollTimer);
314
540
  qrLoginPollTimer = null;
315
541
  document.getElementById('qr-view').style.display = 'none';
316
- document.getElementById('success-view').style.display = 'block';
317
- document.getElementById('welcome-name').textContent = data.agentId || 'you';
542
+ // Branch by next shape. Three cases:
543
+ //
544
+ // 1. Pair-mode (URL ?next=/pair/<CODE>): server stripped apiKey
545
+ // and next from this response (plan C6). Desktop never
546
+ // redirects (C8). Show the dedicated pair-approved view.
547
+ // The phone is the actor that completes pair-complete.
548
+ //
549
+ // 2. Codex remote control continuation (data.next is a
550
+ // /codex-remote-control/<UUID>): standard post-login next.
551
+ // Desktop authenticates with the server-provided apiKey and
552
+ // redirects to next. Server returned the full login response
553
+ // (apiKey, credentialLabel, next) for this case.
554
+ //
555
+ // 3. Legacy login mode (no whitelisted next anywhere): show the
556
+ // existing welcome view.
557
+ var urlNext = readPairNextFromQuery();
558
+ if (urlNext && PAIR_NEXT_REGEX.test(urlNext)) {
559
+ document.getElementById('pair-approved-view').style.display = 'block';
560
+ } else if (data.next && (REMOTE_CONTROL_NEXT_REGEX.test(data.next) || DEMO_NEXT_REGEX.test(data.next)) && data.apiKey) {
561
+ // Standard post-login continuation: pick up the credentials
562
+ // so the destination page is authenticated, then redirect.
563
+ sessionStorage.setItem('wip_api_key', data.apiKey);
564
+ if (data.agentId) sessionStorage.setItem('wip_handle', data.agentId);
565
+ storeDemoHandoffIfNeeded(data.next, data, qrLoginMode === 'register');
566
+ location.replace(data.next);
567
+ } else {
568
+ storeDemoHandoffIfNeeded('/demo', data, qrLoginMode === 'register');
569
+ document.getElementById('success-view').style.display = 'block';
570
+ // Use credentialLabel (matches the saved-passkey label the user
571
+ // sees in iOS Passwords / 1Password). Falls back to agentId for
572
+ // back-compat with older deploys.
573
+ document.getElementById('welcome-name').textContent = data.credentialLabel || data.agentId || 'you';
574
+ }
318
575
  }
319
576
  } catch (err) {
320
577
  // Network error, keep polling
@@ -326,6 +583,7 @@ function cancelQrLogin() {
326
583
  clearInterval(qrLoginPollTimer);
327
584
  qrLoginPollTimer = null;
328
585
  }
586
+ qrLoginMode = null;
329
587
  document.getElementById('qr-view').style.display = 'none';
330
588
  document.getElementById('signup-view').style.display = 'block';
331
589
  document.getElementById('createBtn').disabled = false;
@@ -339,19 +597,49 @@ var qrHandle = urlParams.get('h');
339
597
  var qrMode = urlParams.get('m') || 'register';
340
598
  var qrSessionMode = !!qrSessionId;
341
599
 
600
+ // True only while the auto-start setTimeout is invoking
601
+ // doCreateAccount / doSignIn. Read by the NotAllowedError catches so
602
+ // they can suppress the user-facing "Cancelled. Try again when ready."
603
+ // message when the rejection was caused by a strict user-activation
604
+ // policy (Safari Private Browsing) rather than an actual user
605
+ // cancellation. The user's eventual tap on the existing button or
606
+ // link runs the same handler with a real user gesture, and the same
607
+ // qrSessionId completes /api/qr-login/approve.
608
+ var qrAutoStarting = false;
609
+
342
610
  if (qrSessionMode) {
343
611
  // Clean the URL so session ID doesn't persist
344
612
  history.replaceState(null, '', '/login');
345
613
  // Pre-fill handle if provided
346
614
  if (qrHandle) document.getElementById('handleInput').value = qrHandle;
347
- // Auto-start the right flow on phone (no extra tap needed)
348
- setTimeout(function() {
349
- if (qrMode === 'signin') {
350
- doSignIn();
351
- } else {
352
- doCreateAccount();
615
+ // Auto-start the right flow on phone (no extra tap needed).
616
+ // Restored from the pre-#784 known-good QR phone UX. Safari (normal
617
+ // mode) users never see the static login page (Face ID pops within
618
+ // 300ms). Browsers with stricter user-activation policies (Safari
619
+ // Private Browsing, sometimes Chrome on iOS) may reject the
620
+ // auto-start with NotAllowedError. The catch path below replaces
621
+ // the generic "Cancelled" status with a clear, QR-specific
622
+ // instructional message ("This browser blocked automatic Face ID.
623
+ // Tap ... to continue.") and leaves the existing controls enabled
624
+ // so the user's tap completes the flow with a real gesture.
625
+ setTimeout(async function() {
626
+ qrAutoStarting = true;
627
+ try {
628
+ if (qrMode === 'signin') {
629
+ await doSignIn();
630
+ } else {
631
+ await doCreateAccount();
632
+ }
633
+ } finally {
634
+ qrAutoStarting = false;
353
635
  }
354
636
  }, 300);
637
+ } else if (isPairNextOnDesktop()) {
638
+ // `codex-daemon link` opens /login?next=/pair/<CODE> on the desktop.
639
+ // The desktop's only authority in that flow is to display QR and wait.
640
+ setTimeout(function() {
641
+ startQrLogin('', 'signin');
642
+ }, 0);
355
643
  }
356
644
 
357
645
  // ── Create Account ──
@@ -360,7 +648,14 @@ async function doCreateAccount() {
360
648
  var btn = document.getElementById('createBtn');
361
649
  btn.disabled = true;
362
650
 
363
- // Chrome desktop without local passkeys: use custom QR code
651
+ // Pair-mode desktop URLs must always use the QR path. A local
652
+ // desktop passkey is testing-only and must not become pair authority.
653
+ if (isPairNextOnDesktop() && !qrSessionMode) {
654
+ startQrLogin(username, 'signin');
655
+ return;
656
+ }
657
+
658
+ // Local passkeys off means use WIP's QR cross-device login on any device.
364
659
  if (needsCustomQR() && !qrSessionMode) {
365
660
  startQrLogin(username);
366
661
  return;
@@ -424,19 +719,38 @@ async function doCreateAccount() {
424
719
  var result = await verRes.json();
425
720
 
426
721
  if (result.success) {
722
+ // Direct Remote Control login continuation. Runs before any
723
+ // qr-login/approve or pair-mode logic so the apiKey lands in
724
+ // sessionStorage and the destination's auth gate finds it.
725
+ if (redirectToRemoteControlIfDirectLogin(result)) return;
427
726
  // If phone completing a desktop QR session, approve it
727
+ var approveResponse = null;
428
728
  if (qrSessionMode && qrSessionId) {
429
- await fetch('/api/qr-login/approve', {
729
+ var approveRes = await fetch('/api/qr-login/approve', {
430
730
  method: 'POST',
431
731
  headers: { 'Content-Type': 'application/json' },
432
- body: JSON.stringify({ sessionId: qrSessionId, agentId: result.agentId, apiKey: result.apiKey }),
732
+ body: JSON.stringify({
733
+ sessionId: qrSessionId,
734
+ agentId: result.agentId,
735
+ apiKey: result.apiKey,
736
+ tenantId: result.tenantId,
737
+ credentialLabel: result.credentialLabel,
738
+ }),
433
739
  });
740
+ try { approveResponse = await approveRes.json(); } catch (e) { approveResponse = null; }
434
741
  }
435
742
  // Mark that user has an account (persists across sessions)
436
743
  localStorage.setItem('kscope-has-account', 'true');
744
+ // CRC pair-mode: if the server returned a sanitized `next`, store
745
+ // the api_key on the phone and redirect to /pair/<CODE>. Phone is
746
+ // the actor that completes pair-complete (plan C6).
747
+ if (await followPairNextIfPresent(approveResponse, result, true)) return;
748
+ storeDemoHandoffIfNeeded('/demo', result, true);
437
749
  document.getElementById('signup-view').style.display = 'none';
438
750
  document.getElementById('success-view').style.display = 'block';
439
- document.getElementById('welcome-name').textContent = username || result.agentId || 'you';
751
+ // Prefer the typed handle for the welcome view. credentialLabel
752
+ // is the saved-passkey label and can be a generated user id.
753
+ document.getElementById('welcome-name').textContent = username || result.credentialLabel || result.agentId || 'you';
440
754
  setStatus('', '');
441
755
  } else {
442
756
  setStatus(result.error || 'Registration failed', 'error');
@@ -444,8 +758,19 @@ async function doCreateAccount() {
444
758
  }
445
759
  } catch (err) {
446
760
  if (err.name === 'NotAllowedError') {
447
- setStatus('Cancelled. Try again when ready.', 'error');
448
- setTimeout(function() { clearStatus(); }, 3000);
761
+ // Distinguish auto-start rejection from a user cancellation.
762
+ // When the QR auto-start hits a strict user-activation policy
763
+ // (Safari Private Browsing, future stricter modes), surface a
764
+ // clear, QR-specific instruction instead of the generic
765
+ // "Cancelled" copy. The existing primary button stays enabled
766
+ // (btn.disabled = false below); the user's tap is a real
767
+ // gesture and reuses the same qrSessionId.
768
+ if (qrSessionMode && qrAutoStarting) {
769
+ setStatus('This browser blocked automatic Face ID. Tap Enter the Kaleidoscope to continue.', 'error');
770
+ } else {
771
+ setStatus('Cancelled. Try again when ready.', 'error');
772
+ setTimeout(function() { clearStatus(); }, 3000);
773
+ }
449
774
  } else {
450
775
  setStatus('Error: ' + err.message, 'error');
451
776
  }
@@ -457,7 +782,14 @@ async function doSignIn() {
457
782
  document.getElementById('signInBtn').style.pointerEvents = 'none';
458
783
  document.getElementById('createBtn').disabled = true;
459
784
 
460
- // Chrome desktop without local passkeys: use QR code for sign-in.
785
+ // Pair-mode desktop URLs must always use the QR path. A local
786
+ // desktop passkey is testing-only and must not become pair authority.
787
+ if (isPairNextOnDesktop() && !qrSessionMode) {
788
+ startQrLogin('', 'signin');
789
+ return;
790
+ }
791
+
792
+ // Local passkeys off means use WIP's QR cross-device login on any device.
461
793
  // With local passkeys on, let Chrome show native dialog (supports YubiKey).
462
794
  if (needsCustomQR() && !qrSessionMode) {
463
795
  startQrLogin('', 'signin');
@@ -507,17 +839,36 @@ async function doSignIn() {
507
839
  var result = await verRes.json();
508
840
 
509
841
  if (result.success) {
842
+ // Direct Remote Control login continuation. Runs before any
843
+ // qr-login/approve or pair-mode logic so the apiKey lands in
844
+ // sessionStorage and the destination's auth gate finds it.
845
+ if (redirectToRemoteControlIfDirectLogin(result)) return;
510
846
  // If phone completing a desktop QR session, approve it
847
+ var approveResponse = null;
511
848
  if (qrSessionMode && qrSessionId) {
512
- await fetch('/api/qr-login/approve', {
849
+ var approveRes = await fetch('/api/qr-login/approve', {
513
850
  method: 'POST',
514
851
  headers: { 'Content-Type': 'application/json' },
515
- body: JSON.stringify({ sessionId: qrSessionId, agentId: result.agentId, apiKey: result.apiKey }),
852
+ body: JSON.stringify({
853
+ sessionId: qrSessionId,
854
+ agentId: result.agentId,
855
+ apiKey: result.apiKey,
856
+ tenantId: result.tenantId,
857
+ credentialLabel: result.credentialLabel,
858
+ }),
516
859
  });
860
+ try { approveResponse = await approveRes.json(); } catch (e) { approveResponse = null; }
517
861
  }
862
+ // CRC pair-mode: if the server returned a sanitized `next`, store
863
+ // the api_key on the phone and redirect to /pair/<CODE>. Phone is
864
+ // the actor that completes pair-complete (plan C6).
865
+ if (await followPairNextIfPresent(approveResponse, result, false)) return;
866
+ storeDemoHandoffIfNeeded('/demo', result, false);
518
867
  document.getElementById('signup-view').style.display = 'none';
519
868
  document.getElementById('success-view').style.display = 'block';
520
- document.getElementById('welcome-name').textContent = result.agentId || 'you';
869
+ // Use credentialLabel from the server, which matches the saved-
870
+ // passkey label the user sees in iOS Passwords / 1Password.
871
+ document.getElementById('welcome-name').textContent = result.credentialLabel || result.agentId || 'you';
521
872
  setStatus('', '');
522
873
  } else {
523
874
  setStatus(result.error || 'Authentication failed', 'error');
@@ -526,8 +877,19 @@ async function doSignIn() {
526
877
  }
527
878
  } catch (err) {
528
879
  if (err.name === 'NotAllowedError') {
529
- setStatus('Cancelled. Try again when ready.', 'error');
530
- setTimeout(function() { clearStatus(); }, 3000);
880
+ // Distinguish auto-start rejection from a user cancellation.
881
+ // When the QR auto-start hits a strict user-activation policy
882
+ // (Safari Private Browsing, future stricter modes), surface a
883
+ // clear, QR-specific instruction instead of the generic
884
+ // "Cancelled" copy. The sign-in link and primary button stay
885
+ // enabled below; the user's tap is a real gesture and reuses
886
+ // the same qrSessionId.
887
+ if (qrSessionMode && qrAutoStarting) {
888
+ setStatus('This browser blocked automatic Face ID. Tap Already have an account? Sign in. to continue.', 'error');
889
+ } else {
890
+ setStatus('Cancelled. Try again when ready.', 'error');
891
+ setTimeout(function() { clearStatus(); }, 3000);
892
+ }
531
893
  } else {
532
894
  setStatus('Error: ' + err.message, 'error');
533
895
  }