@wipcomputer/wip-ldm-os 0.4.85-alpha.8 → 0.4.86

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 (57) hide show
  1. package/README.md +22 -2
  2. package/SKILL.md +137 -15
  3. package/bin/ldm.js +608 -75
  4. package/lib/deploy.mjs +90 -12
  5. package/lib/registry-migrations.mjs +296 -0
  6. package/package.json +21 -3
  7. package/scripts/test-boot-dir-truth.mjs +158 -0
  8. package/scripts/test-boot-hook-registration.mjs +136 -0
  9. package/scripts/test-boot-payload-trim.mjs +200 -0
  10. package/scripts/test-crc-agentid-tenant-boundary.mjs +2 -2
  11. package/scripts/test-crc-e2ee-key-persistence.mjs +150 -0
  12. package/scripts/test-crc-e2ee-session-route.mjs +9 -2
  13. package/scripts/test-crc-pair-login-flow.mjs +76 -1
  14. package/scripts/test-crc-pair-relink-audit-and-rotation.mjs +164 -0
  15. package/scripts/test-crc-websocket-abuse-limits.mjs +128 -0
  16. package/scripts/test-deploy-hook-ownership.mjs +160 -0
  17. package/scripts/test-doctor-hook-dedupe.mjs +188 -0
  18. package/scripts/test-install-prompt-policy.mjs +84 -0
  19. package/scripts/test-installer-skill-dry-run-destinations.mjs +100 -0
  20. package/scripts/test-installer-target-self-update.mjs +131 -0
  21. package/scripts/test-kaleidoscope-onboarding-copy.mjs +170 -0
  22. package/scripts/test-kaleidoscope-public-stats-baseline.mjs +129 -0
  23. package/scripts/test-kaleidoscope-qr-authenticator-confirmation.mjs +89 -0
  24. package/scripts/test-ldm-status-concurrency.mjs +118 -0
  25. package/scripts/test-ldm-status-timeout.mjs +96 -0
  26. package/scripts/test-legacy-npm-sources-migration.mjs +460 -0
  27. package/scripts/test-readme-install-prompt.mjs +66 -0
  28. package/shared/boot/boot-config.json +18 -8
  29. package/shared/docs/dev-guide-wipcomputerinc.md.tmpl +5 -4
  30. package/shared/rules/security.md +4 -0
  31. package/shared/templates/install-prompt.md +20 -2
  32. package/src/boot/README.md +24 -1
  33. package/src/boot/boot-config.json +18 -8
  34. package/src/boot/boot-hook.mjs +118 -28
  35. package/src/boot/installer.mjs +33 -10
  36. package/src/hosted-mcp/.env.example +4 -0
  37. package/src/hosted-mcp/README.md +37 -0
  38. package/src/hosted-mcp/app/footer.js +2 -2
  39. package/src/hosted-mcp/app/kaleidoscope-login.html +489 -42
  40. package/src/hosted-mcp/app/pair.html +21 -3
  41. package/src/hosted-mcp/app/wip-logo.png +0 -0
  42. package/src/hosted-mcp/codex-relay-e2ee-registry.mjs +208 -0
  43. package/src/hosted-mcp/codex-relay-ws-abuse-limits.mjs +140 -0
  44. package/src/hosted-mcp/demo/footer.js +2 -2
  45. package/src/hosted-mcp/demo/index.html +166 -44
  46. package/src/hosted-mcp/demo/login.html +87 -23
  47. package/src/hosted-mcp/demo/privacy.html +4 -214
  48. package/src/hosted-mcp/demo/tos.html +4 -189
  49. package/src/hosted-mcp/deploy.sh +2 -0
  50. package/src/hosted-mcp/docs/self-host.md +268 -0
  51. package/src/hosted-mcp/legal/internet-services/kaleidoscope/index.html +257 -0
  52. package/src/hosted-mcp/legal/internet-services/terms/site.html +224 -168
  53. package/src/hosted-mcp/legal/legal-footer.js +75 -0
  54. package/src/hosted-mcp/legal/legal.css +166 -0
  55. package/src/hosted-mcp/legal/privacy/en-ww/index.html +4 -221
  56. package/src/hosted-mcp/legal/privacy/index.html +253 -0
  57. package/src/hosted-mcp/server.mjs +920 -74
@@ -18,7 +18,7 @@
18
18
  "@type": "Offer",
19
19
  "price": "0",
20
20
  "priceCurrency": "USD",
21
- "description": "Demo with $5.00 simulated wallet"
21
+ "description": "Demo with $10.00 simulated wallet"
22
22
  },
23
23
  "author": {
24
24
  "@type": "Organization",
@@ -70,6 +70,10 @@ html, body {
70
70
  -webkit-font-smoothing: antialiased;
71
71
  }
72
72
 
73
+ body.chat-active {
74
+ background: #FFFFFF;
75
+ }
76
+
73
77
  @media (min-width: 768px) {
74
78
  html, body { overflow: hidden; }
75
79
  }
@@ -200,6 +204,7 @@ html, body {
200
204
  bottom: 0;
201
205
  max-width: 600px;
202
206
  margin: 0 auto;
207
+ background: #FFFFFF;
203
208
  }
204
209
 
205
210
  .chat-header {
@@ -210,7 +215,7 @@ html, body {
210
215
  text-align: center;
211
216
  border-bottom: 1px solid rgba(0, 0, 0, 0.06);
212
217
  flex-shrink: 0;
213
- background: rgba(255, 253, 245, 0.8);
218
+ background: rgba(255, 255, 255, 0.8);
214
219
  -webkit-backdrop-filter: saturate(180%) blur(20px);
215
220
  backdrop-filter: saturate(180%) blur(20px);
216
221
  }
@@ -287,6 +292,22 @@ html, body {
287
292
  margin-top: 6px;
288
293
  }
289
294
 
295
+ .bubble a,
296
+ .bubble .chat-link-button {
297
+ color: var(--accent);
298
+ font: inherit;
299
+ text-decoration: underline;
300
+ text-underline-offset: 2px;
301
+ }
302
+
303
+ .bubble .chat-link-button {
304
+ display: inline;
305
+ padding: 0;
306
+ border: 0;
307
+ background: transparent;
308
+ cursor: pointer;
309
+ }
310
+
290
311
  /* Camera preview circle */
291
312
  .camera-container {
292
313
  display: flex;
@@ -477,7 +498,7 @@ html, body {
477
498
  padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
478
499
  border-top: 1px solid var(--input-border);
479
500
  flex-shrink: 0;
480
- background: var(--bg);
501
+ background: #FFFFFF;
481
502
  }
482
503
 
483
504
  .chat-input-row {
@@ -574,7 +595,7 @@ html, body {
574
595
  <!-- ── CHAT PAGE ── -->
575
596
  <div class="chat-page" id="chatPage">
576
597
  <div class="chat-header" style="position:relative;">
577
- <a id="kscopeIcon" onclick="sessionStorage.clear();location.reload();" style="cursor:pointer;position:absolute;left:16px;top:50%;transform:translateY(-50%);display:flex;align-items:center;"></a>
598
+ <button id="kscopeIcon" type="button" onclick="logoutAndSignBackIn()" aria-label="Log out and sign back in" style="cursor:pointer;position:absolute;left:16px;top:50%;transform:translateY(-50%);display:flex;align-items:center;border:0;background:transparent;padding:0;"></button>
578
599
  <div class="chat-header-name">Kaleidoscope</div>
579
600
  <div class="chat-header-sub"></div>
580
601
  </div>
@@ -593,6 +614,20 @@ html, body {
593
614
  <canvas id="photoCanvas" style="display:none;"></canvas>
594
615
 
595
616
  <script>
617
+ // Direct /demo entry is not the public start path. The login handoff writes
618
+ // lesa-token before replacing into /demo, so authenticated continuations pass.
619
+ (function gateDirectDemoEntry() {
620
+ var hasDemoToken = false;
621
+ try {
622
+ hasDemoToken = !!sessionStorage.getItem('lesa-token');
623
+ } catch (err) {
624
+ hasDemoToken = false;
625
+ }
626
+ if (!hasDemoToken) {
627
+ location.replace('/login?next=/demo');
628
+ }
629
+ })();
630
+
596
631
  // ── WebAuthn Helpers ──
597
632
 
598
633
  function b64urlToBytes(b64url) {
@@ -687,6 +722,7 @@ async function doCreateAccount() {
687
722
  if (result.success) {
688
723
  sessionStorage.setItem('lesa-token', result.apiKey);
689
724
  sessionStorage.setItem('lesa-agent', result.agentId);
725
+ if (result.tenantId) sessionStorage.setItem('lesa-tenant', result.tenantId);
690
726
  sessionStorage.setItem('lesa-new-account', 'true');
691
727
  setLoginStatus('Account created.', 'success');
692
728
  setTimeout(function() { showChat(); }, 400);
@@ -757,6 +793,7 @@ async function doSignIn() {
757
793
  if (result.success) {
758
794
  sessionStorage.setItem('lesa-token', result.apiKey);
759
795
  sessionStorage.setItem('lesa-agent', result.agentId);
796
+ if (result.tenantId) sessionStorage.setItem('lesa-tenant', result.tenantId);
760
797
  setLoginStatus('Signed in.', 'success');
761
798
  setTimeout(function() { showChat(); }, 400);
762
799
  } else {
@@ -867,7 +904,7 @@ function showAuthButton() {
867
904
  btn.innerHTML = '<span style="filter:brightness(0) invert(1);">🫆</span> Authorize';
868
905
  btn.onclick = function() {
869
906
  container.remove();
870
- addMessage('Authorized', 'user');
907
+ addMessage('Authorizing', 'user');
871
908
  doFaceIDAuth();
872
909
  };
873
910
  container.appendChild(btn);
@@ -878,17 +915,52 @@ function showAuthButton() {
878
915
 
879
916
  // ── Settings ──
880
917
 
881
- var WALLET_BALANCE = "$5.00";
918
+ var WALLET_BALANCE = "$10.00";
882
919
  var IMAGE_COST = "$0.01";
883
- var IMAGE_BALANCE_AFTER = "$4.99";
920
+ var IMAGE_BALANCE_AFTER = "$9.99";
884
921
  var IMAGE_API_NAME = "Grok Imagine";
885
922
  var IMAGE_API_PROVIDER = "xAI";
923
+ var IS_RETURNING_USER_FLOW = false;
924
+
925
+ async function getWalletSummary() {
926
+ var walletBalance = WALLET_BALANCE;
927
+ var walletCost = IMAGE_COST;
928
+ try {
929
+ var token = sessionStorage.getItem('lesa-token');
930
+ if (!token) return { balance: walletBalance, cost: walletCost };
931
+ var wRes = await fetch('/demo/api/wallet', { headers: { 'Authorization': 'Bearer ' + token } });
932
+ var wData = await wRes.json();
933
+ if (wData.balance) walletBalance = wData.balance;
934
+ if (wData.cost) walletCost = wData.cost;
935
+ } catch {}
936
+ return { balance: walletBalance, cost: walletCost };
937
+ }
938
+
939
+ function currentAccountLabel() {
940
+ return sessionStorage.getItem('lesa-agent') || 'this account';
941
+ }
942
+
943
+ function canonicalAccountId(value) {
944
+ if (typeof value !== 'string') return null;
945
+ var trimmed = value.trim();
946
+ if (!trimmed) return null;
947
+ return trimmed.indexOf('acct:') === 0 ? trimmed.slice(5) : trimmed;
948
+ }
949
+
950
+ function isSameAccountAuthorization(result, activeToken, activeTenant) {
951
+ var sessionAccount = canonicalAccountId(activeTenant);
952
+ var resultAccount = canonicalAccountId(result && result.tenantId);
953
+ if (sessionAccount && resultAccount) {
954
+ return sessionAccount === resultAccount;
955
+ }
956
+ return !!(activeToken && result && result.apiKey === activeToken);
957
+ }
886
958
 
887
959
  // ── Image Prompts ──
888
960
 
889
- var PROMPT_PHOTO = "kaleidoscope pattern made from mirrored and reflected colors and shapes derived from a photograph, shot on expired 35mm film with heavy grain and light leaks, warm analog color bleed, faded and sun-bleached, symmetrical radial reflections but organic and imperfect, no face visible just abstracted colors and textures, washed out like a Boards of Canada album cover, square format, lo-fi analog photography aesthetic, slight overexposure, nostalgic and haunting, no digital sharpness, no CGI, no illustration, no fingers, no text";
961
+ var PROMPT_PHOTO = "Create an abstract kaleidoscope image from the colors, light, and shapes in the user's photo. Use mirrored radial symmetry, analog film grain, warm color bleed, soft exposure, organic imperfections, and no text.";
890
962
 
891
- var PROMPT_NO_PHOTO = "abstract kaleidoscope pattern viewed through a real kaleidoscope, shot on expired 35mm film with heavy grain and light leaks, warm amber and deep red analog color palette, faded and sun-bleached, symmetrical radial reflections but organic and imperfect, washed out like the Geogaddi album cover, square format, lo-fi analog photography aesthetic, slight overexposure, nostalgic and haunting, no digital sharpness, no CGI, no illustration, no faces, no fingers, no text";
963
+ var PROMPT_NO_PHOTO = "Create an abstract kaleidoscope image with mirrored radial symmetry, analog film grain, warm color bleed, soft exposure, organic imperfections, and no text.";
892
964
 
893
965
  // ── Demo Flow ──
894
966
 
@@ -896,16 +968,19 @@ async function startDemo() {
896
968
  await delay(600);
897
969
  var isNew = sessionStorage.getItem('lesa-new-account') === 'true';
898
970
  sessionStorage.removeItem('lesa-new-account');
899
- await lesaSays("Hi, I'm L\u0113sa. Welcome to Kaleidoscope.", 1200);
900
- await delay(1000);
901
971
  if (isNew) {
902
- await lesaSays("You just created an account with a passkey. It lives on this device.", 1200);
972
+ IS_RETURNING_USER_FLOW = false;
973
+ await lesaSays("Hi, I'm L\u0113sa. Welcome to Kaleidoscope.", 1200);
974
+ await delay(1000);
975
+ await lesaSays("You just created an account with a passkey. The passkey lives on your phone.", 1200);
976
+ await lesaSays("Going forward, you can use your phone to log into any Work in Progress Computer service.", 1500);
977
+ await lesaSays("Anytime I need your permission to do something, I'll ask, and you authorize with your fingerprint or face. No passwords. Ever.", 1500);
978
+ await lesaSays("Want to see it in action? I'll try to do something that costs money, and you decide whether to let me.", 1200);
903
979
  } else {
904
- await lesaSays("You just logged in with your passkey.", 1200);
980
+ IS_RETURNING_USER_FLOW = true;
981
+ await lesaSays("Hi, " + currentAccountLabel() + ". Welcome back to Kaleidoscope.", 1200);
982
+ await lesaSays("Do you want to try giving me permission with your device to create something that costs money?", 1200);
905
983
  }
906
- await lesaSays("Going forward, you can use this passkey to log into any Work in Progress Computer service.", 1500);
907
- await lesaSays("And I can use it too. Anytime I need your permission to do something, I'll ask, and you authorize with your fingerprint or face. No passwords. Ever.", 1500);
908
- await lesaSays("Want to see it in action? I'll try to do something that costs money, and you decide whether to let me.", 1200);
909
984
 
910
985
  showChoiceButtons([
911
986
  { label: 'Yes, show me', primary: true, action: demoYesShowMe },
@@ -916,22 +991,16 @@ async function startDemo() {
916
991
  // ── "Yes, show me" path ──
917
992
 
918
993
  async function demoYesShowMe() {
919
- var walletBalance = WALLET_BALANCE;
920
- var walletCost = IMAGE_COST;
921
- try {
922
- var token = sessionStorage.getItem('lesa-token');
923
- var wRes = await fetch('/demo/api/wallet', { headers: { 'Authorization': 'Bearer ' + token } });
924
- var wData = await wRes.json();
925
- if (wData.balance) walletBalance = wData.balance;
926
- if (wData.cost) walletCost = wData.cost;
927
- } catch {}
928
- await lesaSays("I have a wallet with " + walletBalance + ". Do I have your permission to spend " + walletCost + " on image generation using the " + IMAGE_API_PROVIDER + " " + IMAGE_API_NAME + " API?", 1000);
994
+ var wallet = await getWalletSummary();
995
+ await lesaSays("I have a wallet with " + wallet.balance + ". Do I have your permission to spend " + wallet.cost + " on image generation using the " + IMAGE_API_PROVIDER + " " + IMAGE_API_NAME + " API?", 1000);
929
996
 
930
997
  showAuthButton();
931
998
  }
932
999
 
933
1000
  async function doFaceIDAuth() {
934
1001
  showTyping();
1002
+ var activeToken = sessionStorage.getItem('lesa-token');
1003
+ var activeTenant = sessionStorage.getItem('lesa-tenant');
935
1004
 
936
1005
  try {
937
1006
  var optRes = await fetch('/webauthn/auth-options', {
@@ -983,6 +1052,13 @@ async function doFaceIDAuth() {
983
1052
  return;
984
1053
  }
985
1054
 
1055
+ if (!isSameAccountAuthorization(result, activeToken, activeTenant)) {
1056
+ removeTyping();
1057
+ await lesaSays("That passkey belongs to a different account. This onboarding session is using " + currentAccountLabel() + ". Restart if you want to switch accounts.", 600);
1058
+ showAuthButton();
1059
+ return;
1060
+ }
1061
+
986
1062
  removeTyping();
987
1063
  await lesaSays("Thanks for authorizing. Let me show you what I can do.", 600);
988
1064
  await delay(500);
@@ -1081,13 +1157,32 @@ async function demoJustMakeOne() {
1081
1157
  // ── "No thanks" path (skip permissions entirely) ──
1082
1158
 
1083
1159
  async function demoNoThanks() {
1084
- await lesaSays("No problem. Let me make you something beautiful.", 1000);
1085
- await generateKaleidoscope(false);
1160
+ await showNoThanksFlow();
1161
+ }
1162
+
1163
+ async function showNoThanksFlow() {
1164
+ if (IS_RETURNING_USER_FLOW) {
1165
+ await showReturningUserNoThanksFlow();
1166
+ return;
1167
+ }
1168
+ await lesaSays("No problem. Let me show you something beautiful.", 1000);
1169
+ await generateKaleidoscope(false, { showWalletReceipt: false, showOutro: false });
1170
+ await showApprovedOutro();
1171
+ }
1172
+
1173
+ async function showReturningUserNoThanksFlow() {
1174
+ await lesaSays("No problem. You can come back anytime.", 1000);
1175
+ await lesaSays("At Work in Progress Computer, we are building the future of AI and human interaction.", 1500);
1176
+ await lesaSaysHTML('We look forward to providing you a streamlined experience across all the AIs you use. Follow us on X <a href="https://x.com/wipcomputer" target="_blank" rel="noopener">@wipcomputer</a> to keep up to date on when we release new features.', 1800);
1177
+ await lesaSays("Made in California by WIP Computer, Inc. Learning Dreaming Machines.", 1200);
1086
1178
  }
1087
1179
 
1088
1180
  // ── Image generation ──
1089
1181
 
1090
- async function generateKaleidoscope(isPhotoPath) {
1182
+ async function generateKaleidoscope(isPhotoPath, options) {
1183
+ options = options || {};
1184
+ var showWalletReceipt = options.showWalletReceipt !== false;
1185
+ var showDefaultOutro = options.showOutro !== false;
1091
1186
  var prompt = isPhotoPath ? PROMPT_PHOTO : PROMPT_NO_PHOTO;
1092
1187
 
1093
1188
  // If photo path, analyze the photo with vision to extract colors/mood
@@ -1104,7 +1199,7 @@ async function generateKaleidoscope(isPhotoPath) {
1104
1199
  });
1105
1200
  var analyzeData = await analyzeRes.json();
1106
1201
  if (analyzeData.description) {
1107
- prompt = "abstract kaleidoscope pattern, dominant colors: " + analyzeData.description + ", symmetrical radial reflections, mirrored geometric shapes in these exact colors, shot on expired 35mm film with heavy grain and light leaks, warm analog color bleed, faded and sun-bleached like a Boards of Canada album cover, no face, no person, no fingers, no spheres, no beads, no text, just abstract color and light";
1202
+ prompt = "Create an abstract kaleidoscope image from these visual details in the user's photo: " + analyzeData.description + ". Use mirrored radial symmetry, analog film grain, warm color bleed, soft exposure, organic imperfections, and no text.";
1108
1203
  }
1109
1204
  } catch (e) {
1110
1205
  // Vision analysis failed... fall back to original PROMPT_PHOTO
@@ -1146,10 +1241,12 @@ async function generateKaleidoscope(isPhotoPath) {
1146
1241
  genBubble.innerHTML = '<img class="kaleidoscope-img" src="' + data.url + '" alt="Your kaleidoscope">';
1147
1242
  scrollToBottom();
1148
1243
 
1149
- var cost = data.cost || IMAGE_COST;
1150
- var balance = data.balance || IMAGE_BALANCE_AFTER;
1151
- await lesaSays("Cost: " + cost + ". Balance: " + balance + ".", 800);
1152
- await showOutro();
1244
+ if (showWalletReceipt) {
1245
+ var cost = data.cost || IMAGE_COST;
1246
+ var balance = data.balance || IMAGE_BALANCE_AFTER;
1247
+ await lesaSays("Cost: " + cost + ". Balance: " + balance + ".", 800);
1248
+ }
1249
+ if (showDefaultOutro) await showOutro();
1153
1250
 
1154
1251
  } catch (err) {
1155
1252
  loadingDiv.remove();
@@ -1161,14 +1258,36 @@ async function generateKaleidoscope(isPhotoPath) {
1161
1258
  }
1162
1259
 
1163
1260
  async function showOutro() {
1164
- await lesaSays("At Work in Progress Computer we are building the future of AI and human interaction.", 1500);
1165
- showTyping(); await delay(2000); removeTyping();
1166
- await lesaSays("We believe permission is a conversation. Your AI asks. You decide. One glance, one tap.", 2000);
1167
- showTyping(); await delay(2000); removeTyping();
1168
- await lesaSays("To see how passkeys work across devices, open wip.computer/demo in any web browser on any computer. And use this device to authenticate.", 2000);
1169
- showTyping(); await delay(2000); removeTyping();
1170
- await lesaSays("Made in California by WIP Computer, Inc. Learning Dreaming Machines.", 1500);
1171
- await lesaSays("This is the end of the demo. Tap icon or refresh page to start over.", 1200);
1261
+ if (IS_RETURNING_USER_FLOW) {
1262
+ await showReturningUserOutro();
1263
+ return;
1264
+ }
1265
+ await showApprovedOutro();
1266
+ }
1267
+
1268
+ async function showApprovedOutro() {
1269
+ await lesaSays("At Work in Progress Computer, we are building the future of AI and human interaction.", 1500);
1270
+ await lesaSays("We believe permission is a conversation. Your AI asks. You decide. One glance, one tap.", 1800);
1271
+ await lesaSaysHTML('To see how passkeys work across devices, open <a href="https://wip.computer/demo" target="_blank" rel="noopener">https://wip.computer/demo</a> in any web browser on any computer and use your phone to authenticate.', 1800);
1272
+ await lesaSays("You can also click the icon at the top of this chat to log out, and then you can immediately log back in with your phone too.", 1800);
1273
+ await lesaSaysHTML('We look forward to providing you a streamlined experience across all the AIs you use. Follow us on X <a href="https://x.com/wipcomputer" target="_blank" rel="noopener">@wipcomputer</a> to keep up to date on when we release new features.', 1800);
1274
+ await lesaSays("Your passkey will keep working after you leave.", 1200);
1275
+ await lesaSays("Made in California by WIP Computer, Inc. Learning Dreaming Machines.", 1200);
1276
+ }
1277
+
1278
+ async function showReturningUserOutro() {
1279
+ await lesaSays("At Work in Progress Computer, we are building the future of AI and human interaction.", 1500);
1280
+ await lesaSays("We believe permission is a conversation. Your AI asks. You decide. One glance, one tap.", 1800);
1281
+ await lesaSaysHTML('We look forward to providing you a streamlined experience across all the AIs you use. Follow us on X <a href="https://x.com/wipcomputer" target="_blank" rel="noopener">@wipcomputer</a> to keep up to date on when we release new features.', 1800);
1282
+ await lesaSays("Made in California by WIP Computer, Inc. Learning Dreaming Machines.", 1200);
1283
+ }
1284
+
1285
+ function logoutAndSignBackIn() {
1286
+ sessionStorage.removeItem('lesa-token');
1287
+ sessionStorage.removeItem('lesa-agent');
1288
+ sessionStorage.removeItem('lesa-tenant');
1289
+ sessionStorage.removeItem('lesa-new-account');
1290
+ window.location.href = '/login?next=/demo';
1172
1291
  }
1173
1292
 
1174
1293
  async function doGameAuth() {
@@ -1191,7 +1310,7 @@ async function doGameAuth() {
1191
1310
  removeTyping();
1192
1311
  await lesaSays("You are not authorized to play war games with me.", 1200);
1193
1312
  }
1194
- await lesaSays("This is the end of the demo. To try again, refresh this page.", 1500);
1313
+ await lesaSays("Your passkey will continue to work.", 1500);
1195
1314
  }
1196
1315
 
1197
1316
  function showAuthButtonForGame() {
@@ -1203,7 +1322,7 @@ function showAuthButtonForGame() {
1203
1322
  btn.innerHTML = '<span style="filter:brightness(0) invert(1);">🫆</span> Authorize';
1204
1323
  btn.onclick = function() {
1205
1324
  container.remove();
1206
- addMessage('Authorized', 'user');
1325
+ addMessage('Authorizing', 'user');
1207
1326
  doGameAuth();
1208
1327
  };
1209
1328
  container.appendChild(btn);
@@ -1223,6 +1342,9 @@ function showCSSKaleidoscope(bubble) {
1223
1342
 
1224
1343
  function showChat() {
1225
1344
  document.getElementById('loginPage').style.display = 'none';
1345
+ document.body.classList.add('chat-active');
1346
+ var footer = document.getElementById('kscope-footer');
1347
+ if (footer) footer.style.display = 'none';
1226
1348
  var chatPage = document.getElementById('chatPage');
1227
1349
  chatPage.style.display = 'flex';
1228
1350
  startDemo();
@@ -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
 
@@ -150,11 +150,10 @@ html, body {
150
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>
157
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>
158
157
  </div>
159
158
 
160
159
  <!-- Pair-approved view (CRC pair-mode, desktop-side after phone approves) -->
@@ -163,6 +162,15 @@ html, body {
163
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>
164
163
  </div>
165
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>
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>
172
+ </div>
173
+
166
174
  <div class="login-status" id="loginStatus" style="position:absolute;left:0;right:0;margin-top:16px;text-align:center;"></div>
167
175
  </div>
168
176
  </div>
@@ -234,12 +242,22 @@ function isSafariDesktop() {
234
242
  }
235
243
 
236
244
  function needsCustomQR() {
237
- return !isMobileDevice() && !isSafariDesktop() && !isLocalPasskeysOn();
245
+ return !isLocalPasskeysOn();
238
246
  }
239
247
 
240
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
+
241
259
  function isLocalPasskeysOn() {
242
- return localStorage.getItem('localPasskeys') === 'on';
260
+ return getLocalPasskeysPreference() === 'on';
243
261
  }
244
262
 
245
263
  function toggleLocalPasskeys() {
@@ -265,7 +283,7 @@ function updatePasskeysDot() {
265
283
 
266
284
  // ── `next` continuation carrier ──
267
285
  //
268
- // Two whitelisted next shapes:
286
+ // Three whitelisted next shapes:
269
287
  //
270
288
  // PAIR_NEXT_REGEX /pair/<CODE> using the daemon alphabet
271
289
  // (CODEX_PAIR_ALPHABET, length 6, L is
@@ -278,14 +296,18 @@ function updatePasskeysDot() {
278
296
  // desktop and mobile (this is navigation
279
297
  // continuation, not authority transfer).
280
298
  //
299
+ // DEMO_NEXT_REGEX /demo. Standard post-login continuation
300
+ // from the homepage CTA.
301
+ //
281
302
  // Anything else is silently dropped. `next` is NOT a general redirect
282
303
  // primitive. The server validates authoritatively; this client-side
283
304
  // check is defense-in-depth.
284
305
  var PAIR_NEXT_REGEX = /^\/pair\/[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/;
285
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$/;
286
308
 
287
309
  function isWhitelistedNext(raw) {
288
- return typeof raw === 'string' && (PAIR_NEXT_REGEX.test(raw) || REMOTE_CONTROL_NEXT_REGEX.test(raw));
310
+ return typeof raw === 'string' && (PAIR_NEXT_REGEX.test(raw) || REMOTE_CONTROL_NEXT_REGEX.test(raw) || DEMO_NEXT_REGEX.test(raw));
289
311
  }
290
312
 
291
313
  function readPairNextFromQuery() {
@@ -367,7 +389,7 @@ function redirectToRemoteControlIfDirectLogin(result) {
367
389
  // authority rules from plan C6 + C8:
368
390
  //
369
391
  // - QR-scan path (approveResponse.next from the server): follow it for
370
- // either whitelisted shape. The phone scanned the desktop's QR,
392
+ // a whitelisted next. The phone scanned the desktop's QR,
371
393
  // signed in, and the server returned a sanitized `next`.
372
394
  //
373
395
  // - URL fallback path (no approveResponse.next):
@@ -385,10 +407,13 @@ function redirectToRemoteControlIfDirectLogin(result) {
385
407
  // rule because it's a navigation continuation, not authority transfer.
386
408
  //
387
409
  // Returns true if it redirected (caller should not run other view-switching).
388
- async function followPairNextIfPresent(approveResponse, identity) {
410
+ async function followPairNextIfPresent(approveResponse, identity, isNewAccount) {
389
411
  var next = null;
390
- // Path 1: QR phone-side approve. Server-blessed next, either shape.
412
+ // Path 1: QR phone-side approve. Server-blessed next.
391
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
+ }
392
417
  next = approveResponse.next;
393
418
  } else {
394
419
  // Path 2: URL ?next= fallback (no approveResponse). Branch by
@@ -396,7 +421,7 @@ async function followPairNextIfPresent(approveResponse, identity) {
396
421
  // restricted to mobile per C8.
397
422
  var urlNext = readPairNextFromQuery();
398
423
  if (urlNext) {
399
- if (REMOTE_CONTROL_NEXT_REGEX.test(urlNext)) {
424
+ if (REMOTE_CONTROL_NEXT_REGEX.test(urlNext) || DEMO_NEXT_REGEX.test(urlNext)) {
400
425
  next = urlNext;
401
426
  } else if (PAIR_NEXT_REGEX.test(urlNext) && isMobileDevice()) {
402
427
  next = urlNext;
@@ -418,17 +443,50 @@ async function followPairNextIfPresent(approveResponse, identity) {
418
443
  }
419
444
  sessionStorage.setItem('wip_api_key', identity.apiKey);
420
445
  if (identity.agentId) sessionStorage.setItem('wip_handle', identity.agentId);
446
+ storeDemoHandoffIfNeeded(next, identity, isNewAccount === true);
421
447
  location.replace(next);
422
448
  return true;
423
449
  }
424
450
 
425
451
  // ── QR Login (Chrome desktop fallback) ──
426
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
+ }
427
484
 
428
485
  async function startQrLogin(handle, mode) {
429
486
  var btn = document.getElementById('createBtn');
430
487
  btn.disabled = true;
431
488
  setStatus('', '');
489
+ qrLoginMode = mode || 'register';
432
490
 
433
491
  try {
434
492
  var pairNext = readPairNextFromQuery();
@@ -499,13 +557,15 @@ async function pollQrLogin(sessionId) {
499
557
  var urlNext = readPairNextFromQuery();
500
558
  if (urlNext && PAIR_NEXT_REGEX.test(urlNext)) {
501
559
  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.
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.
505
563
  sessionStorage.setItem('wip_api_key', data.apiKey);
506
564
  if (data.agentId) sessionStorage.setItem('wip_handle', data.agentId);
565
+ storeDemoHandoffIfNeeded(data.next, data, qrLoginMode === 'register');
507
566
  location.replace(data.next);
508
567
  } else {
568
+ storeDemoHandoffIfNeeded('/demo', data, qrLoginMode === 'register');
509
569
  document.getElementById('success-view').style.display = 'block';
510
570
  // Use credentialLabel (matches the saved-passkey label the user
511
571
  // sees in iOS Passwords / 1Password). Falls back to agentId for
@@ -523,6 +583,7 @@ function cancelQrLogin() {
523
583
  clearInterval(qrLoginPollTimer);
524
584
  qrLoginPollTimer = null;
525
585
  }
586
+ qrLoginMode = null;
526
587
  document.getElementById('qr-view').style.display = 'none';
527
588
  document.getElementById('signup-view').style.display = 'block';
528
589
  document.getElementById('createBtn').disabled = false;
@@ -594,7 +655,7 @@ async function doCreateAccount() {
594
655
  return;
595
656
  }
596
657
 
597
- // Chrome desktop without local passkeys: use custom QR code
658
+ // Local passkeys off means use WIP's QR cross-device login on any device.
598
659
  if (needsCustomQR() && !qrSessionMode) {
599
660
  startQrLogin(username);
600
661
  return;
@@ -672,6 +733,7 @@ async function doCreateAccount() {
672
733
  sessionId: qrSessionId,
673
734
  agentId: result.agentId,
674
735
  apiKey: result.apiKey,
736
+ tenantId: result.tenantId,
675
737
  credentialLabel: result.credentialLabel,
676
738
  }),
677
739
  });
@@ -682,13 +744,13 @@ async function doCreateAccount() {
682
744
  // CRC pair-mode: if the server returned a sanitized `next`, store
683
745
  // the api_key on the phone and redirect to /pair/<CODE>. Phone is
684
746
  // the actor that completes pair-complete (plan C6).
685
- if (await followPairNextIfPresent(approveResponse, result)) return;
747
+ if (await followPairNextIfPresent(approveResponse, result, true)) return;
748
+ storeDemoHandoffIfNeeded('/demo', result, true);
686
749
  document.getElementById('signup-view').style.display = 'none';
687
750
  document.getElementById('success-view').style.display = 'block';
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';
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';
692
754
  setStatus('', '');
693
755
  } else {
694
756
  setStatus(result.error || 'Registration failed', 'error');
@@ -727,7 +789,7 @@ async function doSignIn() {
727
789
  return;
728
790
  }
729
791
 
730
- // Chrome desktop without local passkeys: use QR code for sign-in.
792
+ // Local passkeys off means use WIP's QR cross-device login on any device.
731
793
  // With local passkeys on, let Chrome show native dialog (supports YubiKey).
732
794
  if (needsCustomQR() && !qrSessionMode) {
733
795
  startQrLogin('', 'signin');
@@ -791,6 +853,7 @@ async function doSignIn() {
791
853
  sessionId: qrSessionId,
792
854
  agentId: result.agentId,
793
855
  apiKey: result.apiKey,
856
+ tenantId: result.tenantId,
794
857
  credentialLabel: result.credentialLabel,
795
858
  }),
796
859
  });
@@ -799,7 +862,8 @@ async function doSignIn() {
799
862
  // CRC pair-mode: if the server returned a sanitized `next`, store
800
863
  // the api_key on the phone and redirect to /pair/<CODE>. Phone is
801
864
  // the actor that completes pair-complete (plan C6).
802
- if (await followPairNextIfPresent(approveResponse, result)) return;
865
+ if (await followPairNextIfPresent(approveResponse, result, false)) return;
866
+ storeDemoHandoffIfNeeded('/demo', result, false);
803
867
  document.getElementById('signup-view').style.display = 'none';
804
868
  document.getElementById('success-view').style.display = 'block';
805
869
  // Use credentialLabel from the server, which matches the saved-