@wipcomputer/wip-ldm-os 0.4.85-alpha.3 → 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.
@@ -147,7 +147,7 @@ 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
153
  <p style="color:var(--text-muted);font-size:15px;margin-bottom:8px;">Your passkey has been saved to your phone.</p>
@@ -157,6 +157,12 @@ html, body {
157
157
  </div>
158
158
  </div>
159
159
 
160
+ <!-- Pair-approved view (CRC pair-mode, desktop-side after phone approves) -->
161
+ <div id="pair-approved-view" style="display:none">
162
+ <p style="font-size:18px;margin-bottom:12px;color:var(--text);">Approved on your phone.</p>
163
+ <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>
164
+ </div>
165
+
160
166
  <div class="login-status" id="loginStatus" style="position:absolute;left:0;right:0;margin-top:16px;text-align:center;"></div>
161
167
  </div>
162
168
  </div>
@@ -257,6 +263,165 @@ function updatePasskeysDot() {
257
263
  }
258
264
  }
259
265
 
266
+ // ── `next` continuation carrier ──
267
+ //
268
+ // Two whitelisted next shapes:
269
+ //
270
+ // PAIR_NEXT_REGEX /pair/<CODE> using the daemon alphabet
271
+ // (CODEX_PAIR_ALPHABET, length 6, L is
272
+ // included; I/O/0/1 excluded). C8 applies:
273
+ // URL fallback is mobile-only, the desktop
274
+ // must not become the pairing authority.
275
+ //
276
+ // REMOTE_CONTROL_NEXT_REGEX /codex-remote-control/<UUID>. Standard
277
+ // ?next semantics; allowed on both
278
+ // desktop and mobile (this is navigation
279
+ // continuation, not authority transfer).
280
+ //
281
+ // Anything else is silently dropped. `next` is NOT a general redirect
282
+ // primitive. The server validates authoritatively; this client-side
283
+ // check is defense-in-depth.
284
+ var PAIR_NEXT_REGEX = /^\/pair\/[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/;
285
+ 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;
286
+
287
+ function isWhitelistedNext(raw) {
288
+ return typeof raw === 'string' && (PAIR_NEXT_REGEX.test(raw) || REMOTE_CONTROL_NEXT_REGEX.test(raw));
289
+ }
290
+
291
+ function readPairNextFromQuery() {
292
+ try {
293
+ var raw = new URLSearchParams(window.location.search).get('next');
294
+ if (!raw) return null;
295
+ return isWhitelistedNext(raw) ? raw : null;
296
+ } catch (e) { return null; }
297
+ }
298
+
299
+ function isPairNextOnDesktop() {
300
+ var next = readPairNextFromQuery();
301
+ return !!(next && PAIR_NEXT_REGEX.test(next) && !isMobileDevice());
302
+ }
303
+
304
+ // Direct Remote Control login continuation. Explicit branch ... NOT
305
+ // routed through pair-mode helpers. Called from doSignIn / doCreateAccount
306
+ // the moment /webauthn/auth-verify or /webauthn/register-verify returns
307
+ // success, BEFORE any qr-login/approve or pair-mode logic runs.
308
+ //
309
+ // If the URL was /login?next=/codex-remote-control/<UUID> AND auth-verify
310
+ // returned a real apiKey, store the credentials in sessionStorage so the
311
+ // destination page's auth gate sees them, then location.replace into the
312
+ // Remote Control surface. Returns true if it redirected; caller must
313
+ // return early.
314
+ //
315
+ // If the URL has no remote-control next, returns false (caller proceeds
316
+ // with the existing QR / pair / welcome logic).
317
+ //
318
+ // If the URL has a remote-control next BUT result.apiKey is missing,
319
+ // console.warn and return false. The caller then falls through to the
320
+ // welcome view (defense in depth from PR #805) so the user is not
321
+ // stranded on a blank /codex-remote-control page that bounces back to
322
+ // sign-in.
323
+ //
324
+ // This is deliberately separate from followPairNextIfPresent because
325
+ // pair-mode behavior (C6 desktop strip, C8 desktop-no-redirect) is
326
+ // orthogonal to standard Remote Control login continuation.
327
+ //
328
+ // Also writes a short-lived same-origin handoff cookie as a Safari-safe
329
+ // fallback: some browsers drop sessionStorage across location.replace
330
+ // under privacy/partition heuristics. Path=/ is required because Safari
331
+ // will silently reject document.cookie writes whose Path attribute is
332
+ // not the current path or an ancestor of it; /login is not an ancestor
333
+ // of /codex-remote-control, so the previous Path=/codex-remote-control
334
+ // scope was dropped on Safari. The Remote Control page reads either
335
+ // source and clears both Path scopes after consumption. See
336
+ // setHandoffCookie below.
337
+ function setHandoffCookie(name, value) {
338
+ document.cookie = name + '=' + encodeURIComponent(value)
339
+ + '; Path=/'
340
+ + '; Max-Age=60'
341
+ + '; Secure'
342
+ + '; SameSite=Strict';
343
+ }
344
+ function redirectToRemoteControlIfDirectLogin(result) {
345
+ try {
346
+ var raw = new URLSearchParams(window.location.search).get('next');
347
+ if (!raw || !REMOTE_CONTROL_NEXT_REGEX.test(raw)) return false;
348
+ if (!result || !result.apiKey) {
349
+ console.warn('redirectToRemoteControlIfDirectLogin: missing apiKey on result; falling through to welcome view. result=', result);
350
+ return false;
351
+ }
352
+ console.log('redirectToRemoteControlIfDirectLogin: storing wip_api_key + redirecting', { agentId: result.agentId, next: raw });
353
+ sessionStorage.setItem('wip_api_key', result.apiKey);
354
+ if (result.agentId) sessionStorage.setItem('wip_handle', result.agentId);
355
+ setHandoffCookie('wip_rc_api_key', result.apiKey);
356
+ if (result.agentId) setHandoffCookie('wip_rc_handle', result.agentId);
357
+ location.replace(raw);
358
+ return true;
359
+ } catch (e) {
360
+ console.warn('redirectToRemoteControlIfDirectLogin: unexpected error', e);
361
+ return false;
362
+ }
363
+ }
364
+
365
+ // Phone-side post-passkey hook. Called after successful passkey + (optional)
366
+ // qr-login/approve. Decides whether to redirect to the next URL based on
367
+ // authority rules from plan C6 + C8:
368
+ //
369
+ // - QR-scan path (approveResponse.next from the server): follow it for
370
+ // either whitelisted shape. The phone scanned the desktop's QR,
371
+ // signed in, and the server returned a sanitized `next`.
372
+ //
373
+ // - URL fallback path (no approveResponse.next):
374
+ // - /codex-remote-control/<UUID>: allowed on both desktop and
375
+ // mobile. Standard navigation continuation.
376
+ // - /pair/<CODE>: mobile-only (C8). Desktop with no
377
+ // approveResponse must not become the pairing authority.
378
+ //
379
+ // REGRESSION COVERED: a desktop browser with "Local passkeys" on,
380
+ // loading /login?next=/pair/<CODE> and completing desktop WebAuthn,
381
+ // MUST NOT redirect to /pair/<CODE> or complete pairing. If a future
382
+ // change opens this gate, it violates plan constraint C8 ("Default
383
+ // authority is phone-held passkey; local desktop passkeys are
384
+ // testing-only"). The codex-remote-control next does NOT trip this
385
+ // rule because it's a navigation continuation, not authority transfer.
386
+ //
387
+ // Returns true if it redirected (caller should not run other view-switching).
388
+ async function followPairNextIfPresent(approveResponse, identity) {
389
+ var next = null;
390
+ // Path 1: QR phone-side approve. Server-blessed next, either shape.
391
+ if (approveResponse && typeof approveResponse.next === 'string' && isWhitelistedNext(approveResponse.next)) {
392
+ next = approveResponse.next;
393
+ } else {
394
+ // Path 2: URL ?next= fallback (no approveResponse). Branch by
395
+ // shape: codex-remote-control allowed on any device, pair-mode
396
+ // restricted to mobile per C8.
397
+ var urlNext = readPairNextFromQuery();
398
+ if (urlNext) {
399
+ if (REMOTE_CONTROL_NEXT_REGEX.test(urlNext)) {
400
+ next = urlNext;
401
+ } else if (PAIR_NEXT_REGEX.test(urlNext) && isMobileDevice()) {
402
+ next = urlNext;
403
+ }
404
+ // Path 3 (desktop + pair next, no approveResponse): next stays null.
405
+ }
406
+ }
407
+ if (!next) return false;
408
+ // Auth-handoff guard: if we don't have an apiKey to store, do NOT
409
+ // redirect to next. Stranding the user on /codex-remote-control/<UUID>
410
+ // without a key in sessionStorage would render a redirect-to-login
411
+ // loop or a blank page (depending on the destination's auth gate).
412
+ // Falling through here lets the caller render the existing
413
+ // welcome-name view, which at least shows the user something they
414
+ // can interact with.
415
+ if (!identity || !identity.apiKey) {
416
+ console.warn('followPairNextIfPresent: missing apiKey on identity; skipping redirect to', next);
417
+ return false;
418
+ }
419
+ sessionStorage.setItem('wip_api_key', identity.apiKey);
420
+ if (identity.agentId) sessionStorage.setItem('wip_handle', identity.agentId);
421
+ location.replace(next);
422
+ return true;
423
+ }
424
+
260
425
  // ── QR Login (Chrome desktop fallback) ──
261
426
  var qrLoginPollTimer = null;
262
427
 
@@ -266,10 +431,13 @@ async function startQrLogin(handle, mode) {
266
431
  setStatus('', '');
267
432
 
268
433
  try {
434
+ var pairNext = readPairNextFromQuery();
435
+ var startBody = { handle: handle || undefined, mode: mode || 'register' };
436
+ if (pairNext) startBody.next = pairNext;
269
437
  var res = await fetch('/api/qr-login', {
270
438
  method: 'POST',
271
439
  headers: { 'Content-Type': 'application/json' },
272
- body: JSON.stringify({ handle: handle || undefined, mode: mode || 'register' }),
440
+ body: JSON.stringify(startBody),
273
441
  });
274
442
  var data = await res.json();
275
443
 
@@ -313,8 +481,37 @@ async function pollQrLogin(sessionId) {
313
481
  clearInterval(qrLoginPollTimer);
314
482
  qrLoginPollTimer = null;
315
483
  document.getElementById('qr-view').style.display = 'none';
316
- document.getElementById('success-view').style.display = 'block';
317
- document.getElementById('welcome-name').textContent = data.agentId || 'you';
484
+ // Branch by next shape. Three cases:
485
+ //
486
+ // 1. Pair-mode (URL ?next=/pair/<CODE>): server stripped apiKey
487
+ // and next from this response (plan C6). Desktop never
488
+ // redirects (C8). Show the dedicated pair-approved view.
489
+ // The phone is the actor that completes pair-complete.
490
+ //
491
+ // 2. Codex remote control continuation (data.next is a
492
+ // /codex-remote-control/<UUID>): standard post-login next.
493
+ // Desktop authenticates with the server-provided apiKey and
494
+ // redirects to next. Server returned the full login response
495
+ // (apiKey, credentialLabel, next) for this case.
496
+ //
497
+ // 3. Legacy login mode (no whitelisted next anywhere): show the
498
+ // existing welcome view.
499
+ var urlNext = readPairNextFromQuery();
500
+ if (urlNext && PAIR_NEXT_REGEX.test(urlNext)) {
501
+ document.getElementById('pair-approved-view').style.display = 'block';
502
+ } else if (data.next && REMOTE_CONTROL_NEXT_REGEX.test(data.next) && data.apiKey) {
503
+ // Codex remote control: pick up the credentials so the
504
+ // destination page is authenticated, then redirect.
505
+ sessionStorage.setItem('wip_api_key', data.apiKey);
506
+ if (data.agentId) sessionStorage.setItem('wip_handle', data.agentId);
507
+ location.replace(data.next);
508
+ } else {
509
+ document.getElementById('success-view').style.display = 'block';
510
+ // Use credentialLabel (matches the saved-passkey label the user
511
+ // sees in iOS Passwords / 1Password). Falls back to agentId for
512
+ // back-compat with older deploys.
513
+ document.getElementById('welcome-name').textContent = data.credentialLabel || data.agentId || 'you';
514
+ }
318
515
  }
319
516
  } catch (err) {
320
517
  // Network error, keep polling
@@ -339,19 +536,49 @@ var qrHandle = urlParams.get('h');
339
536
  var qrMode = urlParams.get('m') || 'register';
340
537
  var qrSessionMode = !!qrSessionId;
341
538
 
539
+ // True only while the auto-start setTimeout is invoking
540
+ // doCreateAccount / doSignIn. Read by the NotAllowedError catches so
541
+ // they can suppress the user-facing "Cancelled. Try again when ready."
542
+ // message when the rejection was caused by a strict user-activation
543
+ // policy (Safari Private Browsing) rather than an actual user
544
+ // cancellation. The user's eventual tap on the existing button or
545
+ // link runs the same handler with a real user gesture, and the same
546
+ // qrSessionId completes /api/qr-login/approve.
547
+ var qrAutoStarting = false;
548
+
342
549
  if (qrSessionMode) {
343
550
  // Clean the URL so session ID doesn't persist
344
551
  history.replaceState(null, '', '/login');
345
552
  // Pre-fill handle if provided
346
553
  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();
554
+ // Auto-start the right flow on phone (no extra tap needed).
555
+ // Restored from the pre-#784 known-good QR phone UX. Safari (normal
556
+ // mode) users never see the static login page (Face ID pops within
557
+ // 300ms). Browsers with stricter user-activation policies (Safari
558
+ // Private Browsing, sometimes Chrome on iOS) may reject the
559
+ // auto-start with NotAllowedError. The catch path below replaces
560
+ // the generic "Cancelled" status with a clear, QR-specific
561
+ // instructional message ("This browser blocked automatic Face ID.
562
+ // Tap ... to continue.") and leaves the existing controls enabled
563
+ // so the user's tap completes the flow with a real gesture.
564
+ setTimeout(async function() {
565
+ qrAutoStarting = true;
566
+ try {
567
+ if (qrMode === 'signin') {
568
+ await doSignIn();
569
+ } else {
570
+ await doCreateAccount();
571
+ }
572
+ } finally {
573
+ qrAutoStarting = false;
353
574
  }
354
575
  }, 300);
576
+ } else if (isPairNextOnDesktop()) {
577
+ // `codex-daemon link` opens /login?next=/pair/<CODE> on the desktop.
578
+ // The desktop's only authority in that flow is to display QR and wait.
579
+ setTimeout(function() {
580
+ startQrLogin('', 'signin');
581
+ }, 0);
355
582
  }
356
583
 
357
584
  // ── Create Account ──
@@ -360,6 +587,13 @@ async function doCreateAccount() {
360
587
  var btn = document.getElementById('createBtn');
361
588
  btn.disabled = true;
362
589
 
590
+ // Pair-mode desktop URLs must always use the QR path. A local
591
+ // desktop passkey is testing-only and must not become pair authority.
592
+ if (isPairNextOnDesktop() && !qrSessionMode) {
593
+ startQrLogin(username, 'signin');
594
+ return;
595
+ }
596
+
363
597
  // Chrome desktop without local passkeys: use custom QR code
364
598
  if (needsCustomQR() && !qrSessionMode) {
365
599
  startQrLogin(username);
@@ -424,19 +658,37 @@ async function doCreateAccount() {
424
658
  var result = await verRes.json();
425
659
 
426
660
  if (result.success) {
661
+ // Direct Remote Control login continuation. Runs before any
662
+ // qr-login/approve or pair-mode logic so the apiKey lands in
663
+ // sessionStorage and the destination's auth gate finds it.
664
+ if (redirectToRemoteControlIfDirectLogin(result)) return;
427
665
  // If phone completing a desktop QR session, approve it
666
+ var approveResponse = null;
428
667
  if (qrSessionMode && qrSessionId) {
429
- await fetch('/api/qr-login/approve', {
668
+ var approveRes = await fetch('/api/qr-login/approve', {
430
669
  method: 'POST',
431
670
  headers: { 'Content-Type': 'application/json' },
432
- body: JSON.stringify({ sessionId: qrSessionId, agentId: result.agentId, apiKey: result.apiKey }),
671
+ body: JSON.stringify({
672
+ sessionId: qrSessionId,
673
+ agentId: result.agentId,
674
+ apiKey: result.apiKey,
675
+ credentialLabel: result.credentialLabel,
676
+ }),
433
677
  });
678
+ try { approveResponse = await approveRes.json(); } catch (e) { approveResponse = null; }
434
679
  }
435
680
  // Mark that user has an account (persists across sessions)
436
681
  localStorage.setItem('kscope-has-account', 'true');
682
+ // CRC pair-mode: if the server returned a sanitized `next`, store
683
+ // the api_key on the phone and redirect to /pair/<CODE>. Phone is
684
+ // the actor that completes pair-complete (plan C6).
685
+ if (await followPairNextIfPresent(approveResponse, result)) return;
437
686
  document.getElementById('signup-view').style.display = 'none';
438
687
  document.getElementById('success-view').style.display = 'block';
439
- document.getElementById('welcome-name').textContent = username || result.agentId || 'you';
688
+ // Use credentialLabel from the server, which matches the userName
689
+ // saved by iOS Passwords / 1Password. Falls back to the typed
690
+ // username, then agentId, then "you".
691
+ document.getElementById('welcome-name').textContent = result.credentialLabel || username || result.agentId || 'you';
440
692
  setStatus('', '');
441
693
  } else {
442
694
  setStatus(result.error || 'Registration failed', 'error');
@@ -444,8 +696,19 @@ async function doCreateAccount() {
444
696
  }
445
697
  } catch (err) {
446
698
  if (err.name === 'NotAllowedError') {
447
- setStatus('Cancelled. Try again when ready.', 'error');
448
- setTimeout(function() { clearStatus(); }, 3000);
699
+ // Distinguish auto-start rejection from a user cancellation.
700
+ // When the QR auto-start hits a strict user-activation policy
701
+ // (Safari Private Browsing, future stricter modes), surface a
702
+ // clear, QR-specific instruction instead of the generic
703
+ // "Cancelled" copy. The existing primary button stays enabled
704
+ // (btn.disabled = false below); the user's tap is a real
705
+ // gesture and reuses the same qrSessionId.
706
+ if (qrSessionMode && qrAutoStarting) {
707
+ setStatus('This browser blocked automatic Face ID. Tap Enter the Kaleidoscope to continue.', 'error');
708
+ } else {
709
+ setStatus('Cancelled. Try again when ready.', 'error');
710
+ setTimeout(function() { clearStatus(); }, 3000);
711
+ }
449
712
  } else {
450
713
  setStatus('Error: ' + err.message, 'error');
451
714
  }
@@ -457,6 +720,13 @@ async function doSignIn() {
457
720
  document.getElementById('signInBtn').style.pointerEvents = 'none';
458
721
  document.getElementById('createBtn').disabled = true;
459
722
 
723
+ // Pair-mode desktop URLs must always use the QR path. A local
724
+ // desktop passkey is testing-only and must not become pair authority.
725
+ if (isPairNextOnDesktop() && !qrSessionMode) {
726
+ startQrLogin('', 'signin');
727
+ return;
728
+ }
729
+
460
730
  // Chrome desktop without local passkeys: use QR code for sign-in.
461
731
  // With local passkeys on, let Chrome show native dialog (supports YubiKey).
462
732
  if (needsCustomQR() && !qrSessionMode) {
@@ -507,17 +777,34 @@ async function doSignIn() {
507
777
  var result = await verRes.json();
508
778
 
509
779
  if (result.success) {
780
+ // Direct Remote Control login continuation. Runs before any
781
+ // qr-login/approve or pair-mode logic so the apiKey lands in
782
+ // sessionStorage and the destination's auth gate finds it.
783
+ if (redirectToRemoteControlIfDirectLogin(result)) return;
510
784
  // If phone completing a desktop QR session, approve it
785
+ var approveResponse = null;
511
786
  if (qrSessionMode && qrSessionId) {
512
- await fetch('/api/qr-login/approve', {
787
+ var approveRes = await fetch('/api/qr-login/approve', {
513
788
  method: 'POST',
514
789
  headers: { 'Content-Type': 'application/json' },
515
- body: JSON.stringify({ sessionId: qrSessionId, agentId: result.agentId, apiKey: result.apiKey }),
790
+ body: JSON.stringify({
791
+ sessionId: qrSessionId,
792
+ agentId: result.agentId,
793
+ apiKey: result.apiKey,
794
+ credentialLabel: result.credentialLabel,
795
+ }),
516
796
  });
797
+ try { approveResponse = await approveRes.json(); } catch (e) { approveResponse = null; }
517
798
  }
799
+ // CRC pair-mode: if the server returned a sanitized `next`, store
800
+ // the api_key on the phone and redirect to /pair/<CODE>. Phone is
801
+ // the actor that completes pair-complete (plan C6).
802
+ if (await followPairNextIfPresent(approveResponse, result)) return;
518
803
  document.getElementById('signup-view').style.display = 'none';
519
804
  document.getElementById('success-view').style.display = 'block';
520
- document.getElementById('welcome-name').textContent = result.agentId || 'you';
805
+ // Use credentialLabel from the server, which matches the saved-
806
+ // passkey label the user sees in iOS Passwords / 1Password.
807
+ document.getElementById('welcome-name').textContent = result.credentialLabel || result.agentId || 'you';
521
808
  setStatus('', '');
522
809
  } else {
523
810
  setStatus(result.error || 'Authentication failed', 'error');
@@ -526,8 +813,19 @@ async function doSignIn() {
526
813
  }
527
814
  } catch (err) {
528
815
  if (err.name === 'NotAllowedError') {
529
- setStatus('Cancelled. Try again when ready.', 'error');
530
- setTimeout(function() { clearStatus(); }, 3000);
816
+ // Distinguish auto-start rejection from a user cancellation.
817
+ // When the QR auto-start hits a strict user-activation policy
818
+ // (Safari Private Browsing, future stricter modes), surface a
819
+ // clear, QR-specific instruction instead of the generic
820
+ // "Cancelled" copy. The sign-in link and primary button stay
821
+ // enabled below; the user's tap is a real gesture and reuses
822
+ // the same qrSessionId.
823
+ if (qrSessionMode && qrAutoStarting) {
824
+ setStatus('This browser blocked automatic Face ID. Tap Already have an account? Sign in. to continue.', 'error');
825
+ } else {
826
+ setStatus('Cancelled. Try again when ready.', 'error');
827
+ setTimeout(function() { clearStatus(); }, 3000);
828
+ }
531
829
  } else {
532
830
  setStatus('Error: ' + err.message, 'error');
533
831
  }